]> git.proxmox.com Git - ceph.git/blame - ceph/src/pybind/mgr/dashboard/static/AdminLTE-2.3.7/plugins/fullcalendar/fullcalendar.js
bump version to 12.2.2-pve1
[ceph.git] / ceph / src / pybind / mgr / dashboard / static / AdminLTE-2.3.7 / plugins / fullcalendar / fullcalendar.js
CommitLineData
31f18b77
FG
1/*!
2 * FullCalendar v2.2.5
3 * Docs & License: http://arshaw.com/fullcalendar/
4 * (c) 2013 Adam Shaw
5 */
6
7(function(factory) {
8 if (typeof define === 'function' && define.amd) {
9 define([ 'jquery', 'moment' ], factory);
10 }
11 else {
12 factory(jQuery, moment);
13 }
14})(function($, moment) {
15
16 var defaults = {
17
18 titleRangeSeparator: ' \u2014 ', // emphasized dash
19 monthYearFormat: 'MMMM YYYY', // required for en. other languages rely on datepicker computable option
20
21 defaultTimedEventDuration: '02:00:00',
22 defaultAllDayEventDuration: { days: 1 },
23 forceEventDuration: false,
24 nextDayThreshold: '09:00:00', // 9am
25
26 // display
27 defaultView: 'month',
28 aspectRatio: 1.35,
29 header: {
30 left: 'title',
31 center: '',
32 right: 'today prev,next'
33 },
34 weekends: true,
35 weekNumbers: false,
36
37 weekNumberTitle: 'W',
38 weekNumberCalculation: 'local',
39
40 //editable: false,
41
42 // event ajax
43 lazyFetching: true,
44 startParam: 'start',
45 endParam: 'end',
46 timezoneParam: 'timezone',
47
48 timezone: false,
49
50 //allDayDefault: undefined,
51
52 // locale
53 isRTL: false,
54 defaultButtonText: {
55 prev: "prev",
56 next: "next",
57 prevYear: "prev year",
58 nextYear: "next year",
59 today: 'today',
60 month: 'month',
61 week: 'week',
62 day: 'day'
63 },
64
65 buttonIcons: {
66 prev: 'left-single-arrow',
67 next: 'right-single-arrow',
68 prevYear: 'left-double-arrow',
69 nextYear: 'right-double-arrow'
70 },
71
72 // jquery-ui theming
73 theme: false,
74 themeButtonIcons: {
75 prev: 'circle-triangle-w',
76 next: 'circle-triangle-e',
77 prevYear: 'seek-prev',
78 nextYear: 'seek-next'
79 },
80
81 dragOpacity: .75,
82 dragRevertDuration: 500,
83 dragScroll: true,
84
85 //selectable: false,
86 unselectAuto: true,
87
88 dropAccept: '*',
89
90 eventLimit: false,
91 eventLimitText: 'more',
92 eventLimitClick: 'popover',
93 dayPopoverFormat: 'LL',
94
95 handleWindowResize: true,
96 windowResizeDelay: 200 // milliseconds before an updateSize happens
97
98};
99
100
101var englishDefaults = {
102 dayPopoverFormat: 'dddd, MMMM D'
103};
104
105
106// right-to-left defaults
107var rtlDefaults = {
108 header: {
109 left: 'next,prev today',
110 center: '',
111 right: 'title'
112 },
113 buttonIcons: {
114 prev: 'right-single-arrow',
115 next: 'left-single-arrow',
116 prevYear: 'right-double-arrow',
117 nextYear: 'left-double-arrow'
118 },
119 themeButtonIcons: {
120 prev: 'circle-triangle-e',
121 next: 'circle-triangle-w',
122 nextYear: 'seek-prev',
123 prevYear: 'seek-next'
124 }
125};
126
127 var fc = $.fullCalendar = { version: "2.2.5" };
128var fcViews = fc.views = {};
129
130
131$.fn.fullCalendar = function(options) {
132 var args = Array.prototype.slice.call(arguments, 1); // for a possible method call
133 var res = this; // what this function will return (this jQuery object by default)
134
135 this.each(function(i, _element) { // loop each DOM element involved
136 var element = $(_element);
137 var calendar = element.data('fullCalendar'); // get the existing calendar object (if any)
138 var singleRes; // the returned value of this single method call
139
140 // a method call
141 if (typeof options === 'string') {
142 if (calendar && $.isFunction(calendar[options])) {
143 singleRes = calendar[options].apply(calendar, args);
144 if (!i) {
145 res = singleRes; // record the first method call result
146 }
147 if (options === 'destroy') { // for the destroy method, must remove Calendar object data
148 element.removeData('fullCalendar');
149 }
150 }
151 }
152 // a new calendar initialization
153 else if (!calendar) { // don't initialize twice
154 calendar = new Calendar(element, options);
155 element.data('fullCalendar', calendar);
156 calendar.render();
157 }
158 });
159
160 return res;
161};
162
163
164// function for adding/overriding defaults
165function setDefaults(d) {
166 mergeOptions(defaults, d);
167}
168
169
170// Recursively combines option hash-objects.
171// Better than `$.extend(true, ...)` because arrays are not traversed/copied.
172//
173// called like:
174// mergeOptions(target, obj1, obj2, ...)
175//
176function mergeOptions(target) {
177
178 function mergeIntoTarget(name, value) {
179 if ($.isPlainObject(value) && $.isPlainObject(target[name]) && !isForcedAtomicOption(name)) {
180 // merge into a new object to avoid destruction
181 target[name] = mergeOptions({}, target[name], value); // combine. `value` object takes precedence
182 }
183 else if (value !== undefined) { // only use values that are set and not undefined
184 target[name] = value;
185 }
186 }
187
188 for (var i=1; i<arguments.length; i++) {
189 $.each(arguments[i], mergeIntoTarget);
190 }
191
192 return target;
193}
194
195
196// overcome sucky view-option-hash and option-merging behavior messing with options it shouldn't
197function isForcedAtomicOption(name) {
198 // Any option that ends in "Time" or "Duration" is probably a Duration,
199 // and these will commonly be specified as plain objects, which we don't want to mess up.
200 return /(Time|Duration)$/.test(name);
201}
202// FIX: find a different solution for view-option-hashes and have a whitelist
203// for options that can be recursively merged.
204
205 var langOptionHash = fc.langs = {}; // initialize and expose
206
207
208// TODO: document the structure and ordering of a FullCalendar lang file
209// TODO: rename everything "lang" to "locale", like what the moment project did
210
211
212// Initialize jQuery UI datepicker translations while using some of the translations
213// Will set this as the default language for datepicker.
214fc.datepickerLang = function(langCode, dpLangCode, dpOptions) {
215
216 // get the FullCalendar internal option hash for this language. create if necessary
217 var fcOptions = langOptionHash[langCode] || (langOptionHash[langCode] = {});
218
219 // transfer some simple options from datepicker to fc
220 fcOptions.isRTL = dpOptions.isRTL;
221 fcOptions.weekNumberTitle = dpOptions.weekHeader;
222
223 // compute some more complex options from datepicker
224 $.each(dpComputableOptions, function(name, func) {
225 fcOptions[name] = func(dpOptions);
226 });
227
228 // is jQuery UI Datepicker is on the page?
229 if ($.datepicker) {
230
231 // Register the language data.
232 // FullCalendar and MomentJS use language codes like "pt-br" but Datepicker
233 // does it like "pt-BR" or if it doesn't have the language, maybe just "pt".
234 // Make an alias so the language can be referenced either way.
235 $.datepicker.regional[dpLangCode] =
236 $.datepicker.regional[langCode] = // alias
237 dpOptions;
238
239 // Alias 'en' to the default language data. Do this every time.
240 $.datepicker.regional.en = $.datepicker.regional[''];
241
242 // Set as Datepicker's global defaults.
243 $.datepicker.setDefaults(dpOptions);
244 }
245};
246
247
248// Sets FullCalendar-specific translations. Will set the language as the global default.
249fc.lang = function(langCode, newFcOptions) {
250 var fcOptions;
251 var momOptions;
252
253 // get the FullCalendar internal option hash for this language. create if necessary
254 fcOptions = langOptionHash[langCode] || (langOptionHash[langCode] = {});
255
256 // provided new options for this language? merge them in
257 if (newFcOptions) {
258 mergeOptions(fcOptions, newFcOptions);
259 }
260
261 // compute language options that weren't defined.
262 // always do this. newFcOptions can be undefined when initializing from i18n file,
263 // so no way to tell if this is an initialization or a default-setting.
264 momOptions = getMomentLocaleData(langCode); // will fall back to en
265 $.each(momComputableOptions, function(name, func) {
266 if (fcOptions[name] === undefined) {
267 fcOptions[name] = func(momOptions, fcOptions);
268 }
269 });
270
271 // set it as the default language for FullCalendar
272 defaults.lang = langCode;
273};
274
275
276// NOTE: can't guarantee any of these computations will run because not every language has datepicker
277// configs, so make sure there are English fallbacks for these in the defaults file.
278var dpComputableOptions = {
279
280 defaultButtonText: function(dpOptions) {
281 return {
282 // the translations sometimes wrongly contain HTML entities
283 prev: stripHtmlEntities(dpOptions.prevText),
284 next: stripHtmlEntities(dpOptions.nextText),
285 today: stripHtmlEntities(dpOptions.currentText)
286 };
287 },
288
289 // Produces format strings like "MMMM YYYY" -> "September 2014"
290 monthYearFormat: function(dpOptions) {
291 return dpOptions.showMonthAfterYear ?
292 'YYYY[' + dpOptions.yearSuffix + '] MMMM' :
293 'MMMM YYYY[' + dpOptions.yearSuffix + ']';
294 }
295
296};
297
298var momComputableOptions = {
299
300 // Produces format strings like "ddd MM/DD" -> "Fri 12/10"
301 dayOfMonthFormat: function(momOptions, fcOptions) {
302 var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY"
303
304 // strip the year off the edge, as well as other misc non-whitespace chars
305 format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, '');
306
307 if (fcOptions.isRTL) {
308 format += ' ddd'; // for RTL, add day-of-week to end
309 }
310 else {
311 format = 'ddd ' + format; // for LTR, add day-of-week to beginning
312 }
313 return format;
314 },
315
316 // Produces format strings like "H(:mm)a" -> "6pm" or "6:30pm"
317 smallTimeFormat: function(momOptions) {
318 return momOptions.longDateFormat('LT')
319 .replace(':mm', '(:mm)')
320 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs
321 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
322 },
323
324 // Produces format strings like "H(:mm)t" -> "6p" or "6:30p"
325 extraSmallTimeFormat: function(momOptions) {
326 return momOptions.longDateFormat('LT')
327 .replace(':mm', '(:mm)')
328 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs
329 .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand
330 },
331
332 // Produces format strings like "H:mm" -> "6:30" (with no AM/PM)
333 noMeridiemTimeFormat: function(momOptions) {
334 return momOptions.longDateFormat('LT')
335 .replace(/\s*a$/i, ''); // remove trailing AM/PM
336 }
337
338};
339
340
341// Returns moment's internal locale data. If doesn't exist, returns English.
342// Works with moment-pre-2.8
343function getMomentLocaleData(langCode) {
344 var func = moment.localeData || moment.langData;
345 return func.call(moment, langCode) ||
346 func.call(moment, 'en'); // the newer localData could return null, so fall back to en
347}
348
349
350// Initialize English by forcing computation of moment-derived options.
351// Also, sets it as the default.
352fc.lang('en', englishDefaults);
353
354// exports
355fc.intersectionToSeg = intersectionToSeg;
356fc.applyAll = applyAll;
357fc.debounce = debounce;
358
359
360/* FullCalendar-specific DOM Utilities
361----------------------------------------------------------------------------------------------------------------------*/
362
363
364// Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left
365// and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that.
366function compensateScroll(rowEls, scrollbarWidths) {
367 if (scrollbarWidths.left) {
368 rowEls.css({
369 'border-left-width': 1,
370 'margin-left': scrollbarWidths.left - 1
371 });
372 }
373 if (scrollbarWidths.right) {
374 rowEls.css({
375 'border-right-width': 1,
376 'margin-right': scrollbarWidths.right - 1
377 });
378 }
379}
380
381
382// Undoes compensateScroll and restores all borders/margins
383function uncompensateScroll(rowEls) {
384 rowEls.css({
385 'margin-left': '',
386 'margin-right': '',
387 'border-left-width': '',
388 'border-right-width': ''
389 });
390}
391
392
393// Make the mouse cursor express that an event is not allowed in the current area
394function disableCursor() {
395 $('body').addClass('fc-not-allowed');
396}
397
398
399// Returns the mouse cursor to its original look
400function enableCursor() {
401 $('body').removeClass('fc-not-allowed');
402}
403
404
405// Given a total available height to fill, have `els` (essentially child rows) expand to accomodate.
406// By default, all elements that are shorter than the recommended height are expanded uniformly, not considering
407// any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and
408// reduces the available height.
409function distributeHeight(els, availableHeight, shouldRedistribute) {
410
411 // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions,
412 // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars.
413
414 var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element
415 var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE*
416 var flexEls = []; // elements that are allowed to expand. array of DOM nodes
417 var flexOffsets = []; // amount of vertical space it takes up
418 var flexHeights = []; // actual css height
419 var usedHeight = 0;
420
421 undistributeHeight(els); // give all elements their natural height
422
423 // find elements that are below the recommended height (expandable).
424 // important to query for heights in a single first pass (to avoid reflow oscillation).
425 els.each(function(i, el) {
426 var minOffset = i === els.length - 1 ? minOffset2 : minOffset1;
427 var naturalOffset = $(el).outerHeight(true);
428
429 if (naturalOffset < minOffset) {
430 flexEls.push(el);
431 flexOffsets.push(naturalOffset);
432 flexHeights.push($(el).height());
433 }
434 else {
435 // this element stretches past recommended height (non-expandable). mark the space as occupied.
436 usedHeight += naturalOffset;
437 }
438 });
439
440 // readjust the recommended height to only consider the height available to non-maxed-out rows.
441 if (shouldRedistribute) {
442 availableHeight -= usedHeight;
443 minOffset1 = Math.floor(availableHeight / flexEls.length);
444 minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE*
445 }
446
447 // assign heights to all expandable elements
448 $(flexEls).each(function(i, el) {
449 var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1;
450 var naturalOffset = flexOffsets[i];
451 var naturalHeight = flexHeights[i];
452 var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding
453
454 if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things
455 $(el).height(newHeight);
456 }
457 });
458}
459
460
461// Undoes distrubuteHeight, restoring all els to their natural height
462function undistributeHeight(els) {
463 els.height('');
464}
465
466
467// Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the
468// cells to be that width.
469// PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline
470function matchCellWidths(els) {
471 var maxInnerWidth = 0;
472
473 els.find('> *').each(function(i, innerEl) {
474 var innerWidth = $(innerEl).outerWidth();
475 if (innerWidth > maxInnerWidth) {
476 maxInnerWidth = innerWidth;
477 }
478 });
479
480 maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance
481
482 els.width(maxInnerWidth);
483
484 return maxInnerWidth;
485}
486
487
488// Turns a container element into a scroller if its contents is taller than the allotted height.
489// Returns true if the element is now a scroller, false otherwise.
490// NOTE: this method is best because it takes weird zooming dimensions into account
491function setPotentialScroller(containerEl, height) {
492 containerEl.height(height).addClass('fc-scroller');
493
494 // are scrollbars needed?
495 if (containerEl[0].scrollHeight - 1 > containerEl[0].clientHeight) { // !!! -1 because IE is often off-by-one :(
496 return true;
497 }
498
499 unsetScroller(containerEl); // undo
500 return false;
501}
502
503
504// Takes an element that might have been a scroller, and turns it back into a normal element.
505function unsetScroller(containerEl) {
506 containerEl.height('').removeClass('fc-scroller');
507}
508
509
510/* General DOM Utilities
511----------------------------------------------------------------------------------------------------------------------*/
512
513
514// borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51
515function getScrollParent(el) {
516 var position = el.css('position'),
517 scrollParent = el.parents().filter(function() {
518 var parent = $(this);
519 return (/(auto|scroll)/).test(
520 parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x')
521 );
522 }).eq(0);
523
524 return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent;
525}
526
527
528// Given a container element, return an object with the pixel values of the left/right scrollbars.
529// Left scrollbars might occur on RTL browsers (IE maybe?) but I have not tested.
530// PREREQUISITE: container element must have a single child with display:block
531function getScrollbarWidths(container) {
532 var containerLeft = container.offset().left;
533 var containerRight = containerLeft + container.width();
534 var inner = container.children();
535 var innerLeft = inner.offset().left;
536 var innerRight = innerLeft + inner.outerWidth();
537
538 return {
539 left: innerLeft - containerLeft,
540 right: containerRight - innerRight
541 };
542}
543
544
545// Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac)
546function isPrimaryMouseButton(ev) {
547 return ev.which == 1 && !ev.ctrlKey;
548}
549
550
551/* FullCalendar-specific Misc Utilities
552----------------------------------------------------------------------------------------------------------------------*/
553
554
555// Creates a basic segment with the intersection of the two ranges. Returns undefined if no intersection.
556// Expects all dates to be normalized to the same timezone beforehand.
557// TODO: move to date section?
558function intersectionToSeg(subjectRange, constraintRange) {
559 var subjectStart = subjectRange.start;
560 var subjectEnd = subjectRange.end;
561 var constraintStart = constraintRange.start;
562 var constraintEnd = constraintRange.end;
563 var segStart, segEnd;
564 var isStart, isEnd;
565
566 if (subjectEnd > constraintStart && subjectStart < constraintEnd) { // in bounds at all?
567
568 if (subjectStart >= constraintStart) {
569 segStart = subjectStart.clone();
570 isStart = true;
571 }
572 else {
573 segStart = constraintStart.clone();
574 isStart = false;
575 }
576
577 if (subjectEnd <= constraintEnd) {
578 segEnd = subjectEnd.clone();
579 isEnd = true;
580 }
581 else {
582 segEnd = constraintEnd.clone();
583 isEnd = false;
584 }
585
586 return {
587 start: segStart,
588 end: segEnd,
589 isStart: isStart,
590 isEnd: isEnd
591 };
592 }
593}
594
595
596function smartProperty(obj, name) { // get a camel-cased/namespaced property of an object
597 obj = obj || {};
598 if (obj[name] !== undefined) {
599 return obj[name];
600 }
601 var parts = name.split(/(?=[A-Z])/),
602 i = parts.length - 1, res;
603 for (; i>=0; i--) {
604 res = obj[parts[i].toLowerCase()];
605 if (res !== undefined) {
606 return res;
607 }
608 }
609 return obj['default'];
610}
611
612
613/* Date Utilities
614----------------------------------------------------------------------------------------------------------------------*/
615
616var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ];
617var intervalUnits = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ];
618
619
620// Diffs the two moments into a Duration where full-days are recorded first, then the remaining time.
621// Moments will have their timezones normalized.
622function diffDayTime(a, b) {
623 return moment.duration({
624 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'),
625 ms: a.time() - b.time() // time-of-day from day start. disregards timezone
626 });
627}
628
629
630// Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations.
631function diffDay(a, b) {
632 return moment.duration({
633 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days')
634 });
635}
636
637
638// Computes the larges whole-unit period of time, as a duration object.
639// For example, 48 hours will be {days:2} whereas 49 hours will be {hours:49}.
640// Accepts start/end, a range object, or an original duration object.
641/* (never used)
642function computeIntervalDuration(start, end) {
643 var durationInput = {};
644 var i, unit;
645 var val;
646
647 for (i = 0; i < intervalUnits.length; i++) {
648 unit = intervalUnits[i];
649 val = computeIntervalAs(unit, start, end);
650 if (val) {
651 break;
652 }
653 }
654
655 durationInput[unit] = val;
656 return moment.duration(durationInput);
657}
658*/
659
660
661// Computes the unit name of the largest whole-unit period of time.
662// For example, 48 hours will be "days" wherewas 49 hours will be "hours".
663// Accepts start/end, a range object, or an original duration object.
664function computeIntervalUnit(start, end) {
665 var i, unit;
666
667 for (i = 0; i < intervalUnits.length; i++) {
668 unit = intervalUnits[i];
669 if (computeIntervalAs(unit, start, end)) {
670 break;
671 }
672 }
673
674 return unit; // will be "milliseconds" if nothing else matches
675}
676
677
678// Computes the number of units the interval is cleanly comprised of.
679// If the given unit does not cleanly divide the interval a whole number of times, `false` is returned.
680// Accepts start/end, a range object, or an original duration object.
681function computeIntervalAs(unit, start, end) {
682 var val;
683
684 if (end != null) { // given start, end
685 val = end.diff(start, unit, true);
686 }
687 else if (moment.isDuration(start)) { // given duration
688 val = start.as(unit);
689 }
690 else { // given { start, end } range object
691 val = start.end.diff(start.start, unit, true);
692 }
693
694 if (val >= 1 && isInt(val)) {
695 return val;
696 }
697
698 return false;
699}
700
701
702function isNativeDate(input) {
703 return Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date;
704}
705
706
707// Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00"
708function isTimeString(str) {
709 return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str);
710}
711
712
713/* General Utilities
714----------------------------------------------------------------------------------------------------------------------*/
715
716var hasOwnPropMethod = {}.hasOwnProperty;
717
718
719// Create an object that has the given prototype. Just like Object.create
720function createObject(proto) {
721 var f = function() {};
722 f.prototype = proto;
723 return new f();
724}
725
726
727function copyOwnProps(src, dest) {
728 for (var name in src) {
729 if (hasOwnProp(src, name)) {
730 dest[name] = src[name];
731 }
732 }
733}
734
735
736function hasOwnProp(obj, name) {
737 return hasOwnPropMethod.call(obj, name);
738}
739
740
741// Is the given value a non-object non-function value?
742function isAtomic(val) {
743 return /undefined|null|boolean|number|string/.test($.type(val));
744}
745
746
747function applyAll(functions, thisObj, args) {
748 if ($.isFunction(functions)) {
749 functions = [ functions ];
750 }
751 if (functions) {
752 var i;
753 var ret;
754 for (i=0; i<functions.length; i++) {
755 ret = functions[i].apply(thisObj, args) || ret;
756 }
757 return ret;
758 }
759}
760
761
762function firstDefined() {
763 for (var i=0; i<arguments.length; i++) {
764 if (arguments[i] !== undefined) {
765 return arguments[i];
766 }
767 }
768}
769
770
771function htmlEscape(s) {
772 return (s + '').replace(/&/g, '&amp;')
773 .replace(/</g, '&lt;')
774 .replace(/>/g, '&gt;')
775 .replace(/'/g, '&#039;')
776 .replace(/"/g, '&quot;')
777 .replace(/\n/g, '<br />');
778}
779
780
781function stripHtmlEntities(text) {
782 return text.replace(/&.*?;/g, '');
783}
784
785
786function capitaliseFirstLetter(str) {
787 return str.charAt(0).toUpperCase() + str.slice(1);
788}
789
790
791function compareNumbers(a, b) { // for .sort()
792 return a - b;
793}
794
795
796function isInt(n) {
797 return n % 1 === 0;
798}
799
800
801// Returns a function, that, as long as it continues to be invoked, will not
802// be triggered. The function will be called after it stops being called for
803// N milliseconds.
804// https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714
805function debounce(func, wait) {
806 var timeoutId;
807 var args;
808 var context;
809 var timestamp; // of most recent call
810 var later = function() {
811 var last = +new Date() - timestamp;
812 if (last < wait && last > 0) {
813 timeoutId = setTimeout(later, wait - last);
814 }
815 else {
816 timeoutId = null;
817 func.apply(context, args);
818 if (!timeoutId) {
819 context = args = null;
820 }
821 }
822 };
823
824 return function() {
825 context = this;
826 args = arguments;
827 timestamp = +new Date();
828 if (!timeoutId) {
829 timeoutId = setTimeout(later, wait);
830 }
831 };
832}
833
834 var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/;
835var ambigTimeOrZoneRegex =
836 /^\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+)?)?)?)?)?$/;
837var newMomentProto = moment.fn; // where we will attach our new methods
838var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods
839var allowValueOptimization;
840var setUTCValues; // function defined below
841var setLocalValues; // function defined below
842
843
844// Creating
845// -------------------------------------------------------------------------------------------------
846
847// Creates a new moment, similar to the vanilla moment(...) constructor, but with
848// extra features (ambiguous time, enhanced formatting). When given an existing moment,
849// it will function as a clone (and retain the zone of the moment). Anything else will
850// result in a moment in the local zone.
851fc.moment = function() {
852 return makeMoment(arguments);
853};
854
855// Sames as fc.moment, but forces the resulting moment to be in the UTC timezone.
856fc.moment.utc = function() {
857 var mom = makeMoment(arguments, true);
858
859 // Force it into UTC because makeMoment doesn't guarantee it
860 // (if given a pre-existing moment for example)
861 if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone
862 mom.utc();
863 }
864
865 return mom;
866};
867
868// Same as fc.moment, but when given an ISO8601 string, the timezone offset is preserved.
869// ISO8601 strings with no timezone offset will become ambiguously zoned.
870fc.moment.parseZone = function() {
871 return makeMoment(arguments, true, true);
872};
873
874// Builds an enhanced moment from args. When given an existing moment, it clones. When given a
875// native Date, or called with no arguments (the current time), the resulting moment will be local.
876// Anything else needs to be "parsed" (a string or an array), and will be affected by:
877// parseAsUTC - if there is no zone information, should we parse the input in UTC?
878// parseZone - if there is zone information, should we force the zone of the moment?
879function makeMoment(args, parseAsUTC, parseZone) {
880 var input = args[0];
881 var isSingleString = args.length == 1 && typeof input === 'string';
882 var isAmbigTime;
883 var isAmbigZone;
884 var ambigMatch;
885 var mom;
886
887 if (moment.isMoment(input)) {
888 mom = moment.apply(null, args); // clone it
889 transferAmbigs(input, mom); // the ambig flags weren't transfered with the clone
890 }
891 else if (isNativeDate(input) || input === undefined) {
892 mom = moment.apply(null, args); // will be local
893 }
894 else { // "parsing" is required
895 isAmbigTime = false;
896 isAmbigZone = false;
897
898 if (isSingleString) {
899 if (ambigDateOfMonthRegex.test(input)) {
900 // accept strings like '2014-05', but convert to the first of the month
901 input += '-01';
902 args = [ input ]; // for when we pass it on to moment's constructor
903 isAmbigTime = true;
904 isAmbigZone = true;
905 }
906 else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) {
907 isAmbigTime = !ambigMatch[5]; // no time part?
908 isAmbigZone = true;
909 }
910 }
911 else if ($.isArray(input)) {
912 // arrays have no timezone information, so assume ambiguous zone
913 isAmbigZone = true;
914 }
915 // otherwise, probably a string with a format
916
917 if (parseAsUTC || isAmbigTime) {
918 mom = moment.utc.apply(moment, args);
919 }
920 else {
921 mom = moment.apply(null, args);
922 }
923
924 if (isAmbigTime) {
925 mom._ambigTime = true;
926 mom._ambigZone = true; // ambiguous time always means ambiguous zone
927 }
928 else if (parseZone) { // let's record the inputted zone somehow
929 if (isAmbigZone) {
930 mom._ambigZone = true;
931 }
932 else if (isSingleString) {
933 mom.zone(input); // if not a valid zone, will assign UTC
934 }
935 }
936 }
937
938 mom._fullCalendar = true; // flag for extended functionality
939
940 return mom;
941}
942
943
944// A clone method that works with the flags related to our enhanced functionality.
945// In the future, use moment.momentProperties
946newMomentProto.clone = function() {
947 var mom = oldMomentProto.clone.apply(this, arguments);
948
949 // these flags weren't transfered with the clone
950 transferAmbigs(this, mom);
951 if (this._fullCalendar) {
952 mom._fullCalendar = true;
953 }
954
955 return mom;
956};
957
958
959// Time-of-day
960// -------------------------------------------------------------------------------------------------
961
962// GETTER
963// Returns a Duration with the hours/minutes/seconds/ms values of the moment.
964// If the moment has an ambiguous time, a duration of 00:00 will be returned.
965//
966// SETTER
967// You can supply a Duration, a Moment, or a Duration-like argument.
968// When setting the time, and the moment has an ambiguous time, it then becomes unambiguous.
969newMomentProto.time = function(time) {
970
971 // Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar.
972 // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins.
973 if (!this._fullCalendar) {
974 return oldMomentProto.time.apply(this, arguments);
975 }
976
977 if (time == null) { // getter
978 return moment.duration({
979 hours: this.hours(),
980 minutes: this.minutes(),
981 seconds: this.seconds(),
982 milliseconds: this.milliseconds()
983 });
984 }
985 else { // setter
986
987 this._ambigTime = false; // mark that the moment now has a time
988
989 if (!moment.isDuration(time) && !moment.isMoment(time)) {
990 time = moment.duration(time);
991 }
992
993 // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day).
994 // Only for Duration times, not Moment times.
995 var dayHours = 0;
996 if (moment.isDuration(time)) {
997 dayHours = Math.floor(time.asDays()) * 24;
998 }
999
1000 // We need to set the individual fields.
1001 // Can't use startOf('day') then add duration. In case of DST at start of day.
1002 return this.hours(dayHours + time.hours())
1003 .minutes(time.minutes())
1004 .seconds(time.seconds())
1005 .milliseconds(time.milliseconds());
1006 }
1007};
1008
1009// Converts the moment to UTC, stripping out its time-of-day and timezone offset,
1010// but preserving its YMD. A moment with a stripped time will display no time
1011// nor timezone offset when .format() is called.
1012newMomentProto.stripTime = function() {
1013 var a;
1014
1015 if (!this._ambigTime) {
1016
1017 // get the values before any conversion happens
1018 a = this.toArray(); // array of y/m/d/h/m/s/ms
1019
1020 this.utc(); // set the internal UTC flag (will clear the ambig flags)
1021 setUTCValues(this, a.slice(0, 3)); // set the year/month/date. time will be zero
1022
1023 // Mark the time as ambiguous. This needs to happen after the .utc() call, which calls .zone(),
1024 // which clears all ambig flags. Same with setUTCValues with moment-timezone.
1025 this._ambigTime = true;
1026 this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset
1027 }
1028
1029 return this; // for chaining
1030};
1031
1032// Returns if the moment has a non-ambiguous time (boolean)
1033newMomentProto.hasTime = function() {
1034 return !this._ambigTime;
1035};
1036
1037
1038// Timezone
1039// -------------------------------------------------------------------------------------------------
1040
1041// Converts the moment to UTC, stripping out its timezone offset, but preserving its
1042// YMD and time-of-day. A moment with a stripped timezone offset will display no
1043// timezone offset when .format() is called.
1044newMomentProto.stripZone = function() {
1045 var a, wasAmbigTime;
1046
1047 if (!this._ambigZone) {
1048
1049 // get the values before any conversion happens
1050 a = this.toArray(); // array of y/m/d/h/m/s/ms
1051 wasAmbigTime = this._ambigTime;
1052
1053 this.utc(); // set the internal UTC flag (will clear the ambig flags)
1054 setUTCValues(this, a); // will set the year/month/date/hours/minutes/seconds/ms
1055
1056 if (wasAmbigTime) {
1057 // the above call to .utc()/.zone() unfortunately clears the ambig flags, so reassign
1058 this._ambigTime = true;
1059 }
1060
1061 // Mark the zone as ambiguous. This needs to happen after the .utc() call, which calls .zone(),
1062 // which clears all ambig flags. Same with setUTCValues with moment-timezone.
1063 this._ambigZone = true;
1064 }
1065
1066 return this; // for chaining
1067};
1068
1069// Returns of the moment has a non-ambiguous timezone offset (boolean)
1070newMomentProto.hasZone = function() {
1071 return !this._ambigZone;
1072};
1073
1074// this method implicitly marks a zone (will get called upon .utc() and .local())
1075newMomentProto.zone = function(tzo) {
1076
1077 if (tzo != null) { // setter
1078 // these assignments needs to happen before the original zone method is called.
1079 // I forget why, something to do with a browser crash.
1080 this._ambigTime = false;
1081 this._ambigZone = false;
1082 }
1083
1084 return oldMomentProto.zone.apply(this, arguments);
1085};
1086
1087// this method implicitly marks a zone
1088newMomentProto.local = function() {
1089 var a = this.toArray(); // year,month,date,hours,minutes,seconds,ms as an array
1090 var wasAmbigZone = this._ambigZone;
1091
1092 oldMomentProto.local.apply(this, arguments); // will clear ambig flags
1093
1094 if (wasAmbigZone) {
1095 // If the moment was ambiguously zoned, the date fields were stored as UTC.
1096 // We want to preserve these, but in local time.
1097 setLocalValues(this, a);
1098 }
1099
1100 return this; // for chaining
1101};
1102
1103
1104// Formatting
1105// -------------------------------------------------------------------------------------------------
1106
1107newMomentProto.format = function() {
1108 if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided?
1109 return formatDate(this, arguments[0]); // our extended formatting
1110 }
1111 if (this._ambigTime) {
1112 return oldMomentFormat(this, 'YYYY-MM-DD');
1113 }
1114 if (this._ambigZone) {
1115 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss');
1116 }
1117 return oldMomentProto.format.apply(this, arguments);
1118};
1119
1120newMomentProto.toISOString = function() {
1121 if (this._ambigTime) {
1122 return oldMomentFormat(this, 'YYYY-MM-DD');
1123 }
1124 if (this._ambigZone) {
1125 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss');
1126 }
1127 return oldMomentProto.toISOString.apply(this, arguments);
1128};
1129
1130
1131// Querying
1132// -------------------------------------------------------------------------------------------------
1133
1134// Is the moment within the specified range? `end` is exclusive.
1135// FYI, this method is not a standard Moment method, so always do our enhanced logic.
1136newMomentProto.isWithin = function(start, end) {
1137 var a = commonlyAmbiguate([ this, start, end ]);
1138 return a[0] >= a[1] && a[0] < a[2];
1139};
1140
1141// When isSame is called with units, timezone ambiguity is normalized before the comparison happens.
1142// If no units specified, the two moments must be identically the same, with matching ambig flags.
1143newMomentProto.isSame = function(input, units) {
1144 var a;
1145
1146 // only do custom logic if this is an enhanced moment
1147 if (!this._fullCalendar) {
1148 return oldMomentProto.isSame.apply(this, arguments);
1149 }
1150
1151 if (units) {
1152 a = commonlyAmbiguate([ this, input ], true); // normalize timezones but don't erase times
1153 return oldMomentProto.isSame.call(a[0], a[1], units);
1154 }
1155 else {
1156 input = fc.moment.parseZone(input); // normalize input
1157 return oldMomentProto.isSame.call(this, input) &&
1158 Boolean(this._ambigTime) === Boolean(input._ambigTime) &&
1159 Boolean(this._ambigZone) === Boolean(input._ambigZone);
1160 }
1161};
1162
1163// Make these query methods work with ambiguous moments
1164$.each([
1165 'isBefore',
1166 'isAfter'
1167], function(i, methodName) {
1168 newMomentProto[methodName] = function(input, units) {
1169 var a;
1170
1171 // only do custom logic if this is an enhanced moment
1172 if (!this._fullCalendar) {
1173 return oldMomentProto[methodName].apply(this, arguments);
1174 }
1175
1176 a = commonlyAmbiguate([ this, input ]);
1177 return oldMomentProto[methodName].call(a[0], a[1], units);
1178 };
1179});
1180
1181
1182// Misc Internals
1183// -------------------------------------------------------------------------------------------------
1184
1185// given an array of moment-like inputs, return a parallel array w/ moments similarly ambiguated.
1186// for example, of one moment has ambig time, but not others, all moments will have their time stripped.
1187// set `preserveTime` to `true` to keep times, but only normalize zone ambiguity.
1188// returns the original moments if no modifications are necessary.
1189function commonlyAmbiguate(inputs, preserveTime) {
1190 var anyAmbigTime = false;
1191 var anyAmbigZone = false;
1192 var len = inputs.length;
1193 var moms = [];
1194 var i, mom;
1195
1196 // parse inputs into real moments and query their ambig flags
1197 for (i = 0; i < len; i++) {
1198 mom = inputs[i];
1199 if (!moment.isMoment(mom)) {
1200 mom = fc.moment.parseZone(mom);
1201 }
1202 anyAmbigTime = anyAmbigTime || mom._ambigTime;
1203 anyAmbigZone = anyAmbigZone || mom._ambigZone;
1204 moms.push(mom);
1205 }
1206
1207 // strip each moment down to lowest common ambiguity
1208 // use clones to avoid modifying the original moments
1209 for (i = 0; i < len; i++) {
1210 mom = moms[i];
1211 if (!preserveTime && anyAmbigTime && !mom._ambigTime) {
1212 moms[i] = mom.clone().stripTime();
1213 }
1214 else if (anyAmbigZone && !mom._ambigZone) {
1215 moms[i] = mom.clone().stripZone();
1216 }
1217 }
1218
1219 return moms;
1220}
1221
1222// Transfers all the flags related to ambiguous time/zone from the `src` moment to the `dest` moment
1223function transferAmbigs(src, dest) {
1224 if (src._ambigTime) {
1225 dest._ambigTime = true;
1226 }
1227 else if (dest._ambigTime) {
1228 dest._ambigTime = false;
1229 }
1230
1231 if (src._ambigZone) {
1232 dest._ambigZone = true;
1233 }
1234 else if (dest._ambigZone) {
1235 dest._ambigZone = false;
1236 }
1237}
1238
1239
1240// Sets the year/month/date/etc values of the moment from the given array.
1241// Inefficient because it calls each individual setter.
1242function setMomentValues(mom, a) {
1243 mom.year(a[0] || 0)
1244 .month(a[1] || 0)
1245 .date(a[2] || 0)
1246 .hours(a[3] || 0)
1247 .minutes(a[4] || 0)
1248 .seconds(a[5] || 0)
1249 .milliseconds(a[6] || 0);
1250}
1251
1252// Can we set the moment's internal date directly?
1253allowValueOptimization = '_d' in moment() && 'updateOffset' in moment;
1254
1255// Utility function. Accepts a moment and an array of the UTC year/month/date/etc values to set.
1256// Assumes the given moment is already in UTC mode.
1257setUTCValues = allowValueOptimization ? function(mom, a) {
1258 // simlate what moment's accessors do
1259 mom._d.setTime(Date.UTC.apply(Date, a));
1260 moment.updateOffset(mom, false); // keepTime=false
1261} : setMomentValues;
1262
1263// Utility function. Accepts a moment and an array of the local year/month/date/etc values to set.
1264// Assumes the given moment is already in local mode.
1265setLocalValues = allowValueOptimization ? function(mom, a) {
1266 // simlate what moment's accessors do
1267 mom._d.setTime(+new Date( // FYI, there is now way to apply an array of args to a constructor
1268 a[0] || 0,
1269 a[1] || 0,
1270 a[2] || 0,
1271 a[3] || 0,
1272 a[4] || 0,
1273 a[5] || 0,
1274 a[6] || 0
1275 ));
1276 moment.updateOffset(mom, false); // keepTime=false
1277} : setMomentValues;
1278
1279// Single Date Formatting
1280// -------------------------------------------------------------------------------------------------
1281
1282
1283// call this if you want Moment's original format method to be used
1284function oldMomentFormat(mom, formatStr) {
1285 return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js
1286}
1287
1288
1289// Formats `date` with a Moment formatting string, but allow our non-zero areas and
1290// additional token.
1291function formatDate(date, formatStr) {
1292 return formatDateWithChunks(date, getFormatStringChunks(formatStr));
1293}
1294
1295
1296function formatDateWithChunks(date, chunks) {
1297 var s = '';
1298 var i;
1299
1300 for (i=0; i<chunks.length; i++) {
1301 s += formatDateWithChunk(date, chunks[i]);
1302 }
1303
1304 return s;
1305}
1306
1307
1308// addition formatting tokens we want recognized
1309var tokenOverrides = {
1310 t: function(date) { // "a" or "p"
1311 return oldMomentFormat(date, 'a').charAt(0);
1312 },
1313 T: function(date) { // "A" or "P"
1314 return oldMomentFormat(date, 'A').charAt(0);
1315 }
1316};
1317
1318
1319function formatDateWithChunk(date, chunk) {
1320 var token;
1321 var maybeStr;
1322
1323 if (typeof chunk === 'string') { // a literal string
1324 return chunk;
1325 }
1326 else if ((token = chunk.token)) { // a token, like "YYYY"
1327 if (tokenOverrides[token]) {
1328 return tokenOverrides[token](date); // use our custom token
1329 }
1330 return oldMomentFormat(date, token);
1331 }
1332 else if (chunk.maybe) { // a grouping of other chunks that must be non-zero
1333 maybeStr = formatDateWithChunks(date, chunk.maybe);
1334 if (maybeStr.match(/[1-9]/)) {
1335 return maybeStr;
1336 }
1337 }
1338
1339 return '';
1340}
1341
1342
1343// Date Range Formatting
1344// -------------------------------------------------------------------------------------------------
1345// TODO: make it work with timezone offset
1346
1347// Using a formatting string meant for a single date, generate a range string, like
1348// "Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ.
1349// If the dates are the same as far as the format string is concerned, just return a single
1350// rendering of one date, without any separator.
1351function formatRange(date1, date2, formatStr, separator, isRTL) {
1352 var localeData;
1353
1354 date1 = fc.moment.parseZone(date1);
1355 date2 = fc.moment.parseZone(date2);
1356
1357 localeData = (date1.localeData || date1.lang).call(date1); // works with moment-pre-2.8
1358
1359 // Expand localized format strings, like "LL" -> "MMMM D YYYY"
1360 formatStr = localeData.longDateFormat(formatStr) || formatStr;
1361 // BTW, this is not important for `formatDate` because it is impossible to put custom tokens
1362 // or non-zero areas in Moment's localized format strings.
1363
1364 separator = separator || ' - ';
1365
1366 return formatRangeWithChunks(
1367 date1,
1368 date2,
1369 getFormatStringChunks(formatStr),
1370 separator,
1371 isRTL
1372 );
1373}
1374fc.formatRange = formatRange; // expose
1375
1376
1377function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) {
1378 var chunkStr; // the rendering of the chunk
1379 var leftI;
1380 var leftStr = '';
1381 var rightI;
1382 var rightStr = '';
1383 var middleI;
1384 var middleStr1 = '';
1385 var middleStr2 = '';
1386 var middleStr = '';
1387
1388 // Start at the leftmost side of the formatting string and continue until you hit a token
1389 // that is not the same between dates.
1390 for (leftI=0; leftI<chunks.length; leftI++) {
1391 chunkStr = formatSimilarChunk(date1, date2, chunks[leftI]);
1392 if (chunkStr === false) {
1393 break;
1394 }
1395 leftStr += chunkStr;
1396 }
1397
1398 // Similarly, start at the rightmost side of the formatting string and move left
1399 for (rightI=chunks.length-1; rightI>leftI; rightI--) {
1400 chunkStr = formatSimilarChunk(date1, date2, chunks[rightI]);
1401 if (chunkStr === false) {
1402 break;
1403 }
1404 rightStr = chunkStr + rightStr;
1405 }
1406
1407 // The area in the middle is different for both of the dates.
1408 // Collect them distinctly so we can jam them together later.
1409 for (middleI=leftI; middleI<=rightI; middleI++) {
1410 middleStr1 += formatDateWithChunk(date1, chunks[middleI]);
1411 middleStr2 += formatDateWithChunk(date2, chunks[middleI]);
1412 }
1413
1414 if (middleStr1 || middleStr2) {
1415 if (isRTL) {
1416 middleStr = middleStr2 + separator + middleStr1;
1417 }
1418 else {
1419 middleStr = middleStr1 + separator + middleStr2;
1420 }
1421 }
1422
1423 return leftStr + middleStr + rightStr;
1424}
1425
1426
1427var similarUnitMap = {
1428 Y: 'year',
1429 M: 'month',
1430 D: 'day', // day of month
1431 d: 'day', // day of week
1432 // prevents a separator between anything time-related...
1433 A: 'second', // AM/PM
1434 a: 'second', // am/pm
1435 T: 'second', // A/P
1436 t: 'second', // a/p
1437 H: 'second', // hour (24)
1438 h: 'second', // hour (12)
1439 m: 'second', // minute
1440 s: 'second' // second
1441};
1442// TODO: week maybe?
1443
1444
1445// Given a formatting chunk, and given that both dates are similar in the regard the
1446// formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`.
1447function formatSimilarChunk(date1, date2, chunk) {
1448 var token;
1449 var unit;
1450
1451 if (typeof chunk === 'string') { // a literal string
1452 return chunk;
1453 }
1454 else if ((token = chunk.token)) {
1455 unit = similarUnitMap[token.charAt(0)];
1456 // are the dates the same for this unit of measurement?
1457 if (unit && date1.isSame(date2, unit)) {
1458 return oldMomentFormat(date1, token); // would be the same if we used `date2`
1459 // BTW, don't support custom tokens
1460 }
1461 }
1462
1463 return false; // the chunk is NOT the same for the two dates
1464 // BTW, don't support splitting on non-zero areas
1465}
1466
1467
1468// Chunking Utils
1469// -------------------------------------------------------------------------------------------------
1470
1471
1472var formatStringChunkCache = {};
1473
1474
1475function getFormatStringChunks(formatStr) {
1476 if (formatStr in formatStringChunkCache) {
1477 return formatStringChunkCache[formatStr];
1478 }
1479 return (formatStringChunkCache[formatStr] = chunkFormatString(formatStr));
1480}
1481
1482
1483// Break the formatting string into an array of chunks
1484function chunkFormatString(formatStr) {
1485 var chunks = [];
1486 var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LT|(\w)\4*o?)|([^\w\[\(]+)/g; // TODO: more descrimination
1487 var match;
1488
1489 while ((match = chunker.exec(formatStr))) {
1490 if (match[1]) { // a literal string inside [ ... ]
1491 chunks.push(match[1]);
1492 }
1493 else if (match[2]) { // non-zero formatting inside ( ... )
1494 chunks.push({ maybe: chunkFormatString(match[2]) });
1495 }
1496 else if (match[3]) { // a formatting token
1497 chunks.push({ token: match[3] });
1498 }
1499 else if (match[5]) { // an unenclosed literal string
1500 chunks.push(match[5]);
1501 }
1502 }
1503
1504 return chunks;
1505}
1506
1507 fc.Class = Class; // export
1508
1509// class that all other classes will inherit from
1510function Class() { }
1511
1512// called upon a class to create a subclass
1513Class.extend = function(members) {
1514 var superClass = this;
1515 var subClass;
1516
1517 members = members || {};
1518
1519 // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist
1520 if (hasOwnProp(members, 'constructor')) {
1521 subClass = members.constructor;
1522 }
1523 if (typeof subClass !== 'function') {
1524 subClass = members.constructor = function() {
1525 superClass.apply(this, arguments);
1526 };
1527 }
1528
1529 // build the base prototype for the subclass, which is an new object chained to the superclass's prototype
1530 subClass.prototype = createObject(superClass.prototype);
1531
1532 // copy each member variable/method onto the the subclass's prototype
1533 copyOwnProps(members, subClass.prototype);
1534
1535 // copy over all class variables/methods to the subclass, such as `extend` and `mixin`
1536 copyOwnProps(superClass, subClass);
1537
1538 return subClass;
1539};
1540
1541// adds new member variables/methods to the class's prototype.
1542// can be called with another class, or a plain object hash containing new members.
1543Class.mixin = function(members) {
1544 copyOwnProps(members.prototype || members, this.prototype);
1545};
1546 /* A rectangular panel that is absolutely positioned over other content
1547------------------------------------------------------------------------------------------------------------------------
1548Options:
1549 - className (string)
1550 - content (HTML string or jQuery element set)
1551 - parentEl
1552 - top
1553 - left
1554 - right (the x coord of where the right edge should be. not a "CSS" right)
1555 - autoHide (boolean)
1556 - show (callback)
1557 - hide (callback)
1558*/
1559
1560var Popover = Class.extend({
1561
1562 isHidden: true,
1563 options: null,
1564 el: null, // the container element for the popover. generated by this object
1565 documentMousedownProxy: null, // document mousedown handler bound to `this`
1566 margin: 10, // the space required between the popover and the edges of the scroll container
1567
1568
1569 constructor: function(options) {
1570 this.options = options || {};
1571 },
1572
1573
1574 // Shows the popover on the specified position. Renders it if not already
1575 show: function() {
1576 if (this.isHidden) {
1577 if (!this.el) {
1578 this.render();
1579 }
1580 this.el.show();
1581 this.position();
1582 this.isHidden = false;
1583 this.trigger('show');
1584 }
1585 },
1586
1587
1588 // Hides the popover, through CSS, but does not remove it from the DOM
1589 hide: function() {
1590 if (!this.isHidden) {
1591 this.el.hide();
1592 this.isHidden = true;
1593 this.trigger('hide');
1594 }
1595 },
1596
1597
1598 // Creates `this.el` and renders content inside of it
1599 render: function() {
1600 var _this = this;
1601 var options = this.options;
1602
1603 this.el = $('<div class="fc-popover"/>')
1604 .addClass(options.className || '')
1605 .css({
1606 // position initially to the top left to avoid creating scrollbars
1607 top: 0,
1608 left: 0
1609 })
1610 .append(options.content)
1611 .appendTo(options.parentEl);
1612
1613 // when a click happens on anything inside with a 'fc-close' className, hide the popover
1614 this.el.on('click', '.fc-close', function() {
1615 _this.hide();
1616 });
1617
1618 if (options.autoHide) {
1619 $(document).on('mousedown', this.documentMousedownProxy = $.proxy(this, 'documentMousedown'));
1620 }
1621 },
1622
1623
1624 // Triggered when the user clicks *anywhere* in the document, for the autoHide feature
1625 documentMousedown: function(ev) {
1626 // only hide the popover if the click happened outside the popover
1627 if (this.el && !$(ev.target).closest(this.el).length) {
1628 this.hide();
1629 }
1630 },
1631
1632
1633 // Hides and unregisters any handlers
1634 destroy: function() {
1635 this.hide();
1636
1637 if (this.el) {
1638 this.el.remove();
1639 this.el = null;
1640 }
1641
1642 $(document).off('mousedown', this.documentMousedownProxy);
1643 },
1644
1645
1646 // Positions the popover optimally, using the top/left/right options
1647 position: function() {
1648 var options = this.options;
1649 var origin = this.el.offsetParent().offset();
1650 var width = this.el.outerWidth();
1651 var height = this.el.outerHeight();
1652 var windowEl = $(window);
1653 var viewportEl = getScrollParent(this.el);
1654 var viewportTop;
1655 var viewportLeft;
1656 var viewportOffset;
1657 var top; // the "position" (not "offset") values for the popover
1658 var left; //
1659
1660 // compute top and left
1661 top = options.top || 0;
1662 if (options.left !== undefined) {
1663 left = options.left;
1664 }
1665 else if (options.right !== undefined) {
1666 left = options.right - width; // derive the left value from the right value
1667 }
1668 else {
1669 left = 0;
1670 }
1671
1672 if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result
1673 viewportEl = windowEl;
1674 viewportTop = 0; // the window is always at the top left
1675 viewportLeft = 0; // (and .offset() won't work if called here)
1676 }
1677 else {
1678 viewportOffset = viewportEl.offset();
1679 viewportTop = viewportOffset.top;
1680 viewportLeft = viewportOffset.left;
1681 }
1682
1683 // if the window is scrolled, it causes the visible area to be further down
1684 viewportTop += windowEl.scrollTop();
1685 viewportLeft += windowEl.scrollLeft();
1686
1687 // constrain to the view port. if constrained by two edges, give precedence to top/left
1688 if (options.viewportConstrain !== false) {
1689 top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin);
1690 top = Math.max(top, viewportTop + this.margin);
1691 left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin);
1692 left = Math.max(left, viewportLeft + this.margin);
1693 }
1694
1695 this.el.css({
1696 top: top - origin.top,
1697 left: left - origin.left
1698 });
1699 },
1700
1701
1702 // Triggers a callback. Calls a function in the option hash of the same name.
1703 // Arguments beyond the first `name` are forwarded on.
1704 // TODO: better code reuse for this. Repeat code
1705 trigger: function(name) {
1706 if (this.options[name]) {
1707 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
1708 }
1709 }
1710
1711});
1712
1713 /* A "coordinate map" converts pixel coordinates into an associated cell, which has an associated date
1714------------------------------------------------------------------------------------------------------------------------
1715Common interface:
1716
1717 CoordMap.prototype = {
1718 build: function() {},
1719 getCell: function(x, y) {}
1720 };
1721
1722*/
1723
1724/* Coordinate map for a grid component
1725----------------------------------------------------------------------------------------------------------------------*/
1726
1727var GridCoordMap = Class.extend({
1728
1729 grid: null, // reference to the Grid
1730 rowCoords: null, // array of {top,bottom} objects
1731 colCoords: null, // array of {left,right} objects
1732
1733 containerEl: null, // container element that all coordinates are constrained to. optionally assigned
1734 minX: null,
1735 maxX: null, // exclusive
1736 minY: null,
1737 maxY: null, // exclusive
1738
1739
1740 constructor: function(grid) {
1741 this.grid = grid;
1742 },
1743
1744
1745 // Queries the grid for the coordinates of all the cells
1746 build: function() {
1747 this.rowCoords = this.grid.computeRowCoords();
1748 this.colCoords = this.grid.computeColCoords();
1749 this.computeBounds();
1750 },
1751
1752
1753 // Clears the coordinates data to free up memory
1754 clear: function() {
1755 this.rowCoords = null;
1756 this.colCoords = null;
1757 },
1758
1759
1760 // Given a coordinate of the document, gets the associated cell. If no cell is underneath, returns null
1761 getCell: function(x, y) {
1762 var rowCoords = this.rowCoords;
1763 var colCoords = this.colCoords;
1764 var hitRow = null;
1765 var hitCol = null;
1766 var i, coords;
1767 var cell;
1768
1769 if (this.inBounds(x, y)) {
1770
1771 for (i = 0; i < rowCoords.length; i++) {
1772 coords = rowCoords[i];
1773 if (y >= coords.top && y < coords.bottom) {
1774 hitRow = i;
1775 break;
1776 }
1777 }
1778
1779 for (i = 0; i < colCoords.length; i++) {
1780 coords = colCoords[i];
1781 if (x >= coords.left && x < coords.right) {
1782 hitCol = i;
1783 break;
1784 }
1785 }
1786
1787 if (hitRow !== null && hitCol !== null) {
1788 cell = this.grid.getCell(hitRow, hitCol);
1789 cell.grid = this.grid; // for DragListener's isCellsEqual. dragging between grids
1790 return cell;
1791 }
1792 }
1793
1794 return null;
1795 },
1796
1797
1798 // If there is a containerEl, compute the bounds into min/max values
1799 computeBounds: function() {
1800 var containerOffset;
1801
1802 if (this.containerEl) {
1803 containerOffset = this.containerEl.offset();
1804 this.minX = containerOffset.left;
1805 this.maxX = containerOffset.left + this.containerEl.outerWidth();
1806 this.minY = containerOffset.top;
1807 this.maxY = containerOffset.top + this.containerEl.outerHeight();
1808 }
1809 },
1810
1811
1812 // Determines if the given coordinates are in bounds. If no `containerEl`, always true
1813 inBounds: function(x, y) {
1814 if (this.containerEl) {
1815 return x >= this.minX && x < this.maxX && y >= this.minY && y < this.maxY;
1816 }
1817 return true;
1818 }
1819
1820});
1821
1822
1823/* Coordinate map that is a combination of multiple other coordinate maps
1824----------------------------------------------------------------------------------------------------------------------*/
1825
1826var ComboCoordMap = Class.extend({
1827
1828 coordMaps: null, // an array of CoordMaps
1829
1830
1831 constructor: function(coordMaps) {
1832 this.coordMaps = coordMaps;
1833 },
1834
1835
1836 // Builds all coordMaps
1837 build: function() {
1838 var coordMaps = this.coordMaps;
1839 var i;
1840
1841 for (i = 0; i < coordMaps.length; i++) {
1842 coordMaps[i].build();
1843 }
1844 },
1845
1846
1847 // Queries all coordMaps for the cell underneath the given coordinates, returning the first result
1848 getCell: function(x, y) {
1849 var coordMaps = this.coordMaps;
1850 var cell = null;
1851 var i;
1852
1853 for (i = 0; i < coordMaps.length && !cell; i++) {
1854 cell = coordMaps[i].getCell(x, y);
1855 }
1856
1857 return cell;
1858 },
1859
1860
1861 // Clears all coordMaps
1862 clear: function() {
1863 var coordMaps = this.coordMaps;
1864 var i;
1865
1866 for (i = 0; i < coordMaps.length; i++) {
1867 coordMaps[i].clear();
1868 }
1869 }
1870
1871});
1872
1873 /* Tracks mouse movements over a CoordMap and raises events about which cell the mouse is over.
1874----------------------------------------------------------------------------------------------------------------------*/
1875// TODO: very useful to have a handler that gets called upon cellOut OR when dragging stops (for cleanup)
1876
1877var DragListener = Class.extend({
1878
1879 coordMap: null,
1880 options: null,
1881
1882 isListening: false,
1883 isDragging: false,
1884
1885 // the cell the mouse was over when listening started
1886 origCell: null,
1887
1888 // the cell the mouse is over
1889 cell: null,
1890
1891 // coordinates of the initial mousedown
1892 mouseX0: null,
1893 mouseY0: null,
1894
1895 // handler attached to the document, bound to the DragListener's `this`
1896 mousemoveProxy: null,
1897 mouseupProxy: null,
1898
1899 scrollEl: null,
1900 scrollBounds: null, // { top, bottom, left, right }
1901 scrollTopVel: null, // pixels per second
1902 scrollLeftVel: null, // pixels per second
1903 scrollIntervalId: null, // ID of setTimeout for scrolling animation loop
1904 scrollHandlerProxy: null, // this-scoped function for handling when scrollEl is scrolled
1905
1906 scrollSensitivity: 30, // pixels from edge for scrolling to start
1907 scrollSpeed: 200, // pixels per second, at maximum speed
1908 scrollIntervalMs: 50, // millisecond wait between scroll increment
1909
1910
1911 constructor: function(coordMap, options) {
1912 this.coordMap = coordMap;
1913 this.options = options || {};
1914 },
1915
1916
1917 // Call this when the user does a mousedown. Will probably lead to startListening
1918 mousedown: function(ev) {
1919 if (isPrimaryMouseButton(ev)) {
1920
1921 ev.preventDefault(); // prevents native selection in most browsers
1922
1923 this.startListening(ev);
1924
1925 // start the drag immediately if there is no minimum distance for a drag start
1926 if (!this.options.distance) {
1927 this.startDrag(ev);
1928 }
1929 }
1930 },
1931
1932
1933 // Call this to start tracking mouse movements
1934 startListening: function(ev) {
1935 var scrollParent;
1936 var cell;
1937
1938 if (!this.isListening) {
1939
1940 // grab scroll container and attach handler
1941 if (ev && this.options.scroll) {
1942 scrollParent = getScrollParent($(ev.target));
1943 if (!scrollParent.is(window) && !scrollParent.is(document)) {
1944 this.scrollEl = scrollParent;
1945
1946 // scope to `this`, and use `debounce` to make sure rapid calls don't happen
1947 this.scrollHandlerProxy = debounce($.proxy(this, 'scrollHandler'), 100);
1948 this.scrollEl.on('scroll', this.scrollHandlerProxy);
1949 }
1950 }
1951
1952 this.computeCoords(); // relies on `scrollEl`
1953
1954 // get info on the initial cell and its coordinates
1955 if (ev) {
1956 cell = this.getCell(ev);
1957 this.origCell = cell;
1958
1959 this.mouseX0 = ev.pageX;
1960 this.mouseY0 = ev.pageY;
1961 }
1962
1963 $(document)
1964 .on('mousemove', this.mousemoveProxy = $.proxy(this, 'mousemove'))
1965 .on('mouseup', this.mouseupProxy = $.proxy(this, 'mouseup'))
1966 .on('selectstart', this.preventDefault); // prevents native selection in IE<=8
1967
1968 this.isListening = true;
1969 this.trigger('listenStart', ev);
1970 }
1971 },
1972
1973
1974 // Recomputes the drag-critical positions of elements
1975 computeCoords: function() {
1976 this.coordMap.build();
1977 this.computeScrollBounds();
1978 },
1979
1980
1981 // Called when the user moves the mouse
1982 mousemove: function(ev) {
1983 var minDistance;
1984 var distanceSq; // current distance from mouseX0/mouseY0, squared
1985
1986 if (!this.isDragging) { // if not already dragging...
1987 // then start the drag if the minimum distance criteria is met
1988 minDistance = this.options.distance || 1;
1989 distanceSq = Math.pow(ev.pageX - this.mouseX0, 2) + Math.pow(ev.pageY - this.mouseY0, 2);
1990 if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem
1991 this.startDrag(ev);
1992 }
1993 }
1994
1995 if (this.isDragging) {
1996 this.drag(ev); // report a drag, even if this mousemove initiated the drag
1997 }
1998 },
1999
2000
2001 // Call this to initiate a legitimate drag.
2002 // This function is called internally from this class, but can also be called explicitly from outside
2003 startDrag: function(ev) {
2004 var cell;
2005
2006 if (!this.isListening) { // startDrag must have manually initiated
2007 this.startListening();
2008 }
2009
2010 if (!this.isDragging) {
2011 this.isDragging = true;
2012 this.trigger('dragStart', ev);
2013
2014 // report the initial cell the mouse is over
2015 // especially important if no min-distance and drag starts immediately
2016 cell = this.getCell(ev); // this might be different from this.origCell if the min-distance is large
2017 if (cell) {
2018 this.cellOver(cell);
2019 }
2020 }
2021 },
2022
2023
2024 // Called while the mouse is being moved and when we know a legitimate drag is taking place
2025 drag: function(ev) {
2026 var cell;
2027
2028 if (this.isDragging) {
2029 cell = this.getCell(ev);
2030
2031 if (!isCellsEqual(cell, this.cell)) { // a different cell than before?
2032 if (this.cell) {
2033 this.cellOut();
2034 }
2035 if (cell) {
2036 this.cellOver(cell);
2037 }
2038 }
2039
2040 this.dragScroll(ev); // will possibly cause scrolling
2041 }
2042 },
2043
2044
2045 // Called when a the mouse has just moved over a new cell
2046 cellOver: function(cell) {
2047 this.cell = cell;
2048 this.trigger('cellOver', cell, isCellsEqual(cell, this.origCell));
2049 },
2050
2051
2052 // Called when the mouse has just moved out of a cell
2053 cellOut: function() {
2054 if (this.cell) {
2055 this.trigger('cellOut', this.cell);
2056 this.cell = null;
2057 }
2058 },
2059
2060
2061 // Called when the user does a mouseup
2062 mouseup: function(ev) {
2063 this.stopDrag(ev);
2064 this.stopListening(ev);
2065 },
2066
2067
2068 // Called when the drag is over. Will not cause listening to stop however.
2069 // A concluding 'cellOut' event will NOT be triggered.
2070 stopDrag: function(ev) {
2071 if (this.isDragging) {
2072 this.stopScrolling();
2073 this.trigger('dragStop', ev);
2074 this.isDragging = false;
2075 }
2076 },
2077
2078
2079 // Call this to stop listening to the user's mouse events
2080 stopListening: function(ev) {
2081 if (this.isListening) {
2082
2083 // remove the scroll handler if there is a scrollEl
2084 if (this.scrollEl) {
2085 this.scrollEl.off('scroll', this.scrollHandlerProxy);
2086 this.scrollHandlerProxy = null;
2087 }
2088
2089 $(document)
2090 .off('mousemove', this.mousemoveProxy)
2091 .off('mouseup', this.mouseupProxy)
2092 .off('selectstart', this.preventDefault);
2093
2094 this.mousemoveProxy = null;
2095 this.mouseupProxy = null;
2096
2097 this.isListening = false;
2098 this.trigger('listenStop', ev);
2099
2100 this.origCell = this.cell = null;
2101 this.coordMap.clear();
2102 }
2103 },
2104
2105
2106 // Gets the cell underneath the coordinates for the given mouse event
2107 getCell: function(ev) {
2108 return this.coordMap.getCell(ev.pageX, ev.pageY);
2109 },
2110
2111
2112 // Triggers a callback. Calls a function in the option hash of the same name.
2113 // Arguments beyond the first `name` are forwarded on.
2114 trigger: function(name) {
2115 if (this.options[name]) {
2116 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
2117 }
2118 },
2119
2120
2121 // Stops a given mouse event from doing it's native browser action. In our case, text selection.
2122 preventDefault: function(ev) {
2123 ev.preventDefault();
2124 },
2125
2126
2127 /* Scrolling
2128 ------------------------------------------------------------------------------------------------------------------*/
2129
2130
2131 // Computes and stores the bounding rectangle of scrollEl
2132 computeScrollBounds: function() {
2133 var el = this.scrollEl;
2134 var offset;
2135
2136 if (el) {
2137 offset = el.offset();
2138 this.scrollBounds = {
2139 top: offset.top,
2140 left: offset.left,
2141 bottom: offset.top + el.outerHeight(),
2142 right: offset.left + el.outerWidth()
2143 };
2144 }
2145 },
2146
2147
2148 // Called when the dragging is in progress and scrolling should be updated
2149 dragScroll: function(ev) {
2150 var sensitivity = this.scrollSensitivity;
2151 var bounds = this.scrollBounds;
2152 var topCloseness, bottomCloseness;
2153 var leftCloseness, rightCloseness;
2154 var topVel = 0;
2155 var leftVel = 0;
2156
2157 if (bounds) { // only scroll if scrollEl exists
2158
2159 // compute closeness to edges. valid range is from 0.0 - 1.0
2160 topCloseness = (sensitivity - (ev.pageY - bounds.top)) / sensitivity;
2161 bottomCloseness = (sensitivity - (bounds.bottom - ev.pageY)) / sensitivity;
2162 leftCloseness = (sensitivity - (ev.pageX - bounds.left)) / sensitivity;
2163 rightCloseness = (sensitivity - (bounds.right - ev.pageX)) / sensitivity;
2164
2165 // translate vertical closeness into velocity.
2166 // mouse must be completely in bounds for velocity to happen.
2167 if (topCloseness >= 0 && topCloseness <= 1) {
2168 topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up
2169 }
2170 else if (bottomCloseness >= 0 && bottomCloseness <= 1) {
2171 topVel = bottomCloseness * this.scrollSpeed;
2172 }
2173
2174 // translate horizontal closeness into velocity
2175 if (leftCloseness >= 0 && leftCloseness <= 1) {
2176 leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left
2177 }
2178 else if (rightCloseness >= 0 && rightCloseness <= 1) {
2179 leftVel = rightCloseness * this.scrollSpeed;
2180 }
2181 }
2182
2183 this.setScrollVel(topVel, leftVel);
2184 },
2185
2186
2187 // Sets the speed-of-scrolling for the scrollEl
2188 setScrollVel: function(topVel, leftVel) {
2189
2190 this.scrollTopVel = topVel;
2191 this.scrollLeftVel = leftVel;
2192
2193 this.constrainScrollVel(); // massages into realistic values
2194
2195 // if there is non-zero velocity, and an animation loop hasn't already started, then START
2196 if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) {
2197 this.scrollIntervalId = setInterval(
2198 $.proxy(this, 'scrollIntervalFunc'), // scope to `this`
2199 this.scrollIntervalMs
2200 );
2201 }
2202 },
2203
2204
2205 // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way
2206 constrainScrollVel: function() {
2207 var el = this.scrollEl;
2208
2209 if (this.scrollTopVel < 0) { // scrolling up?
2210 if (el.scrollTop() <= 0) { // already scrolled all the way up?
2211 this.scrollTopVel = 0;
2212 }
2213 }
2214 else if (this.scrollTopVel > 0) { // scrolling down?
2215 if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down?
2216 this.scrollTopVel = 0;
2217 }
2218 }
2219
2220 if (this.scrollLeftVel < 0) { // scrolling left?
2221 if (el.scrollLeft() <= 0) { // already scrolled all the left?
2222 this.scrollLeftVel = 0;
2223 }
2224 }
2225 else if (this.scrollLeftVel > 0) { // scrolling right?
2226 if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right?
2227 this.scrollLeftVel = 0;
2228 }
2229 }
2230 },
2231
2232
2233 // This function gets called during every iteration of the scrolling animation loop
2234 scrollIntervalFunc: function() {
2235 var el = this.scrollEl;
2236 var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by
2237
2238 // change the value of scrollEl's scroll
2239 if (this.scrollTopVel) {
2240 el.scrollTop(el.scrollTop() + this.scrollTopVel * frac);
2241 }
2242 if (this.scrollLeftVel) {
2243 el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac);
2244 }
2245
2246 this.constrainScrollVel(); // since the scroll values changed, recompute the velocities
2247
2248 // if scrolled all the way, which causes the vels to be zero, stop the animation loop
2249 if (!this.scrollTopVel && !this.scrollLeftVel) {
2250 this.stopScrolling();
2251 }
2252 },
2253
2254
2255 // Kills any existing scrolling animation loop
2256 stopScrolling: function() {
2257 if (this.scrollIntervalId) {
2258 clearInterval(this.scrollIntervalId);
2259 this.scrollIntervalId = null;
2260
2261 // when all done with scrolling, recompute positions since they probably changed
2262 this.computeCoords();
2263 }
2264 },
2265
2266
2267 // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce)
2268 scrollHandler: function() {
2269 // recompute all coordinates, but *only* if this is *not* part of our scrolling animation
2270 if (!this.scrollIntervalId) {
2271 this.computeCoords();
2272 }
2273 }
2274
2275});
2276
2277
2278// Returns `true` if the cells are identically equal. `false` otherwise.
2279// They must have the same row, col, and be from the same grid.
2280// Two null values will be considered equal, as two "out of the grid" states are the same.
2281function isCellsEqual(cell1, cell2) {
2282
2283 if (!cell1 && !cell2) {
2284 return true;
2285 }
2286
2287 if (cell1 && cell2) {
2288 return cell1.grid === cell2.grid &&
2289 cell1.row === cell2.row &&
2290 cell1.col === cell2.col;
2291 }
2292
2293 return false;
2294}
2295
2296 /* Creates a clone of an element and lets it track the mouse as it moves
2297----------------------------------------------------------------------------------------------------------------------*/
2298
2299var MouseFollower = Class.extend({
2300
2301 options: null,
2302
2303 sourceEl: null, // the element that will be cloned and made to look like it is dragging
2304 el: null, // the clone of `sourceEl` that will track the mouse
2305 parentEl: null, // the element that `el` (the clone) will be attached to
2306
2307 // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl
2308 top0: null,
2309 left0: null,
2310
2311 // the initial position of the mouse
2312 mouseY0: null,
2313 mouseX0: null,
2314
2315 // the number of pixels the mouse has moved from its initial position
2316 topDelta: null,
2317 leftDelta: null,
2318
2319 mousemoveProxy: null, // document mousemove handler, bound to the MouseFollower's `this`
2320
2321 isFollowing: false,
2322 isHidden: false,
2323 isAnimating: false, // doing the revert animation?
2324
2325 constructor: function(sourceEl, options) {
2326 this.options = options = options || {};
2327 this.sourceEl = sourceEl;
2328 this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent
2329 },
2330
2331
2332 // Causes the element to start following the mouse
2333 start: function(ev) {
2334 if (!this.isFollowing) {
2335 this.isFollowing = true;
2336
2337 this.mouseY0 = ev.pageY;
2338 this.mouseX0 = ev.pageX;
2339 this.topDelta = 0;
2340 this.leftDelta = 0;
2341
2342 if (!this.isHidden) {
2343 this.updatePosition();
2344 }
2345
2346 $(document).on('mousemove', this.mousemoveProxy = $.proxy(this, 'mousemove'));
2347 }
2348 },
2349
2350
2351 // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position.
2352 // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately.
2353 stop: function(shouldRevert, callback) {
2354 var _this = this;
2355 var revertDuration = this.options.revertDuration;
2356
2357 function complete() {
2358 this.isAnimating = false;
2359 _this.destroyEl();
2360
2361 this.top0 = this.left0 = null; // reset state for future updatePosition calls
2362
2363 if (callback) {
2364 callback();
2365 }
2366 }
2367
2368 if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time
2369 this.isFollowing = false;
2370
2371 $(document).off('mousemove', this.mousemoveProxy);
2372
2373 if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation?
2374 this.isAnimating = true;
2375 this.el.animate({
2376 top: this.top0,
2377 left: this.left0
2378 }, {
2379 duration: revertDuration,
2380 complete: complete
2381 });
2382 }
2383 else {
2384 complete();
2385 }
2386 }
2387 },
2388
2389
2390 // Gets the tracking element. Create it if necessary
2391 getEl: function() {
2392 var el = this.el;
2393
2394 if (!el) {
2395 this.sourceEl.width(); // hack to force IE8 to compute correct bounding box
2396 el = this.el = this.sourceEl.clone()
2397 .css({
2398 position: 'absolute',
2399 visibility: '', // in case original element was hidden (commonly through hideEvents())
2400 display: this.isHidden ? 'none' : '', // for when initially hidden
2401 margin: 0,
2402 right: 'auto', // erase and set width instead
2403 bottom: 'auto', // erase and set height instead
2404 width: this.sourceEl.width(), // explicit height in case there was a 'right' value
2405 height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value
2406 opacity: this.options.opacity || '',
2407 zIndex: this.options.zIndex
2408 })
2409 .appendTo(this.parentEl);
2410 }
2411
2412 return el;
2413 },
2414
2415
2416 // Removes the tracking element if it has already been created
2417 destroyEl: function() {
2418 if (this.el) {
2419 this.el.remove();
2420 this.el = null;
2421 }
2422 },
2423
2424
2425 // Update the CSS position of the tracking element
2426 updatePosition: function() {
2427 var sourceOffset;
2428 var origin;
2429
2430 this.getEl(); // ensure this.el
2431
2432 // make sure origin info was computed
2433 if (this.top0 === null) {
2434 this.sourceEl.width(); // hack to force IE8 to compute correct bounding box
2435 sourceOffset = this.sourceEl.offset();
2436 origin = this.el.offsetParent().offset();
2437 this.top0 = sourceOffset.top - origin.top;
2438 this.left0 = sourceOffset.left - origin.left;
2439 }
2440
2441 this.el.css({
2442 top: this.top0 + this.topDelta,
2443 left: this.left0 + this.leftDelta
2444 });
2445 },
2446
2447
2448 // Gets called when the user moves the mouse
2449 mousemove: function(ev) {
2450 this.topDelta = ev.pageY - this.mouseY0;
2451 this.leftDelta = ev.pageX - this.mouseX0;
2452
2453 if (!this.isHidden) {
2454 this.updatePosition();
2455 }
2456 },
2457
2458
2459 // Temporarily makes the tracking element invisible. Can be called before following starts
2460 hide: function() {
2461 if (!this.isHidden) {
2462 this.isHidden = true;
2463 if (this.el) {
2464 this.el.hide();
2465 }
2466 }
2467 },
2468
2469
2470 // Show the tracking element after it has been temporarily hidden
2471 show: function() {
2472 if (this.isHidden) {
2473 this.isHidden = false;
2474 this.updatePosition();
2475 this.getEl().show();
2476 }
2477 }
2478
2479});
2480
2481 /* A utility class for rendering <tr> rows.
2482----------------------------------------------------------------------------------------------------------------------*/
2483// It leverages methods of the subclass and the View to determine custom rendering behavior for each row "type"
2484// (such as highlight rows, day rows, helper rows, etc).
2485
2486var RowRenderer = Class.extend({
2487
2488 view: null, // a View object
2489 isRTL: null, // shortcut to the view's isRTL option
2490 cellHtml: '<td/>', // plain default HTML used for a cell when no other is available
2491
2492
2493 constructor: function(view) {
2494 this.view = view;
2495 this.isRTL = view.opt('isRTL');
2496 },
2497
2498
2499 // Renders the HTML for a row, leveraging custom cell-HTML-renderers based on the `rowType`.
2500 // Also applies the "intro" and "outro" cells, which are specified by the subclass and views.
2501 // `row` is an optional row number.
2502 rowHtml: function(rowType, row) {
2503 var renderCell = this.getHtmlRenderer('cell', rowType);
2504 var rowCellHtml = '';
2505 var col;
2506 var cell;
2507
2508 row = row || 0;
2509
2510 for (col = 0; col < this.colCnt; col++) {
2511 cell = this.getCell(row, col);
2512 rowCellHtml += renderCell(cell);
2513 }
2514
2515 rowCellHtml = this.bookendCells(rowCellHtml, rowType, row); // apply intro and outro
2516
2517 return '<tr>' + rowCellHtml + '</tr>';
2518 },
2519
2520
2521 // Applies the "intro" and "outro" HTML to the given cells.
2522 // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro.
2523 // `cells` can be an HTML string of <td>'s or a jQuery <tr> element
2524 // `row` is an optional row number.
2525 bookendCells: function(cells, rowType, row) {
2526 var intro = this.getHtmlRenderer('intro', rowType)(row || 0);
2527 var outro = this.getHtmlRenderer('outro', rowType)(row || 0);
2528 var prependHtml = this.isRTL ? outro : intro;
2529 var appendHtml = this.isRTL ? intro : outro;
2530
2531 if (typeof cells === 'string') {
2532 return prependHtml + cells + appendHtml;
2533 }
2534 else { // a jQuery <tr> element
2535 return cells.prepend(prependHtml).append(appendHtml);
2536 }
2537 },
2538
2539
2540 // Returns an HTML-rendering function given a specific `rendererName` (like cell, intro, or outro) and a specific
2541 // `rowType` (like day, eventSkeleton, helperSkeleton), which is optional.
2542 // If a renderer for the specific rowType doesn't exist, it will fall back to a generic renderer.
2543 // We will query the View object first for any custom rendering functions, then the methods of the subclass.
2544 getHtmlRenderer: function(rendererName, rowType) {
2545 var view = this.view;
2546 var generalName; // like "cellHtml"
2547 var specificName; // like "dayCellHtml". based on rowType
2548 var provider; // either the View or the RowRenderer subclass, whichever provided the method
2549 var renderer;
2550
2551 generalName = rendererName + 'Html';
2552 if (rowType) {
2553 specificName = rowType + capitaliseFirstLetter(rendererName) + 'Html';
2554 }
2555
2556 if (specificName && (renderer = view[specificName])) {
2557 provider = view;
2558 }
2559 else if (specificName && (renderer = this[specificName])) {
2560 provider = this;
2561 }
2562 else if ((renderer = view[generalName])) {
2563 provider = view;
2564 }
2565 else if ((renderer = this[generalName])) {
2566 provider = this;
2567 }
2568
2569 if (typeof renderer === 'function') {
2570 return function() {
2571 return renderer.apply(provider, arguments) || ''; // use correct `this` and always return a string
2572 };
2573 }
2574
2575 // the rendered can be a plain string as well. if not specified, always an empty string.
2576 return function() {
2577 return renderer || '';
2578 };
2579 }
2580
2581});
2582
2583 /* An abstract class comprised of a "grid" of cells that each represent a specific datetime
2584----------------------------------------------------------------------------------------------------------------------*/
2585
2586var Grid = fc.Grid = RowRenderer.extend({
2587
2588 start: null, // the date of the first cell
2589 end: null, // the date after the last cell
2590
2591 rowCnt: 0, // number of rows
2592 colCnt: 0, // number of cols
2593 rowData: null, // array of objects, holding misc data for each row
2594 colData: null, // array of objects, holding misc data for each column
2595
2596 el: null, // the containing element
2597 coordMap: null, // a GridCoordMap that converts pixel values to datetimes
2598 elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name.
2599
2600 documentDragStartProxy: null, // binds the Grid's scope to documentDragStart (in DayGrid.events)
2601
2602 // derived from options
2603 colHeadFormat: null, // TODO: move to another class. not applicable to all Grids
2604 eventTimeFormat: null,
2605 displayEventEnd: null,
2606
2607
2608 constructor: function() {
2609 RowRenderer.apply(this, arguments); // call the super-constructor
2610
2611 this.coordMap = new GridCoordMap(this);
2612 this.elsByFill = {};
2613 this.documentDragStartProxy = $.proxy(this, 'documentDragStart');
2614 },
2615
2616
2617 // Renders the grid into the `el` element.
2618 // Subclasses should override and call this super-method when done.
2619 render: function() {
2620 this.bindHandlers();
2621 },
2622
2623
2624 // Called when the grid's resources need to be cleaned up
2625 destroy: function() {
2626 this.unbindHandlers();
2627 },
2628
2629
2630 /* Options
2631 ------------------------------------------------------------------------------------------------------------------*/
2632
2633
2634 // Generates the format string used for the text in column headers, if not explicitly defined by 'columnFormat'
2635 // TODO: move to another class. not applicable to all Grids
2636 computeColHeadFormat: function() {
2637 // subclasses must implement if they want to use headHtml()
2638 },
2639
2640
2641 // Generates the format string used for event time text, if not explicitly defined by 'timeFormat'
2642 computeEventTimeFormat: function() {
2643 return this.view.opt('smallTimeFormat');
2644 },
2645
2646
2647 // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd'
2648 computeDisplayEventEnd: function() {
2649 return false;
2650 },
2651
2652
2653 /* Dates
2654 ------------------------------------------------------------------------------------------------------------------*/
2655
2656
2657 // Tells the grid about what period of time to display. Grid will subsequently compute dates for cell system.
2658 setRange: function(range) {
2659 var view = this.view;
2660
2661 this.start = range.start.clone();
2662 this.end = range.end.clone();
2663
2664 this.rowData = [];
2665 this.colData = [];
2666 this.updateCells();
2667
2668 // Populate option-derived settings. Look for override first, then compute if necessary.
2669 this.colHeadFormat = view.opt('columnFormat') || this.computeColHeadFormat();
2670 this.eventTimeFormat = view.opt('timeFormat') || this.computeEventTimeFormat();
2671 this.displayEventEnd = view.opt('displayEventEnd');
2672 if (this.displayEventEnd == null) {
2673 this.displayEventEnd = this.computeDisplayEventEnd();
2674 }
2675 },
2676
2677
2678 // Responsible for setting rowCnt/colCnt and any other row/col data
2679 updateCells: function() {
2680 // subclasses must implement
2681 },
2682
2683
2684 // Converts a range with an inclusive `start` and an exclusive `end` into an array of segment objects
2685 rangeToSegs: function(range) {
2686 // subclasses must implement
2687 },
2688
2689
2690 /* Cells
2691 ------------------------------------------------------------------------------------------------------------------*/
2692 // NOTE: columns are ordered left-to-right
2693
2694
2695 // Gets an object containing row/col number, misc data, and range information about the cell.
2696 // Accepts row/col values, an object with row/col properties, or a single-number offset from the first cell.
2697 getCell: function(row, col) {
2698 var cell;
2699
2700 if (col == null) {
2701 if (typeof row === 'number') { // a single-number offset
2702 col = row % this.colCnt;
2703 row = Math.floor(row / this.colCnt);
2704 }
2705 else { // an object with row/col properties
2706 col = row.col;
2707 row = row.row;
2708 }
2709 }
2710
2711 cell = { row: row, col: col };
2712
2713 $.extend(cell, this.getRowData(row), this.getColData(col));
2714 $.extend(cell, this.computeCellRange(cell));
2715
2716 return cell;
2717 },
2718
2719
2720 // Given a cell object with index and misc data, generates a range object
2721 computeCellRange: function(cell) {
2722 // subclasses must implement
2723 },
2724
2725
2726 // Retrieves misc data about the given row
2727 getRowData: function(row) {
2728 return this.rowData[row] || {};
2729 },
2730
2731
2732 // Retrieves misc data baout the given column
2733 getColData: function(col) {
2734 return this.colData[col] || {};
2735 },
2736
2737
2738 // Retrieves the element representing the given row
2739 getRowEl: function(row) {
2740 // subclasses should implement if leveraging the default getCellDayEl() or computeRowCoords()
2741 },
2742
2743
2744 // Retrieves the element representing the given column
2745 getColEl: function(col) {
2746 // subclasses should implement if leveraging the default getCellDayEl() or computeColCoords()
2747 },
2748
2749
2750 // Given a cell object, returns the element that represents the cell's whole-day
2751 getCellDayEl: function(cell) {
2752 return this.getColEl(cell.col) || this.getRowEl(cell.row);
2753 },
2754
2755
2756 /* Cell Coordinates
2757 ------------------------------------------------------------------------------------------------------------------*/
2758
2759
2760 // Computes the top/bottom coordinates of all rows.
2761 // By default, queries the dimensions of the element provided by getRowEl().
2762 computeRowCoords: function() {
2763 var items = [];
2764 var i, el;
2765 var item;
2766
2767 for (i = 0; i < this.rowCnt; i++) {
2768 el = this.getRowEl(i);
2769 item = {
2770 top: el.offset().top
2771 };
2772 if (i > 0) {
2773 items[i - 1].bottom = item.top;
2774 }
2775 items.push(item);
2776 }
2777 item.bottom = item.top + el.outerHeight();
2778
2779 return items;
2780 },
2781
2782
2783 // Computes the left/right coordinates of all rows.
2784 // By default, queries the dimensions of the element provided by getColEl().
2785 computeColCoords: function() {
2786 var items = [];
2787 var i, el;
2788 var item;
2789
2790 for (i = 0; i < this.colCnt; i++) {
2791 el = this.getColEl(i);
2792 item = {
2793 left: el.offset().left
2794 };
2795 if (i > 0) {
2796 items[i - 1].right = item.left;
2797 }
2798 items.push(item);
2799 }
2800 item.right = item.left + el.outerWidth();
2801
2802 return items;
2803 },
2804
2805
2806 /* Handlers
2807 ------------------------------------------------------------------------------------------------------------------*/
2808
2809
2810 // Attaches handlers to DOM
2811 bindHandlers: function() {
2812 var _this = this;
2813
2814 // attach a handler to the grid's root element.
2815 // we don't need to clean up in unbindHandlers or destroy, because when jQuery removes the element from the
2816 // DOM it automatically unregisters the handlers.
2817 this.el.on('mousedown', function(ev) {
2818 if (
2819 !$(ev.target).is('.fc-event-container *, .fc-more') && // not an an event element, or "more.." link
2820 !$(ev.target).closest('.fc-popover').length // not on a popover (like the "more.." events one)
2821 ) {
2822 _this.dayMousedown(ev);
2823 }
2824 });
2825
2826 // attach event-element-related handlers. in Grid.events
2827 // same garbage collection note as above.
2828 this.bindSegHandlers();
2829
2830 $(document).on('dragstart', this.documentDragStartProxy); // jqui drag
2831 },
2832
2833
2834 // Unattaches handlers from the DOM
2835 unbindHandlers: function() {
2836 $(document).off('dragstart', this.documentDragStartProxy); // jqui drag
2837 },
2838
2839
2840 // Process a mousedown on an element that represents a day. For day clicking and selecting.
2841 dayMousedown: function(ev) {
2842 var _this = this;
2843 var view = this.view;
2844 var isSelectable = view.opt('selectable');
2845 var dayClickCell; // null if invalid dayClick
2846 var selectionRange; // null if invalid selection
2847
2848 // this listener tracks a mousedown on a day element, and a subsequent drag.
2849 // if the drag ends on the same day, it is a 'dayClick'.
2850 // if 'selectable' is enabled, this listener also detects selections.
2851 var dragListener = new DragListener(this.coordMap, {
2852 //distance: 5, // needs more work if we want dayClick to fire correctly
2853 scroll: view.opt('dragScroll'),
2854 dragStart: function() {
2855 view.unselect(); // since we could be rendering a new selection, we want to clear any old one
2856 },
2857 cellOver: function(cell, isOrig) {
2858 var origCell = dragListener.origCell;
2859 if (origCell) { // click needs to have started on a cell
2860 dayClickCell = isOrig ? cell : null; // single-cell selection is a day click
2861 if (isSelectable) {
2862 selectionRange = _this.computeSelection(origCell, cell);
2863 if (selectionRange) {
2864 _this.renderSelection(selectionRange);
2865 }
2866 else {
2867 disableCursor();
2868 }
2869 }
2870 }
2871 },
2872 cellOut: function(cell) {
2873 dayClickCell = null;
2874 selectionRange = null;
2875 _this.destroySelection();
2876 enableCursor();
2877 },
2878 listenStop: function(ev) {
2879 if (dayClickCell) {
2880 view.trigger('dayClick', _this.getCellDayEl(dayClickCell), dayClickCell.start, ev);
2881 }
2882 if (selectionRange) {
2883 // the selection will already have been rendered. just report it
2884 view.reportSelection(selectionRange, ev);
2885 }
2886 enableCursor();
2887 }
2888 });
2889
2890 dragListener.mousedown(ev); // start listening, which will eventually initiate a dragStart
2891 },
2892
2893
2894 /* Event Helper
2895 ------------------------------------------------------------------------------------------------------------------*/
2896 // TODO: should probably move this to Grid.events, like we did event dragging / resizing
2897
2898
2899 // Renders a mock event over the given range.
2900 // The range's end can be null, in which case the mock event that is rendered will have a null end time.
2901 // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging.
2902 renderRangeHelper: function(range, sourceSeg) {
2903 var fakeEvent;
2904
2905 fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible
2906 fakeEvent.start = range.start.clone();
2907 fakeEvent.end = range.end ? range.end.clone() : null;
2908 fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventDateProps
2909 this.view.calendar.normalizeEventDateProps(fakeEvent);
2910
2911 // this extra className will be useful for differentiating real events from mock events in CSS
2912 fakeEvent.className = (fakeEvent.className || []).concat('fc-helper');
2913
2914 // if something external is being dragged in, don't render a resizer
2915 if (!sourceSeg) {
2916 fakeEvent.editable = false;
2917 }
2918
2919 this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering
2920 },
2921
2922
2923 // Renders a mock event
2924 renderHelper: function(event, sourceSeg) {
2925 // subclasses must implement
2926 },
2927
2928
2929 // Unrenders a mock event
2930 destroyHelper: function() {
2931 // subclasses must implement
2932 },
2933
2934
2935 /* Selection
2936 ------------------------------------------------------------------------------------------------------------------*/
2937
2938
2939 // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses.
2940 renderSelection: function(range) {
2941 this.renderHighlight(range);
2942 },
2943
2944
2945 // Unrenders any visual indications of a selection. Will unrender a highlight by default.
2946 destroySelection: function() {
2947 this.destroyHighlight();
2948 },
2949
2950
2951 // Given the first and last cells of a selection, returns a range object.
2952 // Will return something falsy if the selection is invalid (when outside of selectionConstraint for example).
2953 // Subclasses can override and provide additional data in the range object. Will be passed to renderSelection().
2954 computeSelection: function(firstCell, lastCell) {
2955 var dates = [
2956 firstCell.start,
2957 firstCell.end,
2958 lastCell.start,
2959 lastCell.end
2960 ];
2961 var range;
2962
2963 dates.sort(compareNumbers); // sorts chronologically. works with Moments
2964
2965 range = {
2966 start: dates[0].clone(),
2967 end: dates[3].clone()
2968 };
2969
2970 if (!this.view.calendar.isSelectionRangeAllowed(range)) {
2971 return null;
2972 }
2973
2974 return range;
2975 },
2976
2977
2978 /* Highlight
2979 ------------------------------------------------------------------------------------------------------------------*/
2980
2981
2982 // Renders an emphasis on the given date range. `start` is inclusive. `end` is exclusive.
2983 renderHighlight: function(range) {
2984 this.renderFill('highlight', this.rangeToSegs(range));
2985 },
2986
2987
2988 // Unrenders the emphasis on a date range
2989 destroyHighlight: function() {
2990 this.destroyFill('highlight');
2991 },
2992
2993
2994 // Generates an array of classNames for rendering the highlight. Used by the fill system.
2995 highlightSegClasses: function() {
2996 return [ 'fc-highlight' ];
2997 },
2998
2999
3000 /* Fill System (highlight, background events, business hours)
3001 ------------------------------------------------------------------------------------------------------------------*/
3002
3003
3004 // Renders a set of rectangles over the given segments of time.
3005 // Returns a subset of segs, the segs that were actually rendered.
3006 // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement
3007 renderFill: function(type, segs) {
3008 // subclasses must implement
3009 },
3010
3011
3012 // Unrenders a specific type of fill that is currently rendered on the grid
3013 destroyFill: function(type) {
3014 var el = this.elsByFill[type];
3015
3016 if (el) {
3017 el.remove();
3018 delete this.elsByFill[type];
3019 }
3020 },
3021
3022
3023 // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types.
3024 // Only returns segments that successfully rendered.
3025 // To be harnessed by renderFill (implemented by subclasses).
3026 // Analagous to renderFgSegEls.
3027 renderFillSegEls: function(type, segs) {
3028 var _this = this;
3029 var segElMethod = this[type + 'SegEl'];
3030 var html = '';
3031 var renderedSegs = [];
3032 var i;
3033
3034 if (segs.length) {
3035
3036 // build a large concatenation of segment HTML
3037 for (i = 0; i < segs.length; i++) {
3038 html += this.fillSegHtml(type, segs[i]);
3039 }
3040
3041 // Grab individual elements from the combined HTML string. Use each as the default rendering.
3042 // Then, compute the 'el' for each segment.
3043 $(html).each(function(i, node) {
3044 var seg = segs[i];
3045 var el = $(node);
3046
3047 // allow custom filter methods per-type
3048 if (segElMethod) {
3049 el = segElMethod.call(_this, seg, el);
3050 }
3051
3052 if (el) { // custom filters did not cancel the render
3053 el = $(el); // allow custom filter to return raw DOM node
3054
3055 // correct element type? (would be bad if a non-TD were inserted into a table for example)
3056 if (el.is(_this.fillSegTag)) {
3057 seg.el = el;
3058 renderedSegs.push(seg);
3059 }
3060 }
3061 });
3062 }
3063
3064 return renderedSegs;
3065 },
3066
3067
3068 fillSegTag: 'div', // subclasses can override
3069
3070
3071 // Builds the HTML needed for one fill segment. Generic enought o work with different types.
3072 fillSegHtml: function(type, seg) {
3073 var classesMethod = this[type + 'SegClasses']; // custom hooks per-type
3074 var stylesMethod = this[type + 'SegStyles']; //
3075 var classes = classesMethod ? classesMethod.call(this, seg) : [];
3076 var styles = stylesMethod ? stylesMethod.call(this, seg) : ''; // a semi-colon separated CSS property string
3077
3078 return '<' + this.fillSegTag +
3079 (classes.length ? ' class="' + classes.join(' ') + '"' : '') +
3080 (styles ? ' style="' + styles + '"' : '') +
3081 ' />';
3082 },
3083
3084
3085 /* Generic rendering utilities for subclasses
3086 ------------------------------------------------------------------------------------------------------------------*/
3087
3088
3089 // Renders a day-of-week header row.
3090 // TODO: move to another class. not applicable to all Grids
3091 headHtml: function() {
3092 return '' +
3093 '<div class="fc-row ' + this.view.widgetHeaderClass + '">' +
3094 '<table>' +
3095 '<thead>' +
3096 this.rowHtml('head') + // leverages RowRenderer
3097 '</thead>' +
3098 '</table>' +
3099 '</div>';
3100 },
3101
3102
3103 // Used by the `headHtml` method, via RowRenderer, for rendering the HTML of a day-of-week header cell
3104 // TODO: move to another class. not applicable to all Grids
3105 headCellHtml: function(cell) {
3106 var view = this.view;
3107 var date = cell.start;
3108
3109 return '' +
3110 '<th class="fc-day-header ' + view.widgetHeaderClass + ' fc-' + dayIDs[date.day()] + '">' +
3111 htmlEscape(date.format(this.colHeadFormat)) +
3112 '</th>';
3113 },
3114
3115
3116 // Renders the HTML for a single-day background cell
3117 bgCellHtml: function(cell) {
3118 var view = this.view;
3119 var date = cell.start;
3120 var classes = this.getDayClasses(date);
3121
3122 classes.unshift('fc-day', view.widgetContentClass);
3123
3124 return '<td class="' + classes.join(' ') + '"' +
3125 ' data-date="' + date.format('YYYY-MM-DD') + '"' + // if date has a time, won't format it
3126 '></td>';
3127 },
3128
3129
3130 // Computes HTML classNames for a single-day cell
3131 getDayClasses: function(date) {
3132 var view = this.view;
3133 var today = view.calendar.getNow().stripTime();
3134 var classes = [ 'fc-' + dayIDs[date.day()] ];
3135
3136 if (
3137 view.name === 'month' &&
3138 date.month() != view.intervalStart.month()
3139 ) {
3140 classes.push('fc-other-month');
3141 }
3142
3143 if (date.isSame(today, 'day')) {
3144 classes.push(
3145 'fc-today',
3146 view.highlightStateClass
3147 );
3148 }
3149 else if (date < today) {
3150 classes.push('fc-past');
3151 }
3152 else {
3153 classes.push('fc-future');
3154 }
3155
3156 return classes;
3157 }
3158
3159});
3160
3161 /* Event-rendering and event-interaction methods for the abstract Grid class
3162----------------------------------------------------------------------------------------------------------------------*/
3163
3164Grid.mixin({
3165
3166 mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing
3167 isDraggingSeg: false, // is a segment being dragged? boolean
3168 isResizingSeg: false, // is a segment being resized? boolean
3169 segs: null, // the event segments currently rendered in the grid
3170
3171
3172 // Renders the given events onto the grid
3173 renderEvents: function(events) {
3174 var segs = this.eventsToSegs(events);
3175 var bgSegs = [];
3176 var fgSegs = [];
3177 var i, seg;
3178
3179 for (i = 0; i < segs.length; i++) {
3180 seg = segs[i];
3181
3182 if (isBgEvent(seg.event)) {
3183 bgSegs.push(seg);
3184 }
3185 else {
3186 fgSegs.push(seg);
3187 }
3188 }
3189
3190 // Render each different type of segment.
3191 // Each function may return a subset of the segs, segs that were actually rendered.
3192 bgSegs = this.renderBgSegs(bgSegs) || bgSegs;
3193 fgSegs = this.renderFgSegs(fgSegs) || fgSegs;
3194
3195 this.segs = bgSegs.concat(fgSegs);
3196 },
3197
3198
3199 // Unrenders all events currently rendered on the grid
3200 destroyEvents: function() {
3201 this.triggerSegMouseout(); // trigger an eventMouseout if user's mouse is over an event
3202
3203 this.destroyFgSegs();
3204 this.destroyBgSegs();
3205
3206 this.segs = null;
3207 },
3208
3209
3210 // Retrieves all rendered segment objects currently rendered on the grid
3211 getEventSegs: function() {
3212 return this.segs || [];
3213 },
3214
3215
3216 /* Foreground Segment Rendering
3217 ------------------------------------------------------------------------------------------------------------------*/
3218
3219
3220 // Renders foreground event segments onto the grid. May return a subset of segs that were rendered.
3221 renderFgSegs: function(segs) {
3222 // subclasses must implement
3223 },
3224
3225
3226 // Unrenders all currently rendered foreground segments
3227 destroyFgSegs: function() {
3228 // subclasses must implement
3229 },
3230
3231
3232 // Renders and assigns an `el` property for each foreground event segment.
3233 // Only returns segments that successfully rendered.
3234 // A utility that subclasses may use.
3235 renderFgSegEls: function(segs, disableResizing) {
3236 var view = this.view;
3237 var html = '';
3238 var renderedSegs = [];
3239 var i;
3240
3241 if (segs.length) { // don't build an empty html string
3242
3243 // build a large concatenation of event segment HTML
3244 for (i = 0; i < segs.length; i++) {
3245 html += this.fgSegHtml(segs[i], disableResizing);
3246 }
3247
3248 // Grab individual elements from the combined HTML string. Use each as the default rendering.
3249 // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false.
3250 $(html).each(function(i, node) {
3251 var seg = segs[i];
3252 var el = view.resolveEventEl(seg.event, $(node));
3253
3254 if (el) {
3255 el.data('fc-seg', seg); // used by handlers
3256 seg.el = el;
3257 renderedSegs.push(seg);
3258 }
3259 });
3260 }
3261
3262 return renderedSegs;
3263 },
3264
3265
3266 // Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls()
3267 fgSegHtml: function(seg, disableResizing) {
3268 // subclasses should implement
3269 },
3270
3271
3272 /* Background Segment Rendering
3273 ------------------------------------------------------------------------------------------------------------------*/
3274
3275
3276 // Renders the given background event segments onto the grid.
3277 // Returns a subset of the segs that were actually rendered.
3278 renderBgSegs: function(segs) {
3279 return this.renderFill('bgEvent', segs);
3280 },
3281
3282
3283 // Unrenders all the currently rendered background event segments
3284 destroyBgSegs: function() {
3285 this.destroyFill('bgEvent');
3286 },
3287
3288
3289 // Renders a background event element, given the default rendering. Called by the fill system.
3290 bgEventSegEl: function(seg, el) {
3291 return this.view.resolveEventEl(seg.event, el); // will filter through eventRender
3292 },
3293
3294
3295 // Generates an array of classNames to be used for the default rendering of a background event.
3296 // Called by the fill system.
3297 bgEventSegClasses: function(seg) {
3298 var event = seg.event;
3299 var source = event.source || {};
3300
3301 return [ 'fc-bgevent' ].concat(
3302 event.className,
3303 source.className || []
3304 );
3305 },
3306
3307
3308 // Generates a semicolon-separated CSS string to be used for the default rendering of a background event.
3309 // Called by the fill system.
3310 // TODO: consolidate with getEventSkinCss?
3311 bgEventSegStyles: function(seg) {
3312 var view = this.view;
3313 var event = seg.event;
3314 var source = event.source || {};
3315 var eventColor = event.color;
3316 var sourceColor = source.color;
3317 var optionColor = view.opt('eventColor');
3318 var backgroundColor =
3319 event.backgroundColor ||
3320 eventColor ||
3321 source.backgroundColor ||
3322 sourceColor ||
3323 view.opt('eventBackgroundColor') ||
3324 optionColor;
3325
3326 if (backgroundColor) {
3327 return 'background-color:' + backgroundColor;
3328 }
3329
3330 return '';
3331 },
3332
3333
3334 // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system.
3335 businessHoursSegClasses: function(seg) {
3336 return [ 'fc-nonbusiness', 'fc-bgevent' ];
3337 },
3338
3339
3340 /* Handlers
3341 ------------------------------------------------------------------------------------------------------------------*/
3342
3343
3344 // Attaches event-element-related handlers to the container element and leverage bubbling
3345 bindSegHandlers: function() {
3346 var _this = this;
3347 var view = this.view;
3348
3349 $.each(
3350 {
3351 mouseenter: function(seg, ev) {
3352 _this.triggerSegMouseover(seg, ev);
3353 },
3354 mouseleave: function(seg, ev) {
3355 _this.triggerSegMouseout(seg, ev);
3356 },
3357 click: function(seg, ev) {
3358 return view.trigger('eventClick', this, seg.event, ev); // can return `false` to cancel
3359 },
3360 mousedown: function(seg, ev) {
3361 if ($(ev.target).is('.fc-resizer') && view.isEventResizable(seg.event)) {
3362 _this.segResizeMousedown(seg, ev);
3363 }
3364 else if (view.isEventDraggable(seg.event)) {
3365 _this.segDragMousedown(seg, ev);
3366 }
3367 }
3368 },
3369 function(name, func) {
3370 // attach the handler to the container element and only listen for real event elements via bubbling
3371 _this.el.on(name, '.fc-event-container > *', function(ev) {
3372 var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents
3373
3374 // only call the handlers if there is not a drag/resize in progress
3375 if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) {
3376 return func.call(this, seg, ev); // `this` will be the event element
3377 }
3378 });
3379 }
3380 );
3381 },
3382
3383
3384 // Updates internal state and triggers handlers for when an event element is moused over
3385 triggerSegMouseover: function(seg, ev) {
3386 if (!this.mousedOverSeg) {
3387 this.mousedOverSeg = seg;
3388 this.view.trigger('eventMouseover', seg.el[0], seg.event, ev);
3389 }
3390 },
3391
3392
3393 // Updates internal state and triggers handlers for when an event element is moused out.
3394 // Can be given no arguments, in which case it will mouseout the segment that was previously moused over.
3395 triggerSegMouseout: function(seg, ev) {
3396 ev = ev || {}; // if given no args, make a mock mouse event
3397
3398 if (this.mousedOverSeg) {
3399 seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment
3400 this.mousedOverSeg = null;
3401 this.view.trigger('eventMouseout', seg.el[0], seg.event, ev);
3402 }
3403 },
3404
3405
3406 /* Event Dragging
3407 ------------------------------------------------------------------------------------------------------------------*/
3408
3409
3410 // Called when the user does a mousedown on an event, which might lead to dragging.
3411 // Generic enough to work with any type of Grid.
3412 segDragMousedown: function(seg, ev) {
3413 var _this = this;
3414 var view = this.view;
3415 var el = seg.el;
3416 var event = seg.event;
3417 var dropLocation;
3418
3419 // A clone of the original element that will move with the mouse
3420 var mouseFollower = new MouseFollower(seg.el, {
3421 parentEl: view.el,
3422 opacity: view.opt('dragOpacity'),
3423 revertDuration: view.opt('dragRevertDuration'),
3424 zIndex: 2 // one above the .fc-view
3425 });
3426
3427 // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents
3428 // of the view.
3429 var dragListener = new DragListener(view.coordMap, {
3430 distance: 5,
3431 scroll: view.opt('dragScroll'),
3432 listenStart: function(ev) {
3433 mouseFollower.hide(); // don't show until we know this is a real drag
3434 mouseFollower.start(ev);
3435 },
3436 dragStart: function(ev) {
3437 _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
3438 _this.isDraggingSeg = true;
3439 view.hideEvent(event); // hide all event segments. our mouseFollower will take over
3440 view.trigger('eventDragStart', el[0], event, ev, {}); // last argument is jqui dummy
3441 },
3442 cellOver: function(cell, isOrig) {
3443 var origCell = seg.cell || dragListener.origCell; // starting cell could be forced (DayGrid.limit)
3444
3445 dropLocation = _this.computeEventDrop(origCell, cell, event);
3446 if (dropLocation) {
3447 if (view.renderDrag(dropLocation, seg)) { // have the subclass render a visual indication
3448 mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own
3449 }
3450 else {
3451 mouseFollower.show();
3452 }
3453 if (isOrig) {
3454 dropLocation = null; // needs to have moved cells to be a valid drop
3455 }
3456 }
3457 else {
3458 // have the helper follow the mouse (no snapping) with a warning-style cursor
3459 mouseFollower.show();
3460 disableCursor();
3461 }
3462 },
3463 cellOut: function() { // called before mouse moves to a different cell OR moved out of all cells
3464 dropLocation = null;
3465 view.destroyDrag(); // unrender whatever was done in renderDrag
3466 mouseFollower.show(); // show in case we are moving out of all cells
3467 enableCursor();
3468 },
3469 dragStop: function(ev) {
3470 // do revert animation if hasn't changed. calls a callback when finished (whether animation or not)
3471 mouseFollower.stop(!dropLocation, function() {
3472 _this.isDraggingSeg = false;
3473 view.destroyDrag();
3474 view.showEvent(event);
3475 view.trigger('eventDragStop', el[0], event, ev, {}); // last argument is jqui dummy
3476
3477 if (dropLocation) {
3478 view.reportEventDrop(event, dropLocation, el, ev);
3479 }
3480 });
3481 enableCursor();
3482 },
3483 listenStop: function() {
3484 mouseFollower.stop(); // put in listenStop in case there was a mousedown but the drag never started
3485 }
3486 });
3487
3488 dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart
3489 },
3490
3491
3492 // Given the cell an event drag began, and the cell event was dropped, calculates the new start/end/allDay
3493 // values for the event. Subclasses may override and set additional properties to be used by renderDrag.
3494 // A falsy returned value indicates an invalid drop.
3495 computeEventDrop: function(startCell, endCell, event) {
3496 var dragStart = startCell.start;
3497 var dragEnd = endCell.start;
3498 var delta;
3499 var newStart;
3500 var newEnd;
3501 var newAllDay;
3502 var dropLocation;
3503
3504 if (dragStart.hasTime() === dragEnd.hasTime()) {
3505 delta = diffDayTime(dragEnd, dragStart);
3506 newStart = event.start.clone().add(delta);
3507 if (event.end === null) { // do we need to compute an end?
3508 newEnd = null;
3509 }
3510 else {
3511 newEnd = event.end.clone().add(delta);
3512 }
3513 newAllDay = event.allDay; // keep it the same
3514 }
3515 else {
3516 // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared
3517 newStart = dragEnd.clone();
3518 newEnd = null; // end should be cleared
3519 newAllDay = !dragEnd.hasTime();
3520 }
3521
3522 dropLocation = {
3523 start: newStart,
3524 end: newEnd,
3525 allDay: newAllDay
3526 };
3527
3528 if (!this.view.calendar.isEventRangeAllowed(dropLocation, event)) {
3529 return null;
3530 }
3531
3532 return dropLocation;
3533 },
3534
3535
3536 /* External Element Dragging
3537 ------------------------------------------------------------------------------------------------------------------*/
3538
3539
3540 // Called when a jQuery UI drag is initiated anywhere in the DOM
3541 documentDragStart: function(ev, ui) {
3542 var view = this.view;
3543 var el;
3544 var accept;
3545
3546 if (view.opt('droppable')) { // only listen if this setting is on
3547 el = $(ev.target);
3548
3549 // Test that the dragged element passes the dropAccept selector or filter function.
3550 // FYI, the default is "*" (matches all)
3551 accept = view.opt('dropAccept');
3552 if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) {
3553
3554 this.startExternalDrag(el, ev, ui);
3555 }
3556 }
3557 },
3558
3559
3560 // Called when a jQuery UI drag starts and it needs to be monitored for cell dropping
3561 startExternalDrag: function(el, ev, ui) {
3562 var _this = this;
3563 var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create
3564 var dragListener;
3565 var dropLocation; // a null value signals an unsuccessful drag
3566
3567 // listener that tracks mouse movement over date-associated pixel regions
3568 dragListener = new DragListener(this.coordMap, {
3569 cellOver: function(cell) {
3570 dropLocation = _this.computeExternalDrop(cell, meta);
3571 if (dropLocation) {
3572 _this.renderDrag(dropLocation); // called without a seg parameter
3573 }
3574 else { // invalid drop cell
3575 disableCursor();
3576 }
3577 },
3578 cellOut: function() {
3579 dropLocation = null; // signal unsuccessful
3580 _this.destroyDrag();
3581 enableCursor();
3582 }
3583 });
3584
3585 // gets called, only once, when jqui drag is finished
3586 $(document).one('dragstop', function(ev, ui) {
3587 _this.destroyDrag();
3588 enableCursor();
3589
3590 if (dropLocation) { // element was dropped on a valid date/time cell
3591 _this.view.reportExternalDrop(meta, dropLocation, el, ev, ui);
3592 }
3593 });
3594
3595 dragListener.startDrag(ev); // start listening immediately
3596 },
3597
3598
3599 // Given a cell to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object),
3600 // returns start/end dates for the event that would result from the hypothetical drop. end might be null.
3601 // Returning a null value signals an invalid drop cell.
3602 computeExternalDrop: function(cell, meta) {
3603 var dropLocation = {
3604 start: cell.start.clone(),
3605 end: null
3606 };
3607
3608 // if dropped on an all-day cell, and element's metadata specified a time, set it
3609 if (meta.startTime && !dropLocation.start.hasTime()) {
3610 dropLocation.start.time(meta.startTime);
3611 }
3612
3613 if (meta.duration) {
3614 dropLocation.end = dropLocation.start.clone().add(meta.duration);
3615 }
3616
3617 if (!this.view.calendar.isExternalDropRangeAllowed(dropLocation, meta.eventProps)) {
3618 return null;
3619 }
3620
3621 return dropLocation;
3622 },
3623
3624
3625
3626 /* Drag Rendering (for both events and an external elements)
3627 ------------------------------------------------------------------------------------------------------------------*/
3628
3629
3630 // Renders a visual indication of an event or external element being dragged.
3631 // `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null.
3632 // `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null.
3633 // A truthy returned value indicates this method has rendered a helper element.
3634 renderDrag: function(dropLocation, seg) {
3635 // subclasses must implement
3636 },
3637
3638
3639 // Unrenders a visual indication of an event or external element being dragged
3640 destroyDrag: function() {
3641 // subclasses must implement
3642 },
3643
3644
3645 /* Resizing
3646 ------------------------------------------------------------------------------------------------------------------*/
3647
3648
3649 // Called when the user does a mousedown on an event's resizer, which might lead to resizing.
3650 // Generic enough to work with any type of Grid.
3651 segResizeMousedown: function(seg, ev) {
3652 var _this = this;
3653 var view = this.view;
3654 var calendar = view.calendar;
3655 var el = seg.el;
3656 var event = seg.event;
3657 var start = event.start;
3658 var oldEnd = calendar.getEventEnd(event);
3659 var newEnd; // falsy if invalid resize
3660 var dragListener;
3661
3662 function destroy() { // resets the rendering to show the original event
3663 _this.destroyEventResize();
3664 view.showEvent(event);
3665 enableCursor();
3666 }
3667
3668 // Tracks mouse movement over the *grid's* coordinate map
3669 dragListener = new DragListener(this.coordMap, {
3670 distance: 5,
3671 scroll: view.opt('dragScroll'),
3672 dragStart: function(ev) {
3673 _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
3674 _this.isResizingSeg = true;
3675 view.trigger('eventResizeStart', el[0], event, ev, {}); // last argument is jqui dummy
3676 },
3677 cellOver: function(cell) {
3678 newEnd = cell.end;
3679
3680 if (!newEnd.isAfter(start)) { // was end moved before start?
3681 newEnd = start.clone().add( // make the event span a single slot
3682 diffDayTime(cell.end, cell.start) // assumes all slot durations are the same
3683 );
3684 }
3685
3686 if (newEnd.isSame(oldEnd)) {
3687 newEnd = null;
3688 }
3689 else if (!calendar.isEventRangeAllowed({ start: start, end: newEnd }, event)) {
3690 newEnd = null;
3691 disableCursor();
3692 }
3693 else {
3694 _this.renderEventResize({ start: start, end: newEnd }, seg);
3695 view.hideEvent(event);
3696 }
3697 },
3698 cellOut: function() { // called before mouse moves to a different cell OR moved out of all cells
3699 newEnd = null;
3700 destroy();
3701 },
3702 dragStop: function(ev) {
3703 _this.isResizingSeg = false;
3704 destroy();
3705 view.trigger('eventResizeStop', el[0], event, ev, {}); // last argument is jqui dummy
3706
3707 if (newEnd) { // valid date to resize to?
3708 view.reportEventResize(event, newEnd, el, ev);
3709 }
3710 }
3711 });
3712
3713 dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart
3714 },
3715
3716
3717 // Renders a visual indication of an event being resized.
3718 // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag.
3719 renderEventResize: function(range, seg) {
3720 // subclasses must implement
3721 },
3722
3723
3724 // Unrenders a visual indication of an event being resized.
3725 destroyEventResize: function() {
3726 // subclasses must implement
3727 },
3728
3729
3730 /* Rendering Utils
3731 ------------------------------------------------------------------------------------------------------------------*/
3732
3733
3734 // Compute the text that should be displayed on an event's element.
3735 // `range` can be the Event object itself, or something range-like, with at least a `start`.
3736 // The `timeFormat` options and the grid's default format is used, but `formatStr` can override.
3737 getEventTimeText: function(range, formatStr) {
3738
3739 formatStr = formatStr || this.eventTimeFormat;
3740
3741 if (range.end && this.displayEventEnd) {
3742 return this.view.formatRange(range, formatStr);
3743 }
3744 else {
3745 return range.start.format(formatStr);
3746 }
3747 },
3748
3749
3750 // Generic utility for generating the HTML classNames for an event segment's element
3751 getSegClasses: function(seg, isDraggable, isResizable) {
3752 var event = seg.event;
3753 var classes = [
3754 'fc-event',
3755 seg.isStart ? 'fc-start' : 'fc-not-start',
3756 seg.isEnd ? 'fc-end' : 'fc-not-end'
3757 ].concat(
3758 event.className,
3759 event.source ? event.source.className : []
3760 );
3761
3762 if (isDraggable) {
3763 classes.push('fc-draggable');
3764 }
3765 if (isResizable) {
3766 classes.push('fc-resizable');
3767 }
3768
3769 return classes;
3770 },
3771
3772
3773 // Utility for generating a CSS string with all the event skin-related properties
3774 getEventSkinCss: function(event) {
3775 var view = this.view;
3776 var source = event.source || {};
3777 var eventColor = event.color;
3778 var sourceColor = source.color;
3779 var optionColor = view.opt('eventColor');
3780 var backgroundColor =
3781 event.backgroundColor ||
3782 eventColor ||
3783 source.backgroundColor ||
3784 sourceColor ||
3785 view.opt('eventBackgroundColor') ||
3786 optionColor;
3787 var borderColor =
3788 event.borderColor ||
3789 eventColor ||
3790 source.borderColor ||
3791 sourceColor ||
3792 view.opt('eventBorderColor') ||
3793 optionColor;
3794 var textColor =
3795 event.textColor ||
3796 source.textColor ||
3797 view.opt('eventTextColor');
3798 var statements = [];
3799 if (backgroundColor) {
3800 statements.push('background-color:' + backgroundColor);
3801 }
3802 if (borderColor) {
3803 statements.push('border-color:' + borderColor);
3804 }
3805 if (textColor) {
3806 statements.push('color:' + textColor);
3807 }
3808 return statements.join(';');
3809 },
3810
3811
3812 /* Converting events -> ranges -> segs
3813 ------------------------------------------------------------------------------------------------------------------*/
3814
3815
3816 // Converts an array of event objects into an array of event segment objects.
3817 // A custom `rangeToSegsFunc` may be given for arbitrarily slicing up events.
3818 eventsToSegs: function(events, rangeToSegsFunc) {
3819 var eventRanges = this.eventsToRanges(events);
3820 var segs = [];
3821 var i;
3822
3823 for (i = 0; i < eventRanges.length; i++) {
3824 segs.push.apply(
3825 segs,
3826 this.eventRangeToSegs(eventRanges[i], rangeToSegsFunc)
3827 );
3828 }
3829
3830 return segs;
3831 },
3832
3833
3834 // Converts an array of events into an array of "range" objects.
3835 // A "range" object is a plain object with start/end properties denoting the time it covers. Also an event property.
3836 // For "normal" events, this will be identical to the event's start/end, but for "inverse-background" events,
3837 // will create an array of ranges that span the time *not* covered by the given event.
3838 eventsToRanges: function(events) {
3839 var _this = this;
3840 var eventsById = groupEventsById(events);
3841 var ranges = [];
3842
3843 // group by ID so that related inverse-background events can be rendered together
3844 $.each(eventsById, function(id, eventGroup) {
3845 if (eventGroup.length) {
3846 ranges.push.apply(
3847 ranges,
3848 isInverseBgEvent(eventGroup[0]) ?
3849 _this.eventsToInverseRanges(eventGroup) :
3850 _this.eventsToNormalRanges(eventGroup)
3851 );
3852 }
3853 });
3854
3855 return ranges;
3856 },
3857
3858
3859 // Converts an array of "normal" events (not inverted rendering) into a parallel array of ranges
3860 eventsToNormalRanges: function(events) {
3861 var calendar = this.view.calendar;
3862 var ranges = [];
3863 var i, event;
3864 var eventStart, eventEnd;
3865
3866 for (i = 0; i < events.length; i++) {
3867 event = events[i];
3868
3869 // make copies and normalize by stripping timezone
3870 eventStart = event.start.clone().stripZone();
3871 eventEnd = calendar.getEventEnd(event).stripZone();
3872
3873 ranges.push({
3874 event: event,
3875 start: eventStart,
3876 end: eventEnd,
3877 eventStartMS: +eventStart,
3878 eventDurationMS: eventEnd - eventStart
3879 });
3880 }
3881
3882 return ranges;
3883 },
3884
3885
3886 // Converts an array of events, with inverse-background rendering, into an array of range objects.
3887 // The range objects will cover all the time NOT covered by the events.
3888 eventsToInverseRanges: function(events) {
3889 var view = this.view;
3890 var viewStart = view.start.clone().stripZone(); // normalize timezone
3891 var viewEnd = view.end.clone().stripZone(); // normalize timezone
3892 var normalRanges = this.eventsToNormalRanges(events); // will give us normalized dates we can use w/o copies
3893 var inverseRanges = [];
3894 var event0 = events[0]; // assign this to each range's `.event`
3895 var start = viewStart; // the end of the previous range. the start of the new range
3896 var i, normalRange;
3897
3898 // ranges need to be in order. required for our date-walking algorithm
3899 normalRanges.sort(compareNormalRanges);
3900
3901 for (i = 0; i < normalRanges.length; i++) {
3902 normalRange = normalRanges[i];
3903
3904 // add the span of time before the event (if there is any)
3905 if (normalRange.start > start) { // compare millisecond time (skip any ambig logic)
3906 inverseRanges.push({
3907 event: event0,
3908 start: start,
3909 end: normalRange.start
3910 });
3911 }
3912
3913 start = normalRange.end;
3914 }
3915
3916 // add the span of time after the last event (if there is any)
3917 if (start < viewEnd) { // compare millisecond time (skip any ambig logic)
3918 inverseRanges.push({
3919 event: event0,
3920 start: start,
3921 end: viewEnd
3922 });
3923 }
3924
3925 return inverseRanges;
3926 },
3927
3928
3929 // Slices the given event range into one or more segment objects.
3930 // A `rangeToSegsFunc` custom slicing function can be given.
3931 eventRangeToSegs: function(eventRange, rangeToSegsFunc) {
3932 var segs;
3933 var i, seg;
3934
3935 if (rangeToSegsFunc) {
3936 segs = rangeToSegsFunc(eventRange);
3937 }
3938 else {
3939 segs = this.rangeToSegs(eventRange); // defined by the subclass
3940 }
3941
3942 for (i = 0; i < segs.length; i++) {
3943 seg = segs[i];
3944 seg.event = eventRange.event;
3945 seg.eventStartMS = eventRange.eventStartMS;
3946 seg.eventDurationMS = eventRange.eventDurationMS;
3947 }
3948
3949 return segs;
3950 }
3951
3952});
3953
3954
3955/* Utilities
3956----------------------------------------------------------------------------------------------------------------------*/
3957
3958
3959function isBgEvent(event) { // returns true if background OR inverse-background
3960 var rendering = getEventRendering(event);
3961 return rendering === 'background' || rendering === 'inverse-background';
3962}
3963
3964
3965function isInverseBgEvent(event) {
3966 return getEventRendering(event) === 'inverse-background';
3967}
3968
3969
3970function getEventRendering(event) {
3971 return firstDefined((event.source || {}).rendering, event.rendering);
3972}
3973
3974
3975function groupEventsById(events) {
3976 var eventsById = {};
3977 var i, event;
3978
3979 for (i = 0; i < events.length; i++) {
3980 event = events[i];
3981 (eventsById[event._id] || (eventsById[event._id] = [])).push(event);
3982 }
3983
3984 return eventsById;
3985}
3986
3987
3988// A cmp function for determining which non-inverted "ranges" (see above) happen earlier
3989function compareNormalRanges(range1, range2) {
3990 return range1.eventStartMS - range2.eventStartMS; // earlier ranges go first
3991}
3992
3993
3994// A cmp function for determining which segments should take visual priority
3995// DOES NOT WORK ON INVERTED BACKGROUND EVENTS because they have no eventStartMS/eventDurationMS
3996function compareSegs(seg1, seg2) {
3997 return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first
3998 seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first
3999 seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1)
4000 (seg1.event.title || '').localeCompare(seg2.event.title); // tie? alphabetically by title
4001}
4002
4003fc.compareSegs = compareSegs; // export
4004
4005
4006/* External-Dragging-Element Data
4007----------------------------------------------------------------------------------------------------------------------*/
4008
4009// Require all HTML5 data-* attributes used by FullCalendar to have this prefix.
4010// A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event.
4011fc.dataAttrPrefix = '';
4012
4013// Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure
4014// to be used for Event Object creation.
4015// A defined `.eventProps`, even when empty, indicates that an event should be created.
4016function getDraggedElMeta(el) {
4017 var prefix = fc.dataAttrPrefix;
4018 var eventProps; // properties for creating the event, not related to date/time
4019 var startTime; // a Duration
4020 var duration;
4021 var stick;
4022
4023 if (prefix) { prefix += '-'; }
4024 eventProps = el.data(prefix + 'event') || null;
4025
4026 if (eventProps) {
4027 if (typeof eventProps === 'object') {
4028 eventProps = $.extend({}, eventProps); // make a copy
4029 }
4030 else { // something like 1 or true. still signal event creation
4031 eventProps = {};
4032 }
4033
4034 // pluck special-cased date/time properties
4035 startTime = eventProps.start;
4036 if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well
4037 duration = eventProps.duration;
4038 stick = eventProps.stick;
4039 delete eventProps.start;
4040 delete eventProps.time;
4041 delete eventProps.duration;
4042 delete eventProps.stick;
4043 }
4044
4045 // fallback to standalone attribute values for each of the date/time properties
4046 if (startTime == null) { startTime = el.data(prefix + 'start'); }
4047 if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well
4048 if (duration == null) { duration = el.data(prefix + 'duration'); }
4049 if (stick == null) { stick = el.data(prefix + 'stick'); }
4050
4051 // massage into correct data types
4052 startTime = startTime != null ? moment.duration(startTime) : null;
4053 duration = duration != null ? moment.duration(duration) : null;
4054 stick = Boolean(stick);
4055
4056 return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick };
4057}
4058
4059
4060 /* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week.
4061----------------------------------------------------------------------------------------------------------------------*/
4062
4063var DayGrid = Grid.extend({
4064
4065 numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal
4066 bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid
4067 breakOnWeeks: null, // should create a new row for each week? set by outside view
4068
4069 cellDates: null, // flat chronological array of each cell's dates
4070 dayToCellOffsets: null, // maps days offsets from grid's start date, to cell offsets
4071
4072 rowEls: null, // set of fake row elements
4073 dayEls: null, // set of whole-day elements comprising the row's background
4074 helperEls: null, // set of cell skeleton elements for rendering the mock event "helper"
4075
4076
4077 // Renders the rows and columns into the component's `this.el`, which should already be assigned.
4078 // isRigid determins whether the individual rows should ignore the contents and be a constant height.
4079 // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient.
4080 render: function(isRigid) {
4081 var view = this.view;
4082 var rowCnt = this.rowCnt;
4083 var colCnt = this.colCnt;
4084 var cellCnt = rowCnt * colCnt;
4085 var html = '';
4086 var row;
4087 var i, cell;
4088
4089 for (row = 0; row < rowCnt; row++) {
4090 html += this.dayRowHtml(row, isRigid);
4091 }
4092 this.el.html(html);
4093
4094 this.rowEls = this.el.find('.fc-row');
4095 this.dayEls = this.el.find('.fc-day');
4096
4097 // trigger dayRender with each cell's element
4098 for (i = 0; i < cellCnt; i++) {
4099 cell = this.getCell(i);
4100 view.trigger('dayRender', null, cell.start, this.dayEls.eq(i));
4101 }
4102
4103 Grid.prototype.render.call(this); // call the super-method
4104 },
4105
4106
4107 destroy: function() {
4108 this.destroySegPopover();
4109 Grid.prototype.destroy.call(this); // call the super-method
4110 },
4111
4112
4113 // Generates the HTML for a single row. `row` is the row number.
4114 dayRowHtml: function(row, isRigid) {
4115 var view = this.view;
4116 var classes = [ 'fc-row', 'fc-week', view.widgetContentClass ];
4117
4118 if (isRigid) {
4119 classes.push('fc-rigid');
4120 }
4121
4122 return '' +
4123 '<div class="' + classes.join(' ') + '">' +
4124 '<div class="fc-bg">' +
4125 '<table>' +
4126 this.rowHtml('day', row) + // leverages RowRenderer. calls dayCellHtml()
4127 '</table>' +
4128 '</div>' +
4129 '<div class="fc-content-skeleton">' +
4130 '<table>' +
4131 (this.numbersVisible ?
4132 '<thead>' +
4133 this.rowHtml('number', row) + // leverages RowRenderer. View will define render method
4134 '</thead>' :
4135 ''
4136 ) +
4137 '</table>' +
4138 '</div>' +
4139 '</div>';
4140 },
4141
4142
4143 // Renders the HTML for a whole-day cell. Will eventually end up in the day-row's background.
4144 // We go through a 'day' row type instead of just doing a 'bg' row type so that the View can do custom rendering
4145 // specifically for whole-day rows, whereas a 'bg' might also be used for other purposes (TimeGrid bg for example).
4146 dayCellHtml: function(cell) {
4147 return this.bgCellHtml(cell);
4148 },
4149
4150
4151 /* Options
4152 ------------------------------------------------------------------------------------------------------------------*/
4153
4154
4155 // Computes a default column header formatting string if `colFormat` is not explicitly defined
4156 computeColHeadFormat: function() {
4157 if (this.rowCnt > 1) { // more than one week row. day numbers will be in each cell
4158 return 'ddd'; // "Sat"
4159 }
4160 else if (this.colCnt > 1) { // multiple days, so full single date string WON'T be in title text
4161 return this.view.opt('dayOfMonthFormat'); // "Sat 12/10"
4162 }
4163 else { // single day, so full single date string will probably be in title text
4164 return 'dddd'; // "Saturday"
4165 }
4166 },
4167
4168
4169 // Computes a default event time formatting string if `timeFormat` is not explicitly defined
4170 computeEventTimeFormat: function() {
4171 return this.view.opt('extraSmallTimeFormat'); // like "6p" or "6:30p"
4172 },
4173
4174
4175 // Computes a default `displayEventEnd` value if one is not expliclty defined
4176 computeDisplayEventEnd: function() {
4177 return this.colCnt == 1; // we'll likely have space if there's only one day
4178 },
4179
4180
4181 /* Cell System
4182 ------------------------------------------------------------------------------------------------------------------*/
4183
4184
4185 // Initializes row/col information
4186 updateCells: function() {
4187 var cellDates;
4188 var firstDay;
4189 var rowCnt;
4190 var colCnt;
4191
4192 this.updateCellDates(); // populates cellDates and dayToCellOffsets
4193 cellDates = this.cellDates;
4194
4195 if (this.breakOnWeeks) {
4196 // count columns until the day-of-week repeats
4197 firstDay = cellDates[0].day();
4198 for (colCnt = 1; colCnt < cellDates.length; colCnt++) {
4199 if (cellDates[colCnt].day() == firstDay) {
4200 break;
4201 }
4202 }
4203 rowCnt = Math.ceil(cellDates.length / colCnt);
4204 }
4205 else {
4206 rowCnt = 1;
4207 colCnt = cellDates.length;
4208 }
4209
4210 this.rowCnt = rowCnt;
4211 this.colCnt = colCnt;
4212 },
4213
4214
4215 // Populates cellDates and dayToCellOffsets
4216 updateCellDates: function() {
4217 var view = this.view;
4218 var date = this.start.clone();
4219 var dates = [];
4220 var offset = -1;
4221 var offsets = [];
4222
4223 while (date.isBefore(this.end)) { // loop each day from start to end
4224 if (view.isHiddenDay(date)) {
4225 offsets.push(offset + 0.5); // mark that it's between offsets
4226 }
4227 else {
4228 offset++;
4229 offsets.push(offset);
4230 dates.push(date.clone());
4231 }
4232 date.add(1, 'days');
4233 }
4234
4235 this.cellDates = dates;
4236 this.dayToCellOffsets = offsets;
4237 },
4238
4239
4240 // Given a cell object, generates a range object
4241 computeCellRange: function(cell) {
4242 var colCnt = this.colCnt;
4243 var index = cell.row * colCnt + (this.isRTL ? colCnt - cell.col - 1 : cell.col);
4244 var start = this.cellDates[index].clone();
4245 var end = start.clone().add(1, 'day');
4246
4247 return { start: start, end: end };
4248 },
4249
4250
4251 // Retrieves the element representing the given row
4252 getRowEl: function(row) {
4253 return this.rowEls.eq(row);
4254 },
4255
4256
4257 // Retrieves the element representing the given column
4258 getColEl: function(col) {
4259 return this.dayEls.eq(col);
4260 },
4261
4262
4263 // Gets the whole-day element associated with the cell
4264 getCellDayEl: function(cell) {
4265 return this.dayEls.eq(cell.row * this.colCnt + cell.col);
4266 },
4267
4268
4269 // Overrides Grid's method for when row coordinates are computed
4270 computeRowCoords: function() {
4271 var rowCoords = Grid.prototype.computeRowCoords.call(this); // call the super-method
4272
4273 // hack for extending last row (used by AgendaView)
4274 rowCoords[rowCoords.length - 1].bottom += this.bottomCoordPadding;
4275
4276 return rowCoords;
4277 },
4278
4279
4280 /* Dates
4281 ------------------------------------------------------------------------------------------------------------------*/
4282
4283
4284 // Slices up a date range by row into an array of segments
4285 rangeToSegs: function(range) {
4286 var isRTL = this.isRTL;
4287 var rowCnt = this.rowCnt;
4288 var colCnt = this.colCnt;
4289 var segs = [];
4290 var first, last; // inclusive cell-offset range for given range
4291 var row;
4292 var rowFirst, rowLast; // inclusive cell-offset range for current row
4293 var isStart, isEnd;
4294 var segFirst, segLast; // inclusive cell-offset range for segment
4295 var seg;
4296
4297 range = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold
4298 first = this.dateToCellOffset(range.start);
4299 last = this.dateToCellOffset(range.end.subtract(1, 'days')); // offset of inclusive end date
4300
4301 for (row = 0; row < rowCnt; row++) {
4302 rowFirst = row * colCnt;
4303 rowLast = rowFirst + colCnt - 1;
4304
4305 // intersect segment's offset range with the row's
4306 segFirst = Math.max(rowFirst, first);
4307 segLast = Math.min(rowLast, last);
4308
4309 // deal with in-between indices
4310 segFirst = Math.ceil(segFirst); // in-between starts round to next cell
4311 segLast = Math.floor(segLast); // in-between ends round to prev cell
4312
4313 if (segFirst <= segLast) { // was there any intersection with the current row?
4314
4315 // must be matching integers to be the segment's start/end
4316 isStart = segFirst === first;
4317 isEnd = segLast === last;
4318
4319 // translate offsets to be relative to start-of-row
4320 segFirst -= rowFirst;
4321 segLast -= rowFirst;
4322
4323 seg = { row: row, isStart: isStart, isEnd: isEnd };
4324 if (isRTL) {
4325 seg.leftCol = colCnt - segLast - 1;
4326 seg.rightCol = colCnt - segFirst - 1;
4327 }
4328 else {
4329 seg.leftCol = segFirst;
4330 seg.rightCol = segLast;
4331 }
4332 segs.push(seg);
4333 }
4334 }
4335
4336 return segs;
4337 },
4338
4339
4340 // Given a date, returns its chronolocial cell-offset from the first cell of the grid.
4341 // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets.
4342 // If before the first offset, returns a negative number.
4343 // If after the last offset, returns an offset past the last cell offset.
4344 // Only works for *start* dates of cells. Will not work for exclusive end dates for cells.
4345 dateToCellOffset: function(date) {
4346 var offsets = this.dayToCellOffsets;
4347 var day = date.diff(this.start, 'days');
4348
4349 if (day < 0) {
4350 return offsets[0] - 1;
4351 }
4352 else if (day >= offsets.length) {
4353 return offsets[offsets.length - 1] + 1;
4354 }
4355 else {
4356 return offsets[day];
4357 }
4358 },
4359
4360
4361 /* Event Drag Visualization
4362 ------------------------------------------------------------------------------------------------------------------*/
4363 // TODO: move to DayGrid.event, similar to what we did with Grid's drag methods
4364
4365
4366 // Renders a visual indication of an event or external element being dragged.
4367 // The dropLocation's end can be null. seg can be null. See Grid::renderDrag for more info.
4368 renderDrag: function(dropLocation, seg) {
4369 var opacity;
4370
4371 // always render a highlight underneath
4372 this.renderHighlight(
4373 this.view.calendar.ensureVisibleEventRange(dropLocation) // needs to be a proper range
4374 );
4375
4376 // if a segment from the same calendar but another component is being dragged, render a helper event
4377 if (seg && !seg.el.closest(this.el).length) {
4378
4379 this.renderRangeHelper(dropLocation, seg);
4380
4381 opacity = this.view.opt('dragOpacity');
4382 if (opacity !== undefined) {
4383 this.helperEls.css('opacity', opacity);
4384 }
4385
4386 return true; // a helper has been rendered
4387 }
4388 },
4389
4390
4391 // Unrenders any visual indication of a hovering event
4392 destroyDrag: function() {
4393 this.destroyHighlight();
4394 this.destroyHelper();
4395 },
4396
4397
4398 /* Event Resize Visualization
4399 ------------------------------------------------------------------------------------------------------------------*/
4400
4401
4402 // Renders a visual indication of an event being resized
4403 renderEventResize: function(range, seg) {
4404 this.renderHighlight(range);
4405 this.renderRangeHelper(range, seg);
4406 },
4407
4408
4409 // Unrenders a visual indication of an event being resized
4410 destroyEventResize: function() {
4411 this.destroyHighlight();
4412 this.destroyHelper();
4413 },
4414
4415
4416 /* Event Helper
4417 ------------------------------------------------------------------------------------------------------------------*/
4418
4419
4420 // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null.
4421 renderHelper: function(event, sourceSeg) {
4422 var helperNodes = [];
4423 var segs = this.eventsToSegs([ event ]);
4424 var rowStructs;
4425
4426 segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered
4427 rowStructs = this.renderSegRows(segs);
4428
4429 // inject each new event skeleton into each associated row
4430 this.rowEls.each(function(row, rowNode) {
4431 var rowEl = $(rowNode); // the .fc-row
4432 var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned
4433 var skeletonTop;
4434
4435 // If there is an original segment, match the top position. Otherwise, put it at the row's top level
4436 if (sourceSeg && sourceSeg.row === row) {
4437 skeletonTop = sourceSeg.el.position().top;
4438 }
4439 else {
4440 skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top;
4441 }
4442
4443 skeletonEl.css('top', skeletonTop)
4444 .find('table')
4445 .append(rowStructs[row].tbodyEl);
4446
4447 rowEl.append(skeletonEl);
4448 helperNodes.push(skeletonEl[0]);
4449 });
4450
4451 this.helperEls = $(helperNodes); // array -> jQuery set
4452 },
4453
4454
4455 // Unrenders any visual indication of a mock helper event
4456 destroyHelper: function() {
4457 if (this.helperEls) {
4458 this.helperEls.remove();
4459 this.helperEls = null;
4460 }
4461 },
4462
4463
4464 /* Fill System (highlight, background events, business hours)
4465 ------------------------------------------------------------------------------------------------------------------*/
4466
4467
4468 fillSegTag: 'td', // override the default tag name
4469
4470
4471 // Renders a set of rectangles over the given segments of days.
4472 // Only returns segments that successfully rendered.
4473 renderFill: function(type, segs) {
4474 var nodes = [];
4475 var i, seg;
4476 var skeletonEl;
4477
4478 segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs
4479
4480 for (i = 0; i < segs.length; i++) {
4481 seg = segs[i];
4482 skeletonEl = this.renderFillRow(type, seg);
4483 this.rowEls.eq(seg.row).append(skeletonEl);
4484 nodes.push(skeletonEl[0]);
4485 }
4486
4487 this.elsByFill[type] = $(nodes);
4488
4489 return segs;
4490 },
4491
4492
4493 // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered.
4494 renderFillRow: function(type, seg) {
4495 var colCnt = this.colCnt;
4496 var startCol = seg.leftCol;
4497 var endCol = seg.rightCol + 1;
4498 var skeletonEl;
4499 var trEl;
4500
4501 skeletonEl = $(
4502 '<div class="fc-' + type.toLowerCase() + '-skeleton">' +
4503 '<table><tr/></table>' +
4504 '</div>'
4505 );
4506 trEl = skeletonEl.find('tr');
4507
4508 if (startCol > 0) {
4509 trEl.append('<td colspan="' + startCol + '"/>');
4510 }
4511
4512 trEl.append(
4513 seg.el.attr('colspan', endCol - startCol)
4514 );
4515
4516 if (endCol < colCnt) {
4517 trEl.append('<td colspan="' + (colCnt - endCol) + '"/>');
4518 }
4519
4520 this.bookendCells(trEl, type);
4521
4522 return skeletonEl;
4523 }
4524
4525});
4526
4527 /* Event-rendering methods for the DayGrid class
4528----------------------------------------------------------------------------------------------------------------------*/
4529
4530DayGrid.mixin({
4531
4532 rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering
4533
4534
4535 // Unrenders all events currently rendered on the grid
4536 destroyEvents: function() {
4537 this.destroySegPopover(); // removes the "more.." events popover
4538 Grid.prototype.destroyEvents.apply(this, arguments); // calls the super-method
4539 },
4540
4541
4542 // Retrieves all rendered segment objects currently rendered on the grid
4543 getEventSegs: function() {
4544 return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method
4545 .concat(this.popoverSegs || []); // append the segments from the "more..." popover
4546 },
4547
4548
4549 // Renders the given background event segments onto the grid
4550 renderBgSegs: function(segs) {
4551
4552 // don't render timed background events
4553 var allDaySegs = $.grep(segs, function(seg) {
4554 return seg.event.allDay;
4555 });
4556
4557 return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method
4558 },
4559
4560
4561 // Renders the given foreground event segments onto the grid
4562 renderFgSegs: function(segs) {
4563 var rowStructs;
4564
4565 // render an `.el` on each seg
4566 // returns a subset of the segs. segs that were actually rendered
4567 segs = this.renderFgSegEls(segs);
4568
4569 rowStructs = this.rowStructs = this.renderSegRows(segs);
4570
4571 // append to each row's content skeleton
4572 this.rowEls.each(function(i, rowNode) {
4573 $(rowNode).find('.fc-content-skeleton > table').append(
4574 rowStructs[i].tbodyEl
4575 );
4576 });
4577
4578 return segs; // return only the segs that were actually rendered
4579 },
4580
4581
4582 // Unrenders all currently rendered foreground event segments
4583 destroyFgSegs: function() {
4584 var rowStructs = this.rowStructs || [];
4585 var rowStruct;
4586
4587 while ((rowStruct = rowStructs.pop())) {
4588 rowStruct.tbodyEl.remove();
4589 }
4590
4591 this.rowStructs = null;
4592 },
4593
4594
4595 // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton.
4596 // Returns an array of rowStruct objects (see the bottom of `renderSegRow`).
4597 // PRECONDITION: each segment shoud already have a rendered and assigned `.el`
4598 renderSegRows: function(segs) {
4599 var rowStructs = [];
4600 var segRows;
4601 var row;
4602
4603 segRows = this.groupSegRows(segs); // group into nested arrays
4604
4605 // iterate each row of segment groupings
4606 for (row = 0; row < segRows.length; row++) {
4607 rowStructs.push(
4608 this.renderSegRow(row, segRows[row])
4609 );
4610 }
4611
4612 return rowStructs;
4613 },
4614
4615
4616 // Builds the HTML to be used for the default element for an individual segment
4617 fgSegHtml: function(seg, disableResizing) {
4618 var view = this.view;
4619 var event = seg.event;
4620 var isDraggable = view.isEventDraggable(event);
4621 var isResizable = !disableResizing && event.allDay && seg.isEnd && view.isEventResizable(event);
4622 var classes = this.getSegClasses(seg, isDraggable, isResizable);
4623 var skinCss = this.getEventSkinCss(event);
4624 var timeHtml = '';
4625 var titleHtml;
4626
4627 classes.unshift('fc-day-grid-event');
4628
4629 // Only display a timed events time if it is the starting segment
4630 if (!event.allDay && seg.isStart) {
4631 timeHtml = '<span class="fc-time">' + htmlEscape(this.getEventTimeText(event)) + '</span>';
4632 }
4633
4634 titleHtml =
4635 '<span class="fc-title">' +
4636 (htmlEscape(event.title || '') || '&nbsp;') + // we always want one line of height
4637 '</span>';
4638
4639 return '<a class="' + classes.join(' ') + '"' +
4640 (event.url ?
4641 ' href="' + htmlEscape(event.url) + '"' :
4642 ''
4643 ) +
4644 (skinCss ?
4645 ' style="' + skinCss + '"' :
4646 ''
4647 ) +
4648 '>' +
4649 '<div class="fc-content">' +
4650 (this.isRTL ?
4651 titleHtml + ' ' + timeHtml : // put a natural space in between
4652 timeHtml + ' ' + titleHtml //
4653 ) +
4654 '</div>' +
4655 (isResizable ?
4656 '<div class="fc-resizer"/>' :
4657 ''
4658 ) +
4659 '</a>';
4660 },
4661
4662
4663 // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains
4664 // the segments. Returns object with a bunch of internal data about how the render was calculated.
4665 renderSegRow: function(row, rowSegs) {
4666 var colCnt = this.colCnt;
4667 var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels
4668 var levelCnt = Math.max(1, segLevels.length); // ensure at least one level
4669 var tbody = $('<tbody/>');
4670 var segMatrix = []; // lookup for which segments are rendered into which level+col cells
4671 var cellMatrix = []; // lookup for all <td> elements of the level+col matrix
4672 var loneCellMatrix = []; // lookup for <td> elements that only take up a single column
4673 var i, levelSegs;
4674 var col;
4675 var tr;
4676 var j, seg;
4677 var td;
4678
4679 // populates empty cells from the current column (`col`) to `endCol`
4680 function emptyCellsUntil(endCol) {
4681 while (col < endCol) {
4682 // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell
4683 td = (loneCellMatrix[i - 1] || [])[col];
4684 if (td) {
4685 td.attr(
4686 'rowspan',
4687 parseInt(td.attr('rowspan') || 1, 10) + 1
4688 );
4689 }
4690 else {
4691 td = $('<td/>');
4692 tr.append(td);
4693 }
4694 cellMatrix[i][col] = td;
4695 loneCellMatrix[i][col] = td;
4696 col++;
4697 }
4698 }
4699
4700 for (i = 0; i < levelCnt; i++) { // iterate through all levels
4701 levelSegs = segLevels[i];
4702 col = 0;
4703 tr = $('<tr/>');
4704
4705 segMatrix.push([]);
4706 cellMatrix.push([]);
4707 loneCellMatrix.push([]);
4708
4709 // levelCnt might be 1 even though there are no actual levels. protect against this.
4710 // this single empty row is useful for styling.
4711 if (levelSegs) {
4712 for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level
4713 seg = levelSegs[j];
4714
4715 emptyCellsUntil(seg.leftCol);
4716
4717 // create a container that occupies or more columns. append the event element.
4718 td = $('<td class="fc-event-container"/>').append(seg.el);
4719 if (seg.leftCol != seg.rightCol) {
4720 td.attr('colspan', seg.rightCol - seg.leftCol + 1);
4721 }
4722 else { // a single-column segment
4723 loneCellMatrix[i][col] = td;
4724 }
4725
4726 while (col <= seg.rightCol) {
4727 cellMatrix[i][col] = td;
4728 segMatrix[i][col] = seg;
4729 col++;
4730 }
4731
4732 tr.append(td);
4733 }
4734 }
4735
4736 emptyCellsUntil(colCnt); // finish off the row
4737 this.bookendCells(tr, 'eventSkeleton');
4738 tbody.append(tr);
4739 }
4740
4741 return { // a "rowStruct"
4742 row: row, // the row number
4743 tbodyEl: tbody,
4744 cellMatrix: cellMatrix,
4745 segMatrix: segMatrix,
4746 segLevels: segLevels,
4747 segs: rowSegs
4748 };
4749 },
4750
4751
4752 // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels.
4753 buildSegLevels: function(segs) {
4754 var levels = [];
4755 var i, seg;
4756 var j;
4757
4758 // Give preference to elements with certain criteria, so they have
4759 // a chance to be closer to the top.
4760 segs.sort(compareSegs);
4761
4762 for (i = 0; i < segs.length; i++) {
4763 seg = segs[i];
4764
4765 // loop through levels, starting with the topmost, until the segment doesn't collide with other segments
4766 for (j = 0; j < levels.length; j++) {
4767 if (!isDaySegCollision(seg, levels[j])) {
4768 break;
4769 }
4770 }
4771 // `j` now holds the desired subrow index
4772 seg.level = j;
4773
4774 // create new level array if needed and append segment
4775 (levels[j] || (levels[j] = [])).push(seg);
4776 }
4777
4778 // order segments left-to-right. very important if calendar is RTL
4779 for (j = 0; j < levels.length; j++) {
4780 levels[j].sort(compareDaySegCols);
4781 }
4782
4783 return levels;
4784 },
4785
4786
4787 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row
4788 groupSegRows: function(segs) {
4789 var segRows = [];
4790 var i;
4791
4792 for (i = 0; i < this.rowCnt; i++) {
4793 segRows.push([]);
4794 }
4795
4796 for (i = 0; i < segs.length; i++) {
4797 segRows[segs[i].row].push(segs[i]);
4798 }
4799
4800 return segRows;
4801 }
4802
4803});
4804
4805
4806// Computes whether two segments' columns collide. They are assumed to be in the same row.
4807function isDaySegCollision(seg, otherSegs) {
4808 var i, otherSeg;
4809
4810 for (i = 0; i < otherSegs.length; i++) {
4811 otherSeg = otherSegs[i];
4812
4813 if (
4814 otherSeg.leftCol <= seg.rightCol &&
4815 otherSeg.rightCol >= seg.leftCol
4816 ) {
4817 return true;
4818 }
4819 }
4820
4821 return false;
4822}
4823
4824
4825// A cmp function for determining the leftmost event
4826function compareDaySegCols(a, b) {
4827 return a.leftCol - b.leftCol;
4828}
4829
4830 /* Methods relate to limiting the number events for a given day on a DayGrid
4831----------------------------------------------------------------------------------------------------------------------*/
4832// NOTE: all the segs being passed around in here are foreground segs
4833
4834DayGrid.mixin({
4835
4836 segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible
4837 popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible
4838
4839
4840 destroySegPopover: function() {
4841 if (this.segPopover) {
4842 this.segPopover.hide(); // will trigger destruction of `segPopover` and `popoverSegs`
4843 }
4844 },
4845
4846
4847 // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid.
4848 // `levelLimit` can be false (don't limit), a number, or true (should be computed).
4849 limitRows: function(levelLimit) {
4850 var rowStructs = this.rowStructs || [];
4851 var row; // row #
4852 var rowLevelLimit;
4853
4854 for (row = 0; row < rowStructs.length; row++) {
4855 this.unlimitRow(row);
4856
4857 if (!levelLimit) {
4858 rowLevelLimit = false;
4859 }
4860 else if (typeof levelLimit === 'number') {
4861 rowLevelLimit = levelLimit;
4862 }
4863 else {
4864 rowLevelLimit = this.computeRowLevelLimit(row);
4865 }
4866
4867 if (rowLevelLimit !== false) {
4868 this.limitRow(row, rowLevelLimit);
4869 }
4870 }
4871 },
4872
4873
4874 // Computes the number of levels a row will accomodate without going outside its bounds.
4875 // Assumes the row is "rigid" (maintains a constant height regardless of what is inside).
4876 // `row` is the row number.
4877 computeRowLevelLimit: function(row) {
4878 var rowEl = this.rowEls.eq(row); // the containing "fake" row div
4879 var rowHeight = rowEl.height(); // TODO: cache somehow?
4880 var trEls = this.rowStructs[row].tbodyEl.children();
4881 var i, trEl;
4882
4883 // Reveal one level <tr> at a time and stop when we find one out of bounds
4884 for (i = 0; i < trEls.length; i++) {
4885 trEl = trEls.eq(i).removeClass('fc-limited'); // get and reveal
4886 if (trEl.position().top + trEl.outerHeight() > rowHeight) {
4887 return i;
4888 }
4889 }
4890
4891 return false; // should not limit at all
4892 },
4893
4894
4895 // Limits the given grid row to the maximum number of levels and injects "more" links if necessary.
4896 // `row` is the row number.
4897 // `levelLimit` is a number for the maximum (inclusive) number of levels allowed.
4898 limitRow: function(row, levelLimit) {
4899 var _this = this;
4900 var rowStruct = this.rowStructs[row];
4901 var moreNodes = []; // array of "more" <a> links and <td> DOM nodes
4902 var col = 0; // col #, left-to-right (not chronologically)
4903 var cell;
4904 var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right
4905 var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row
4906 var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes
4907 var i, seg;
4908 var segsBelow; // array of segment objects below `seg` in the current `col`
4909 var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies
4910 var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column)
4911 var td, rowspan;
4912 var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell
4913 var j;
4914 var moreTd, moreWrap, moreLink;
4915
4916 // Iterates through empty level cells and places "more" links inside if need be
4917 function emptyCellsUntil(endCol) { // goes from current `col` to `endCol`
4918 while (col < endCol) {
4919 cell = _this.getCell(row, col);
4920 segsBelow = _this.getCellSegs(cell, levelLimit);
4921 if (segsBelow.length) {
4922 td = cellMatrix[levelLimit - 1][col];
4923 moreLink = _this.renderMoreLink(cell, segsBelow);
4924 moreWrap = $('<div/>').append(moreLink);
4925 td.append(moreWrap);
4926 moreNodes.push(moreWrap[0]);
4927 }
4928 col++;
4929 }
4930 }
4931
4932 if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit?
4933 levelSegs = rowStruct.segLevels[levelLimit - 1];
4934 cellMatrix = rowStruct.cellMatrix;
4935
4936 limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit
4937 .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array
4938
4939 // iterate though segments in the last allowable level
4940 for (i = 0; i < levelSegs.length; i++) {
4941 seg = levelSegs[i];
4942 emptyCellsUntil(seg.leftCol); // process empty cells before the segment
4943
4944 // determine *all* segments below `seg` that occupy the same columns
4945 colSegsBelow = [];
4946 totalSegsBelow = 0;
4947 while (col <= seg.rightCol) {
4948 cell = this.getCell(row, col);
4949 segsBelow = this.getCellSegs(cell, levelLimit);
4950 colSegsBelow.push(segsBelow);
4951 totalSegsBelow += segsBelow.length;
4952 col++;
4953 }
4954
4955 if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links?
4956 td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell
4957 rowspan = td.attr('rowspan') || 1;
4958 segMoreNodes = [];
4959
4960 // make a replacement <td> for each column the segment occupies. will be one for each colspan
4961 for (j = 0; j < colSegsBelow.length; j++) {
4962 moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan);
4963 segsBelow = colSegsBelow[j];
4964 cell = this.getCell(row, seg.leftCol + j);
4965 moreLink = this.renderMoreLink(cell, [ seg ].concat(segsBelow)); // count seg as hidden too
4966 moreWrap = $('<div/>').append(moreLink);
4967 moreTd.append(moreWrap);
4968 segMoreNodes.push(moreTd[0]);
4969 moreNodes.push(moreTd[0]);
4970 }
4971
4972 td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements
4973 limitedNodes.push(td[0]);
4974 }
4975 }
4976
4977 emptyCellsUntil(this.colCnt); // finish off the level
4978 rowStruct.moreEls = $(moreNodes); // for easy undoing later
4979 rowStruct.limitedEls = $(limitedNodes); // for easy undoing later
4980 }
4981 },
4982
4983
4984 // Reveals all levels and removes all "more"-related elements for a grid's row.
4985 // `row` is a row number.
4986 unlimitRow: function(row) {
4987 var rowStruct = this.rowStructs[row];
4988
4989 if (rowStruct.moreEls) {
4990 rowStruct.moreEls.remove();
4991 rowStruct.moreEls = null;
4992 }
4993
4994 if (rowStruct.limitedEls) {
4995 rowStruct.limitedEls.removeClass('fc-limited');
4996 rowStruct.limitedEls = null;
4997 }
4998 },
4999
5000
5001 // Renders an <a> element that represents hidden event element for a cell.
5002 // Responsible for attaching click handler as well.
5003 renderMoreLink: function(cell, hiddenSegs) {
5004 var _this = this;
5005 var view = this.view;
5006
5007 return $('<a class="fc-more"/>')
5008 .text(
5009 this.getMoreLinkText(hiddenSegs.length)
5010 )
5011 .on('click', function(ev) {
5012 var clickOption = view.opt('eventLimitClick');
5013 var date = cell.start;
5014 var moreEl = $(this);
5015 var dayEl = _this.getCellDayEl(cell);
5016 var allSegs = _this.getCellSegs(cell);
5017
5018 // rescope the segments to be within the cell's date
5019 var reslicedAllSegs = _this.resliceDaySegs(allSegs, date);
5020 var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date);
5021
5022 if (typeof clickOption === 'function') {
5023 // the returned value can be an atomic option
5024 clickOption = view.trigger('eventLimitClick', null, {
5025 date: date,
5026 dayEl: dayEl,
5027 moreEl: moreEl,
5028 segs: reslicedAllSegs,
5029 hiddenSegs: reslicedHiddenSegs
5030 }, ev);
5031 }
5032
5033 if (clickOption === 'popover') {
5034 _this.showSegPopover(cell, moreEl, reslicedAllSegs);
5035 }
5036 else if (typeof clickOption === 'string') { // a view name
5037 view.calendar.zoomTo(date, clickOption);
5038 }
5039 });
5040 },
5041
5042
5043 // Reveals the popover that displays all events within a cell
5044 showSegPopover: function(cell, moreLink, segs) {
5045 var _this = this;
5046 var view = this.view;
5047 var moreWrap = moreLink.parent(); // the <div> wrapper around the <a>
5048 var topEl; // the element we want to match the top coordinate of
5049 var options;
5050
5051 if (this.rowCnt == 1) {
5052 topEl = view.el; // will cause the popover to cover any sort of header
5053 }
5054 else {
5055 topEl = this.rowEls.eq(cell.row); // will align with top of row
5056 }
5057
5058 options = {
5059 className: 'fc-more-popover',
5060 content: this.renderSegPopoverContent(cell, segs),
5061 parentEl: this.el,
5062 top: topEl.offset().top,
5063 autoHide: true, // when the user clicks elsewhere, hide the popover
5064 viewportConstrain: view.opt('popoverViewportConstrain'),
5065 hide: function() {
5066 // destroy everything when the popover is hidden
5067 _this.segPopover.destroy();
5068 _this.segPopover = null;
5069 _this.popoverSegs = null;
5070 }
5071 };
5072
5073 // Determine horizontal coordinate.
5074 // We use the moreWrap instead of the <td> to avoid border confusion.
5075 if (this.isRTL) {
5076 options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border
5077 }
5078 else {
5079 options.left = moreWrap.offset().left - 1; // -1 to be over cell border
5080 }
5081
5082 this.segPopover = new Popover(options);
5083 this.segPopover.show();
5084 },
5085
5086
5087 // Builds the inner DOM contents of the segment popover
5088 renderSegPopoverContent: function(cell, segs) {
5089 var view = this.view;
5090 var isTheme = view.opt('theme');
5091 var title = cell.start.format(view.opt('dayPopoverFormat'));
5092 var content = $(
5093 '<div class="fc-header ' + view.widgetHeaderClass + '">' +
5094 '<span class="fc-close ' +
5095 (isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') +
5096 '"></span>' +
5097 '<span class="fc-title">' +
5098 htmlEscape(title) +
5099 '</span>' +
5100 '<div class="fc-clear"/>' +
5101 '</div>' +
5102 '<div class="fc-body ' + view.widgetContentClass + '">' +
5103 '<div class="fc-event-container"></div>' +
5104 '</div>'
5105 );
5106 var segContainer = content.find('.fc-event-container');
5107 var i;
5108
5109 // render each seg's `el` and only return the visible segs
5110 segs = this.renderFgSegEls(segs, true); // disableResizing=true
5111 this.popoverSegs = segs;
5112
5113 for (i = 0; i < segs.length; i++) {
5114
5115 // because segments in the popover are not part of a grid coordinate system, provide a hint to any
5116 // grids that want to do drag-n-drop about which cell it came from
5117 segs[i].cell = cell;
5118
5119 segContainer.append(segs[i].el);
5120 }
5121
5122 return content;
5123 },
5124
5125
5126 // Given the events within an array of segment objects, reslice them to be in a single day
5127 resliceDaySegs: function(segs, dayDate) {
5128
5129 // build an array of the original events
5130 var events = $.map(segs, function(seg) {
5131 return seg.event;
5132 });
5133
5134 var dayStart = dayDate.clone().stripTime();
5135 var dayEnd = dayStart.clone().add(1, 'days');
5136 var dayRange = { start: dayStart, end: dayEnd };
5137
5138 // slice the events with a custom slicing function
5139 return this.eventsToSegs(
5140 events,
5141 function(range) {
5142 var seg = intersectionToSeg(range, dayRange); // undefind if no intersection
5143 return seg ? [ seg ] : []; // must return an array of segments
5144 }
5145 );
5146 },
5147
5148
5149 // Generates the text that should be inside a "more" link, given the number of events it represents
5150 getMoreLinkText: function(num) {
5151 var opt = this.view.opt('eventLimitText');
5152
5153 if (typeof opt === 'function') {
5154 return opt(num);
5155 }
5156 else {
5157 return '+' + num + ' ' + opt;
5158 }
5159 },
5160
5161
5162 // Returns segments within a given cell.
5163 // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs.
5164 getCellSegs: function(cell, startLevel) {
5165 var segMatrix = this.rowStructs[cell.row].segMatrix;
5166 var level = startLevel || 0;
5167 var segs = [];
5168 var seg;
5169
5170 while (level < segMatrix.length) {
5171 seg = segMatrix[level][cell.col];
5172 if (seg) {
5173 segs.push(seg);
5174 }
5175 level++;
5176 }
5177
5178 return segs;
5179 }
5180
5181});
5182
5183 /* A component that renders one or more columns of vertical time slots
5184----------------------------------------------------------------------------------------------------------------------*/
5185
5186var TimeGrid = Grid.extend({
5187
5188 slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines
5189 snapDuration: null, // granularity of time for dragging and selecting
5190
5191 minTime: null, // Duration object that denotes the first visible time of any given day
5192 maxTime: null, // Duration object that denotes the exclusive visible end time of any given day
5193
5194 axisFormat: null, // formatting string for times running along vertical axis
5195
5196 dayEls: null, // cells elements in the day-row background
5197 slatEls: null, // elements running horizontally across all columns
5198
5199 slatTops: null, // an array of top positions, relative to the container. last item holds bottom of last slot
5200
5201 helperEl: null, // cell skeleton element for rendering the mock event "helper"
5202
5203 businessHourSegs: null,
5204
5205
5206 constructor: function() {
5207 Grid.apply(this, arguments); // call the super-constructor
5208 this.processOptions();
5209 },
5210
5211
5212 // Renders the time grid into `this.el`, which should already be assigned.
5213 // Relies on the view's colCnt. In the future, this component should probably be self-sufficient.
5214 render: function() {
5215 this.el.html(this.renderHtml());
5216 this.dayEls = this.el.find('.fc-day');
5217 this.slatEls = this.el.find('.fc-slats tr');
5218
5219 this.computeSlatTops();
5220 this.renderBusinessHours();
5221 Grid.prototype.render.call(this); // call the super-method
5222 },
5223
5224
5225 renderBusinessHours: function() {
5226 var events = this.view.calendar.getBusinessHoursEvents();
5227 this.businessHourSegs = this.renderFill('businessHours', this.eventsToSegs(events), 'bgevent');
5228 },
5229
5230
5231 // Renders the basic HTML skeleton for the grid
5232 renderHtml: function() {
5233 return '' +
5234 '<div class="fc-bg">' +
5235 '<table>' +
5236 this.rowHtml('slotBg') + // leverages RowRenderer, which will call slotBgCellHtml
5237 '</table>' +
5238 '</div>' +
5239 '<div class="fc-slats">' +
5240 '<table>' +
5241 this.slatRowHtml() +
5242 '</table>' +
5243 '</div>';
5244 },
5245
5246
5247 // Renders the HTML for a vertical background cell behind the slots.
5248 // This method is distinct from 'bg' because we wanted a new `rowType` so the View could customize the rendering.
5249 slotBgCellHtml: function(cell) {
5250 return this.bgCellHtml(cell);
5251 },
5252
5253
5254 // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL.
5255 slatRowHtml: function() {
5256 var view = this.view;
5257 var isRTL = this.isRTL;
5258 var html = '';
5259 var slotNormal = this.slotDuration.asMinutes() % 15 === 0;
5260 var slotTime = moment.duration(+this.minTime); // wish there was .clone() for durations
5261 var slotDate; // will be on the view's first day, but we only care about its time
5262 var minutes;
5263 var axisHtml;
5264
5265 // Calculate the time for each slot
5266 while (slotTime < this.maxTime) {
5267 slotDate = this.start.clone().time(slotTime); // will be in UTC but that's good. to avoid DST issues
5268 minutes = slotDate.minutes();
5269
5270 axisHtml =
5271 '<td class="fc-axis fc-time ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' +
5272 ((!slotNormal || !minutes) ? // if irregular slot duration, or on the hour, then display the time
5273 '<span>' + // for matchCellWidths
5274 htmlEscape(slotDate.format(this.axisFormat)) +
5275 '</span>' :
5276 ''
5277 ) +
5278 '</td>';
5279
5280 html +=
5281 '<tr ' + (!minutes ? '' : 'class="fc-minor"') + '>' +
5282 (!isRTL ? axisHtml : '') +
5283 '<td class="' + view.widgetContentClass + '"/>' +
5284 (isRTL ? axisHtml : '') +
5285 "</tr>";
5286
5287 slotTime.add(this.slotDuration);
5288 }
5289
5290 return html;
5291 },
5292
5293
5294 /* Options
5295 ------------------------------------------------------------------------------------------------------------------*/
5296
5297
5298 // Parses various options into properties of this object
5299 processOptions: function() {
5300 var view = this.view;
5301 var slotDuration = view.opt('slotDuration');
5302 var snapDuration = view.opt('snapDuration');
5303
5304 slotDuration = moment.duration(slotDuration);
5305 snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration;
5306
5307 this.slotDuration = slotDuration;
5308 this.snapDuration = snapDuration;
5309
5310 this.minTime = moment.duration(view.opt('minTime'));
5311 this.maxTime = moment.duration(view.opt('maxTime'));
5312
5313 this.axisFormat = view.opt('axisFormat') || view.opt('smallTimeFormat');
5314 },
5315
5316
5317 // Computes a default column header formatting string if `colFormat` is not explicitly defined
5318 computeColHeadFormat: function() {
5319 if (this.colCnt > 1) { // multiple days, so full single date string WON'T be in title text
5320 return this.view.opt('dayOfMonthFormat'); // "Sat 12/10"
5321 }
5322 else { // single day, so full single date string will probably be in title text
5323 return 'dddd'; // "Saturday"
5324 }
5325 },
5326
5327
5328 // Computes a default event time formatting string if `timeFormat` is not explicitly defined
5329 computeEventTimeFormat: function() {
5330 return this.view.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM)
5331 },
5332
5333
5334 // Computes a default `displayEventEnd` value if one is not expliclty defined
5335 computeDisplayEventEnd: function() {
5336 return true;
5337 },
5338
5339
5340 /* Cell System
5341 ------------------------------------------------------------------------------------------------------------------*/
5342
5343
5344 // Initializes row/col information
5345 updateCells: function() {
5346 var view = this.view;
5347 var colData = [];
5348 var date;
5349
5350 date = this.start.clone();
5351 while (date.isBefore(this.end)) {
5352 colData.push({
5353 day: date.clone()
5354 });
5355 date.add(1, 'day');
5356 date = view.skipHiddenDays(date);
5357 }
5358
5359 if (this.isRTL) {
5360 colData.reverse();
5361 }
5362
5363 this.colData = colData;
5364 this.colCnt = colData.length;
5365 this.rowCnt = Math.ceil((this.maxTime - this.minTime) / this.snapDuration); // # of vertical snaps
5366 },
5367
5368
5369 // Given a cell object, generates a range object
5370 computeCellRange: function(cell) {
5371 var time = this.computeSnapTime(cell.row);
5372 var start = this.view.calendar.rezoneDate(cell.day).time(time);
5373 var end = start.clone().add(this.snapDuration);
5374
5375 return { start: start, end: end };
5376 },
5377
5378
5379 // Retrieves the element representing the given column
5380 getColEl: function(col) {
5381 return this.dayEls.eq(col);
5382 },
5383
5384
5385 /* Dates
5386 ------------------------------------------------------------------------------------------------------------------*/
5387
5388
5389 // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day
5390 computeSnapTime: function(row) {
5391 return moment.duration(this.minTime + this.snapDuration * row);
5392 },
5393
5394
5395 // Slices up a date range by column into an array of segments
5396 rangeToSegs: function(range) {
5397 var colCnt = this.colCnt;
5398 var segs = [];
5399 var seg;
5400 var col;
5401 var colDate;
5402 var colRange;
5403
5404 // normalize :(
5405 range = {
5406 start: range.start.clone().stripZone(),
5407 end: range.end.clone().stripZone()
5408 };
5409
5410 for (col = 0; col < colCnt; col++) {
5411 colDate = this.colData[col].day; // will be ambig time/timezone
5412 colRange = {
5413 start: colDate.clone().time(this.minTime),
5414 end: colDate.clone().time(this.maxTime)
5415 };
5416 seg = intersectionToSeg(range, colRange); // both will be ambig timezone
5417 if (seg) {
5418 seg.col = col;
5419 segs.push(seg);
5420 }
5421 }
5422
5423 return segs;
5424 },
5425
5426
5427 /* Coordinates
5428 ------------------------------------------------------------------------------------------------------------------*/
5429
5430
5431 // Called when there is a window resize/zoom and we need to recalculate coordinates for the grid
5432 resize: function() {
5433 this.computeSlatTops();
5434 this.updateSegVerticals();
5435 },
5436
5437
5438 // Computes the top/bottom coordinates of each "snap" rows
5439 computeRowCoords: function() {
5440 var originTop = this.el.offset().top;
5441 var items = [];
5442 var i;
5443 var item;
5444
5445 for (i = 0; i < this.rowCnt; i++) {
5446 item = {
5447 top: originTop + this.computeTimeTop(this.computeSnapTime(i))
5448 };
5449 if (i > 0) {
5450 items[i - 1].bottom = item.top;
5451 }
5452 items.push(item);
5453 }
5454 item.bottom = item.top + this.computeTimeTop(this.computeSnapTime(i));
5455
5456 return items;
5457 },
5458
5459
5460 // Computes the top coordinate, relative to the bounds of the grid, of the given date.
5461 // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight.
5462 computeDateTop: function(date, startOfDayDate) {
5463 return this.computeTimeTop(
5464 moment.duration(
5465 date.clone().stripZone() - startOfDayDate.clone().stripTime()
5466 )
5467 );
5468 },
5469
5470
5471 // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration).
5472 computeTimeTop: function(time) {
5473 var slatCoverage = (time - this.minTime) / this.slotDuration; // floating-point value of # of slots covered
5474 var slatIndex;
5475 var slatRemainder;
5476 var slatTop;
5477 var slatBottom;
5478
5479 // constrain. because minTime/maxTime might be customized
5480 slatCoverage = Math.max(0, slatCoverage);
5481 slatCoverage = Math.min(this.slatEls.length, slatCoverage);
5482
5483 slatIndex = Math.floor(slatCoverage); // an integer index of the furthest whole slot
5484 slatRemainder = slatCoverage - slatIndex;
5485 slatTop = this.slatTops[slatIndex]; // the top position of the furthest whole slot
5486
5487 if (slatRemainder) { // time spans part-way into the slot
5488 slatBottom = this.slatTops[slatIndex + 1];
5489 return slatTop + (slatBottom - slatTop) * slatRemainder; // part-way between slots
5490 }
5491 else {
5492 return slatTop;
5493 }
5494 },
5495
5496
5497 // Queries each `slatEl` for its position relative to the grid's container and stores it in `slatTops`.
5498 // Includes the the bottom of the last slat as the last item in the array.
5499 computeSlatTops: function() {
5500 var tops = [];
5501 var top;
5502
5503 this.slatEls.each(function(i, node) {
5504 top = $(node).position().top;
5505 tops.push(top);
5506 });
5507
5508 tops.push(top + this.slatEls.last().outerHeight()); // bottom of the last slat
5509
5510 this.slatTops = tops;
5511 },
5512
5513
5514 /* Event Drag Visualization
5515 ------------------------------------------------------------------------------------------------------------------*/
5516
5517
5518 // Renders a visual indication of an event being dragged over the specified date(s).
5519 // dropLocation's end might be null, as well as `seg`. See Grid::renderDrag for more info.
5520 // A returned value of `true` signals that a mock "helper" event has been rendered.
5521 renderDrag: function(dropLocation, seg) {
5522 var opacity;
5523
5524 if (seg) { // if there is event information for this drag, render a helper event
5525 this.renderRangeHelper(dropLocation, seg);
5526
5527 opacity = this.view.opt('dragOpacity');
5528 if (opacity !== undefined) {
5529 this.helperEl.css('opacity', opacity);
5530 }
5531
5532 return true; // signal that a helper has been rendered
5533 }
5534 else {
5535 // otherwise, just render a highlight
5536 this.renderHighlight(
5537 this.view.calendar.ensureVisibleEventRange(dropLocation) // needs to be a proper range
5538 );
5539 }
5540 },
5541
5542
5543 // Unrenders any visual indication of an event being dragged
5544 destroyDrag: function() {
5545 this.destroyHelper();
5546 this.destroyHighlight();
5547 },
5548
5549
5550 /* Event Resize Visualization
5551 ------------------------------------------------------------------------------------------------------------------*/
5552
5553
5554 // Renders a visual indication of an event being resized
5555 renderEventResize: function(range, seg) {
5556 this.renderRangeHelper(range, seg);
5557 },
5558
5559
5560 // Unrenders any visual indication of an event being resized
5561 destroyEventResize: function() {
5562 this.destroyHelper();
5563 },
5564
5565
5566 /* Event Helper
5567 ------------------------------------------------------------------------------------------------------------------*/
5568
5569
5570 // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag)
5571 renderHelper: function(event, sourceSeg) {
5572 var segs = this.eventsToSegs([ event ]);
5573 var tableEl;
5574 var i, seg;
5575 var sourceEl;
5576
5577 segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered
5578 tableEl = this.renderSegTable(segs);
5579
5580 // Try to make the segment that is in the same row as sourceSeg look the same
5581 for (i = 0; i < segs.length; i++) {
5582 seg = segs[i];
5583 if (sourceSeg && sourceSeg.col === seg.col) {
5584 sourceEl = sourceSeg.el;
5585 seg.el.css({
5586 left: sourceEl.css('left'),
5587 right: sourceEl.css('right'),
5588 'margin-left': sourceEl.css('margin-left'),
5589 'margin-right': sourceEl.css('margin-right')
5590 });
5591 }
5592 }
5593
5594 this.helperEl = $('<div class="fc-helper-skeleton"/>')
5595 .append(tableEl)
5596 .appendTo(this.el);
5597 },
5598
5599
5600 // Unrenders any mock helper event
5601 destroyHelper: function() {
5602 if (this.helperEl) {
5603 this.helperEl.remove();
5604 this.helperEl = null;
5605 }
5606 },
5607
5608
5609 /* Selection
5610 ------------------------------------------------------------------------------------------------------------------*/
5611
5612
5613 // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight.
5614 renderSelection: function(range) {
5615 if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered
5616 this.renderRangeHelper(range);
5617 }
5618 else {
5619 this.renderHighlight(range);
5620 }
5621 },
5622
5623
5624 // Unrenders any visual indication of a selection
5625 destroySelection: function() {
5626 this.destroyHelper();
5627 this.destroyHighlight();
5628 },
5629
5630
5631 /* Fill System (highlight, background events, business hours)
5632 ------------------------------------------------------------------------------------------------------------------*/
5633
5634
5635 // Renders a set of rectangles over the given time segments.
5636 // Only returns segments that successfully rendered.
5637 renderFill: function(type, segs, className) {
5638 var segCols;
5639 var skeletonEl;
5640 var trEl;
5641 var col, colSegs;
5642 var tdEl;
5643 var containerEl;
5644 var dayDate;
5645 var i, seg;
5646
5647 if (segs.length) {
5648
5649 segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs
5650 segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg
5651
5652 className = className || type.toLowerCase();
5653 skeletonEl = $(
5654 '<div class="fc-' + className + '-skeleton">' +
5655 '<table><tr/></table>' +
5656 '</div>'
5657 );
5658 trEl = skeletonEl.find('tr');
5659
5660 for (col = 0; col < segCols.length; col++) {
5661 colSegs = segCols[col];
5662 tdEl = $('<td/>').appendTo(trEl);
5663
5664 if (colSegs.length) {
5665 containerEl = $('<div class="fc-' + className + '-container"/>').appendTo(tdEl);
5666 dayDate = this.colData[col].day;
5667
5668 for (i = 0; i < colSegs.length; i++) {
5669 seg = colSegs[i];
5670 containerEl.append(
5671 seg.el.css({
5672 top: this.computeDateTop(seg.start, dayDate),
5673 bottom: -this.computeDateTop(seg.end, dayDate) // the y position of the bottom edge
5674 })
5675 );
5676 }
5677 }
5678 }
5679
5680 this.bookendCells(trEl, type);
5681
5682 this.el.append(skeletonEl);
5683 this.elsByFill[type] = skeletonEl;
5684 }
5685
5686 return segs;
5687 }
5688
5689});
5690
5691 /* Event-rendering methods for the TimeGrid class
5692----------------------------------------------------------------------------------------------------------------------*/
5693
5694TimeGrid.mixin({
5695
5696 eventSkeletonEl: null, // has cells with event-containers, which contain absolutely positioned event elements
5697
5698
5699 // Renders the given foreground event segments onto the grid
5700 renderFgSegs: function(segs) {
5701 segs = this.renderFgSegEls(segs); // returns a subset of the segs. segs that were actually rendered
5702
5703 this.el.append(
5704 this.eventSkeletonEl = $('<div class="fc-content-skeleton"/>')
5705 .append(this.renderSegTable(segs))
5706 );
5707
5708 return segs; // return only the segs that were actually rendered
5709 },
5710
5711
5712 // Unrenders all currently rendered foreground event segments
5713 destroyFgSegs: function(segs) {
5714 if (this.eventSkeletonEl) {
5715 this.eventSkeletonEl.remove();
5716 this.eventSkeletonEl = null;
5717 }
5718 },
5719
5720
5721 // Renders and returns the <table> portion of the event-skeleton.
5722 // Returns an object with properties 'tbodyEl' and 'segs'.
5723 renderSegTable: function(segs) {
5724 var tableEl = $('<table><tr/></table>');
5725 var trEl = tableEl.find('tr');
5726 var segCols;
5727 var i, seg;
5728 var col, colSegs;
5729 var containerEl;
5730
5731 segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg
5732
5733 this.computeSegVerticals(segs); // compute and assign top/bottom
5734
5735 for (col = 0; col < segCols.length; col++) { // iterate each column grouping
5736 colSegs = segCols[col];
5737 placeSlotSegs(colSegs); // compute horizontal coordinates, z-index's, and reorder the array
5738
5739 containerEl = $('<div class="fc-event-container"/>');
5740
5741 // assign positioning CSS and insert into container
5742 for (i = 0; i < colSegs.length; i++) {
5743 seg = colSegs[i];
5744 seg.el.css(this.generateSegPositionCss(seg));
5745
5746 // if the height is short, add a className for alternate styling
5747 if (seg.bottom - seg.top < 30) {
5748 seg.el.addClass('fc-short');
5749 }
5750
5751 containerEl.append(seg.el);
5752 }
5753
5754 trEl.append($('<td/>').append(containerEl));
5755 }
5756
5757 this.bookendCells(trEl, 'eventSkeleton');
5758
5759 return tableEl;
5760 },
5761
5762
5763 // Refreshes the CSS top/bottom coordinates for each segment element. Probably after a window resize/zoom.
5764 // Repositions business hours segs too, so not just for events. Maybe shouldn't be here.
5765 updateSegVerticals: function() {
5766 var allSegs = (this.segs || []).concat(this.businessHourSegs || []);
5767 var i;
5768
5769 this.computeSegVerticals(allSegs);
5770
5771 for (i = 0; i < allSegs.length; i++) {
5772 allSegs[i].el.css(
5773 this.generateSegVerticalCss(allSegs[i])
5774 );
5775 }
5776 },
5777
5778
5779 // For each segment in an array, computes and assigns its top and bottom properties
5780 computeSegVerticals: function(segs) {
5781 var i, seg;
5782
5783 for (i = 0; i < segs.length; i++) {
5784 seg = segs[i];
5785 seg.top = this.computeDateTop(seg.start, seg.start);
5786 seg.bottom = this.computeDateTop(seg.end, seg.start);
5787 }
5788 },
5789
5790
5791 // Renders the HTML for a single event segment's default rendering
5792 fgSegHtml: function(seg, disableResizing) {
5793 var view = this.view;
5794 var event = seg.event;
5795 var isDraggable = view.isEventDraggable(event);
5796 var isResizable = !disableResizing && seg.isEnd && view.isEventResizable(event);
5797 var classes = this.getSegClasses(seg, isDraggable, isResizable);
5798 var skinCss = this.getEventSkinCss(event);
5799 var timeText;
5800 var fullTimeText; // more verbose time text. for the print stylesheet
5801 var startTimeText; // just the start time text
5802
5803 classes.unshift('fc-time-grid-event');
5804
5805 if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day...
5806 // Don't display time text on segments that run entirely through a day.
5807 // That would appear as midnight-midnight and would look dumb.
5808 // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am)
5809 if (seg.isStart || seg.isEnd) {
5810 timeText = this.getEventTimeText(seg);
5811 fullTimeText = this.getEventTimeText(seg, 'LT');
5812 startTimeText = this.getEventTimeText({ start: seg.start });
5813 }
5814 } else {
5815 // Display the normal time text for the *event's* times
5816 timeText = this.getEventTimeText(event);
5817 fullTimeText = this.getEventTimeText(event, 'LT');
5818 startTimeText = this.getEventTimeText({ start: event.start });
5819 }
5820
5821 return '<a class="' + classes.join(' ') + '"' +
5822 (event.url ?
5823 ' href="' + htmlEscape(event.url) + '"' :
5824 ''
5825 ) +
5826 (skinCss ?
5827 ' style="' + skinCss + '"' :
5828 ''
5829 ) +
5830 '>' +
5831 '<div class="fc-content">' +
5832 (timeText ?
5833 '<div class="fc-time"' +
5834 ' data-start="' + htmlEscape(startTimeText) + '"' +
5835 ' data-full="' + htmlEscape(fullTimeText) + '"' +
5836 '>' +
5837 '<span>' + htmlEscape(timeText) + '</span>' +
5838 '</div>' :
5839 ''
5840 ) +
5841 (event.title ?
5842 '<div class="fc-title">' +
5843 htmlEscape(event.title) +
5844 '</div>' :
5845 ''
5846 ) +
5847 '</div>' +
5848 '<div class="fc-bg"/>' +
5849 (isResizable ?
5850 '<div class="fc-resizer"/>' :
5851 ''
5852 ) +
5853 '</a>';
5854 },
5855
5856
5857 // Generates an object with CSS properties/values that should be applied to an event segment element.
5858 // Contains important positioning-related properties that should be applied to any event element, customized or not.
5859 generateSegPositionCss: function(seg) {
5860 var shouldOverlap = this.view.opt('slotEventOverlap');
5861 var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point
5862 var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point
5863 var props = this.generateSegVerticalCss(seg); // get top/bottom first
5864 var left; // amount of space from left edge, a fraction of the total width
5865 var right; // amount of space from right edge, a fraction of the total width
5866
5867 if (shouldOverlap) {
5868 // double the width, but don't go beyond the maximum forward coordinate (1.0)
5869 forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2);
5870 }
5871
5872 if (this.isRTL) {
5873 left = 1 - forwardCoord;
5874 right = backwardCoord;
5875 }
5876 else {
5877 left = backwardCoord;
5878 right = 1 - forwardCoord;
5879 }
5880
5881 props.zIndex = seg.level + 1; // convert from 0-base to 1-based
5882 props.left = left * 100 + '%';
5883 props.right = right * 100 + '%';
5884
5885 if (shouldOverlap && seg.forwardPressure) {
5886 // add padding to the edge so that forward stacked events don't cover the resizer's icon
5887 props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width
5888 }
5889
5890 return props;
5891 },
5892
5893
5894 // Generates an object with CSS properties for the top/bottom coordinates of a segment element
5895 generateSegVerticalCss: function(seg) {
5896 return {
5897 top: seg.top,
5898 bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container
5899 };
5900 },
5901
5902
5903 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col
5904 groupSegCols: function(segs) {
5905 var segCols = [];
5906 var i;
5907
5908 for (i = 0; i < this.colCnt; i++) {
5909 segCols.push([]);
5910 }
5911
5912 for (i = 0; i < segs.length; i++) {
5913 segCols[segs[i].col].push(segs[i]);
5914 }
5915
5916 return segCols;
5917 }
5918
5919});
5920
5921
5922// Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each.
5923// Also reorders the given array by date!
5924function placeSlotSegs(segs) {
5925 var levels;
5926 var level0;
5927 var i;
5928
5929 segs.sort(compareSegs); // order by date
5930 levels = buildSlotSegLevels(segs);
5931 computeForwardSlotSegs(levels);
5932
5933 if ((level0 = levels[0])) {
5934
5935 for (i = 0; i < level0.length; i++) {
5936 computeSlotSegPressures(level0[i]);
5937 }
5938
5939 for (i = 0; i < level0.length; i++) {
5940 computeSlotSegCoords(level0[i], 0, 0);
5941 }
5942 }
5943}
5944
5945
5946// Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is
5947// left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date.
5948function buildSlotSegLevels(segs) {
5949 var levels = [];
5950 var i, seg;
5951 var j;
5952
5953 for (i=0; i<segs.length; i++) {
5954 seg = segs[i];
5955
5956 // go through all the levels and stop on the first level where there are no collisions
5957 for (j=0; j<levels.length; j++) {
5958 if (!computeSlotSegCollisions(seg, levels[j]).length) {
5959 break;
5960 }
5961 }
5962
5963 seg.level = j;
5964
5965 (levels[j] || (levels[j] = [])).push(seg);
5966 }
5967
5968 return levels;
5969}
5970
5971
5972// For every segment, figure out the other segments that are in subsequent
5973// levels that also occupy the same vertical space. Accumulate in seg.forwardSegs
5974function computeForwardSlotSegs(levels) {
5975 var i, level;
5976 var j, seg;
5977 var k;
5978
5979 for (i=0; i<levels.length; i++) {
5980 level = levels[i];
5981
5982 for (j=0; j<level.length; j++) {
5983 seg = level[j];
5984
5985 seg.forwardSegs = [];
5986 for (k=i+1; k<levels.length; k++) {
5987 computeSlotSegCollisions(seg, levels[k], seg.forwardSegs);
5988 }
5989 }
5990 }
5991}
5992
5993
5994// Figure out which path forward (via seg.forwardSegs) results in the longest path until
5995// the furthest edge is reached. The number of segments in this path will be seg.forwardPressure
5996function computeSlotSegPressures(seg) {
5997 var forwardSegs = seg.forwardSegs;
5998 var forwardPressure = 0;
5999 var i, forwardSeg;
6000
6001 if (seg.forwardPressure === undefined) { // not already computed
6002
6003 for (i=0; i<forwardSegs.length; i++) {
6004 forwardSeg = forwardSegs[i];
6005
6006 // figure out the child's maximum forward path
6007 computeSlotSegPressures(forwardSeg);
6008
6009 // either use the existing maximum, or use the child's forward pressure
6010 // plus one (for the forwardSeg itself)
6011 forwardPressure = Math.max(
6012 forwardPressure,
6013 1 + forwardSeg.forwardPressure
6014 );
6015 }
6016
6017 seg.forwardPressure = forwardPressure;
6018 }
6019}
6020
6021
6022// Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range
6023// from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and
6024// seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left.
6025//
6026// The segment might be part of a "series", which means consecutive segments with the same pressure
6027// who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of
6028// segments behind this one in the current series, and `seriesBackwardCoord` is the starting
6029// coordinate of the first segment in the series.
6030function computeSlotSegCoords(seg, seriesBackwardPressure, seriesBackwardCoord) {
6031 var forwardSegs = seg.forwardSegs;
6032 var i;
6033
6034 if (seg.forwardCoord === undefined) { // not already computed
6035
6036 if (!forwardSegs.length) {
6037
6038 // if there are no forward segments, this segment should butt up against the edge
6039 seg.forwardCoord = 1;
6040 }
6041 else {
6042
6043 // sort highest pressure first
6044 forwardSegs.sort(compareForwardSlotSegs);
6045
6046 // this segment's forwardCoord will be calculated from the backwardCoord of the
6047 // highest-pressure forward segment.
6048 computeSlotSegCoords(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord);
6049 seg.forwardCoord = forwardSegs[0].backwardCoord;
6050 }
6051
6052 // calculate the backwardCoord from the forwardCoord. consider the series
6053 seg.backwardCoord = seg.forwardCoord -
6054 (seg.forwardCoord - seriesBackwardCoord) / // available width for series
6055 (seriesBackwardPressure + 1); // # of segments in the series
6056
6057 // use this segment's coordinates to computed the coordinates of the less-pressurized
6058 // forward segments
6059 for (i=0; i<forwardSegs.length; i++) {
6060 computeSlotSegCoords(forwardSegs[i], 0, seg.forwardCoord);
6061 }
6062 }
6063}
6064
6065
6066// Find all the segments in `otherSegs` that vertically collide with `seg`.
6067// Append into an optionally-supplied `results` array and return.
6068function computeSlotSegCollisions(seg, otherSegs, results) {
6069 results = results || [];
6070
6071 for (var i=0; i<otherSegs.length; i++) {
6072 if (isSlotSegCollision(seg, otherSegs[i])) {
6073 results.push(otherSegs[i]);
6074 }
6075 }
6076
6077 return results;
6078}
6079
6080
6081// Do these segments occupy the same vertical space?
6082function isSlotSegCollision(seg1, seg2) {
6083 return seg1.bottom > seg2.top && seg1.top < seg2.bottom;
6084}
6085
6086
6087// A cmp function for determining which forward segment to rely on more when computing coordinates.
6088function compareForwardSlotSegs(seg1, seg2) {
6089 // put higher-pressure first
6090 return seg2.forwardPressure - seg1.forwardPressure ||
6091 // put segments that are closer to initial edge first (and favor ones with no coords yet)
6092 (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) ||
6093 // do normal sorting...
6094 compareSegs(seg1, seg2);
6095}
6096
6097 /* An abstract class from which other views inherit from
6098----------------------------------------------------------------------------------------------------------------------*/
6099
6100var View = fc.View = Class.extend({
6101
6102 type: null, // subclass' view name (string)
6103 name: null, // deprecated. use `type` instead
6104
6105 calendar: null, // owner Calendar object
6106 options: null, // view-specific options
6107 coordMap: null, // a CoordMap object for converting pixel regions to dates
6108 el: null, // the view's containing element. set by Calendar
6109
6110 // range the view is actually displaying (moments)
6111 start: null,
6112 end: null, // exclusive
6113
6114 // range the view is formally responsible for (moments)
6115 // may be different from start/end. for example, a month view might have 1st-31st, excluding padded dates
6116 intervalStart: null,
6117 intervalEnd: null, // exclusive
6118
6119 intervalDuration: null, // the whole-unit duration that is being displayed
6120 intervalUnit: null, // name of largest unit being displayed, like "month" or "week"
6121
6122 isSelected: false, // boolean whether a range of time is user-selected or not
6123
6124 // subclasses can optionally use a scroll container
6125 scrollerEl: null, // the element that will most likely scroll when content is too tall
6126 scrollTop: null, // cached vertical scroll value
6127
6128 // classNames styled by jqui themes
6129 widgetHeaderClass: null,
6130 widgetContentClass: null,
6131 highlightStateClass: null,
6132
6133 // for date utils, computed from options
6134 nextDayThreshold: null,
6135 isHiddenDayHash: null,
6136
6137 // document handlers, bound to `this` object
6138 documentMousedownProxy: null, // TODO: doesn't work with touch
6139
6140
6141 constructor: function(calendar, viewOptions, viewType) {
6142 this.calendar = calendar;
6143 this.options = viewOptions;
6144 this.type = this.name = viewType; // .name is deprecated
6145
6146 this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold'));
6147 this.initTheming();
6148 this.initHiddenDays();
6149
6150 this.documentMousedownProxy = $.proxy(this, 'documentMousedown');
6151
6152 this.initialize();
6153 },
6154
6155
6156 // A good place for subclasses to initialize member variables
6157 initialize: function() {
6158 // subclasses can implement
6159 },
6160
6161
6162 // Retrieves an option with the given name
6163 opt: function(name) {
6164 var val;
6165
6166 val = this.options[name]; // look at view-specific options first
6167 if (val !== undefined) {
6168 return val;
6169 }
6170
6171 val = this.calendar.options[name];
6172 if ($.isPlainObject(val) && !isForcedAtomicOption(name)) { // view-option-hashes are deprecated
6173 return smartProperty(val, this.type);
6174 }
6175
6176 return val;
6177 },
6178
6179
6180 // Triggers handlers that are view-related. Modifies args before passing to calendar.
6181 trigger: function(name, thisObj) { // arguments beyond thisObj are passed along
6182 var calendar = this.calendar;
6183
6184 return calendar.trigger.apply(
6185 calendar,
6186 [name, thisObj || this].concat(
6187 Array.prototype.slice.call(arguments, 2), // arguments beyond thisObj
6188 [ this ] // always make the last argument a reference to the view. TODO: deprecate
6189 )
6190 );
6191 },
6192
6193
6194 /* Dates
6195 ------------------------------------------------------------------------------------------------------------------*/
6196
6197
6198 // Updates all internal dates to center around the given current date
6199 setDate: function(date) {
6200 this.setRange(this.computeRange(date));
6201 },
6202
6203
6204 // Updates all internal dates for displaying the given range.
6205 // Expects all values to be normalized (like what computeRange does).
6206 setRange: function(range) {
6207 $.extend(this, range);
6208 },
6209
6210
6211 // Given a single current date, produce information about what range to display.
6212 // Subclasses can override. Must return all properties.
6213 computeRange: function(date) {
6214 var intervalDuration = moment.duration(this.opt('duration') || this.constructor.duration || { days: 1 });
6215 var intervalUnit = computeIntervalUnit(intervalDuration);
6216 var intervalStart = date.clone().startOf(intervalUnit);
6217 var intervalEnd = intervalStart.clone().add(intervalDuration);
6218 var start, end;
6219
6220 // normalize the range's time-ambiguity
6221 if (computeIntervalAs('days', intervalDuration)) { // whole-days?
6222 intervalStart.stripTime();
6223 intervalEnd.stripTime();
6224 }
6225 else { // needs to have a time?
6226 if (!intervalStart.hasTime()) {
6227 intervalStart = this.calendar.rezoneDate(intervalStart); // convert to current timezone, with 00:00
6228 }
6229 if (!intervalEnd.hasTime()) {
6230 intervalEnd = this.calendar.rezoneDate(intervalEnd); // convert to current timezone, with 00:00
6231 }
6232 }
6233
6234 start = intervalStart.clone();
6235 start = this.skipHiddenDays(start);
6236 end = intervalEnd.clone();
6237 end = this.skipHiddenDays(end, -1, true); // exclusively move backwards
6238
6239 return {
6240 intervalDuration: intervalDuration,
6241 intervalUnit: intervalUnit,
6242 intervalStart: intervalStart,
6243 intervalEnd: intervalEnd,
6244 start: start,
6245 end: end
6246 };
6247 },
6248
6249
6250 // Computes the new date when the user hits the prev button, given the current date
6251 computePrevDate: function(date) {
6252 return this.skipHiddenDays(
6253 date.clone().startOf(this.intervalUnit).subtract(this.intervalDuration), -1
6254 );
6255 },
6256
6257
6258 // Computes the new date when the user hits the next button, given the current date
6259 computeNextDate: function(date) {
6260 return this.skipHiddenDays(
6261 date.clone().startOf(this.intervalUnit).add(this.intervalDuration)
6262 );
6263 },
6264
6265
6266 /* Title and Date Formatting
6267 ------------------------------------------------------------------------------------------------------------------*/
6268
6269
6270 // Computes what the title at the top of the calendar should be for this view
6271 computeTitle: function() {
6272 return this.formatRange(
6273 { start: this.intervalStart, end: this.intervalEnd },
6274 this.opt('titleFormat') || this.computeTitleFormat(),
6275 this.opt('titleRangeSeparator')
6276 );
6277 },
6278
6279
6280 // Generates the format string that should be used to generate the title for the current date range.
6281 // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`.
6282 computeTitleFormat: function() {
6283 if (this.intervalUnit == 'year') {
6284 return 'YYYY';
6285 }
6286 else if (this.intervalUnit == 'month') {
6287 return this.opt('monthYearFormat'); // like "September 2014"
6288 }
6289 else if (this.intervalDuration.as('days') > 1) {
6290 return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014"
6291 }
6292 else {
6293 return 'LL'; // one day. longer, like "September 9 2014"
6294 }
6295 },
6296
6297
6298 // Utility for formatting a range. Accepts a range object, formatting string, and optional separator.
6299 // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account.
6300 formatRange: function(range, formatStr, separator) {
6301 var end = range.end;
6302
6303 if (!end.hasTime()) { // all-day?
6304 end = end.clone().subtract(1); // convert to inclusive. last ms of previous day
6305 }
6306
6307 return formatRange(range.start, end, formatStr, separator, this.opt('isRTL'));
6308 },
6309
6310
6311 /* Rendering
6312 ------------------------------------------------------------------------------------------------------------------*/
6313
6314
6315 // Wraps the basic render() method with more View-specific logic. Called by the owner Calendar.
6316 renderView: function() {
6317 this.render();
6318 this.updateSize();
6319 this.initializeScroll();
6320 this.trigger('viewRender', this, this, this.el);
6321
6322 // attach handlers to document. do it here to allow for destroy/rerender
6323 $(document).on('mousedown', this.documentMousedownProxy);
6324 },
6325
6326
6327 // Renders the view inside an already-defined `this.el`
6328 render: function() {
6329 // subclasses should implement
6330 },
6331
6332
6333 // Wraps the basic destroy() method with more View-specific logic. Called by the owner Calendar.
6334 destroyView: function() {
6335 this.unselect();
6336 this.destroyViewEvents();
6337 this.destroy();
6338 this.trigger('viewDestroy', this, this, this.el);
6339
6340 $(document).off('mousedown', this.documentMousedownProxy);
6341 },
6342
6343
6344 // Clears the view's rendering
6345 destroy: function() {
6346 this.el.empty(); // removes inner contents but leaves the element intact
6347 },
6348
6349
6350 // Initializes internal variables related to theming
6351 initTheming: function() {
6352 var tm = this.opt('theme') ? 'ui' : 'fc';
6353
6354 this.widgetHeaderClass = tm + '-widget-header';
6355 this.widgetContentClass = tm + '-widget-content';
6356 this.highlightStateClass = tm + '-state-highlight';
6357 },
6358
6359
6360 /* Dimensions
6361 ------------------------------------------------------------------------------------------------------------------*/
6362
6363
6364 // Refreshes anything dependant upon sizing of the container element of the grid
6365 updateSize: function(isResize) {
6366 if (isResize) {
6367 this.recordScroll();
6368 }
6369 this.updateHeight();
6370 this.updateWidth();
6371 },
6372
6373
6374 // Refreshes the horizontal dimensions of the calendar
6375 updateWidth: function() {
6376 // subclasses should implement
6377 },
6378
6379
6380 // Refreshes the vertical dimensions of the calendar
6381 updateHeight: function() {
6382 var calendar = this.calendar; // we poll the calendar for height information
6383
6384 this.setHeight(
6385 calendar.getSuggestedViewHeight(),
6386 calendar.isHeightAuto()
6387 );
6388 },
6389
6390
6391 // Updates the vertical dimensions of the calendar to the specified height.
6392 // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height.
6393 setHeight: function(height, isAuto) {
6394 // subclasses should implement
6395 },
6396
6397
6398 /* Scroller
6399 ------------------------------------------------------------------------------------------------------------------*/
6400
6401
6402 // Given the total height of the view, return the number of pixels that should be used for the scroller.
6403 // By default, uses this.scrollerEl, but can pass this in as well.
6404 // Utility for subclasses.
6405 computeScrollerHeight: function(totalHeight, scrollerEl) {
6406 var both;
6407 var otherHeight; // cumulative height of everything that is not the scrollerEl in the view (header+borders)
6408
6409 scrollerEl = scrollerEl || this.scrollerEl;
6410 both = this.el.add(scrollerEl);
6411
6412 // fuckin IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked
6413 both.css({
6414 position: 'relative', // cause a reflow, which will force fresh dimension recalculation
6415 left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll
6416 });
6417 otherHeight = this.el.outerHeight() - scrollerEl.height(); // grab the dimensions
6418 both.css({ position: '', left: '' }); // undo hack
6419
6420 return totalHeight - otherHeight;
6421 },
6422
6423
6424 // Sets the scroll value of the scroller to the initial pre-configured state prior to allowing the user to change it
6425 initializeScroll: function() {
6426 },
6427
6428
6429 // Called for remembering the current scroll value of the scroller.
6430 // Should be called before there is a destructive operation (like removing DOM elements) that might inadvertently
6431 // change the scroll of the container.
6432 recordScroll: function() {
6433 if (this.scrollerEl) {
6434 this.scrollTop = this.scrollerEl.scrollTop();
6435 }
6436 },
6437
6438
6439 // Set the scroll value of the scroller to the previously recorded value.
6440 // Should be called after we know the view's dimensions have been restored following some type of destructive
6441 // operation (like temporarily removing DOM elements).
6442 restoreScroll: function() {
6443 if (this.scrollTop !== null) {
6444 this.scrollerEl.scrollTop(this.scrollTop);
6445 }
6446 },
6447
6448
6449 /* Event Elements / Segments
6450 ------------------------------------------------------------------------------------------------------------------*/
6451
6452
6453 // Wraps the basic renderEvents() method with more View-specific logic
6454 renderViewEvents: function(events) {
6455 this.renderEvents(events);
6456
6457 this.eventSegEach(function(seg) {
6458 this.trigger('eventAfterRender', seg.event, seg.event, seg.el);
6459 });
6460 this.trigger('eventAfterAllRender');
6461 },
6462
6463
6464 // Renders the events onto the view.
6465 renderEvents: function() {
6466 // subclasses should implement
6467 },
6468
6469
6470 // Wraps the basic destroyEvents() method with more View-specific logic
6471 destroyViewEvents: function() {
6472 this.eventSegEach(function(seg) {
6473 this.trigger('eventDestroy', seg.event, seg.event, seg.el);
6474 });
6475
6476 this.destroyEvents();
6477 },
6478
6479
6480 // Removes event elements from the view.
6481 destroyEvents: function() {
6482 // subclasses should implement
6483 },
6484
6485
6486 // Given an event and the default element used for rendering, returns the element that should actually be used.
6487 // Basically runs events and elements through the eventRender hook.
6488 resolveEventEl: function(event, el) {
6489 var custom = this.trigger('eventRender', event, event, el);
6490
6491 if (custom === false) { // means don't render at all
6492 el = null;
6493 }
6494 else if (custom && custom !== true) {
6495 el = $(custom);
6496 }
6497
6498 return el;
6499 },
6500
6501
6502 // Hides all rendered event segments linked to the given event
6503 showEvent: function(event) {
6504 this.eventSegEach(function(seg) {
6505 seg.el.css('visibility', '');
6506 }, event);
6507 },
6508
6509
6510 // Shows all rendered event segments linked to the given event
6511 hideEvent: function(event) {
6512 this.eventSegEach(function(seg) {
6513 seg.el.css('visibility', 'hidden');
6514 }, event);
6515 },
6516
6517
6518 // Iterates through event segments. Goes through all by default.
6519 // If the optional `event` argument is specified, only iterates through segments linked to that event.
6520 // The `this` value of the callback function will be the view.
6521 eventSegEach: function(func, event) {
6522 var segs = this.getEventSegs();
6523 var i;
6524
6525 for (i = 0; i < segs.length; i++) {
6526 if (!event || segs[i].event._id === event._id) {
6527 func.call(this, segs[i]);
6528 }
6529 }
6530 },
6531
6532
6533 // Retrieves all the rendered segment objects for the view
6534 getEventSegs: function() {
6535 // subclasses must implement
6536 return [];
6537 },
6538
6539
6540 /* Event Drag-n-Drop
6541 ------------------------------------------------------------------------------------------------------------------*/
6542
6543
6544 // Computes if the given event is allowed to be dragged by the user
6545 isEventDraggable: function(event) {
6546 var source = event.source || {};
6547
6548 return firstDefined(
6549 event.startEditable,
6550 source.startEditable,
6551 this.opt('eventStartEditable'),
6552 event.editable,
6553 source.editable,
6554 this.opt('editable')
6555 );
6556 },
6557
6558
6559 // Must be called when an event in the view is dropped onto new location.
6560 // `dropLocation` is an object that contains the new start/end/allDay values for the event.
6561 reportEventDrop: function(event, dropLocation, el, ev) {
6562 var calendar = this.calendar;
6563 var mutateResult = calendar.mutateEvent(event, dropLocation);
6564 var undoFunc = function() {
6565 mutateResult.undo();
6566 calendar.reportEventChange();
6567 };
6568
6569 this.triggerEventDrop(event, mutateResult.dateDelta, undoFunc, el, ev);
6570 calendar.reportEventChange(); // will rerender events
6571 },
6572
6573
6574 // Triggers event-drop handlers that have subscribed via the API
6575 triggerEventDrop: function(event, dateDelta, undoFunc, el, ev) {
6576 this.trigger('eventDrop', el[0], event, dateDelta, undoFunc, ev, {}); // {} = jqui dummy
6577 },
6578
6579
6580 /* External Element Drag-n-Drop
6581 ------------------------------------------------------------------------------------------------------------------*/
6582
6583
6584 // Must be called when an external element, via jQuery UI, has been dropped onto the calendar.
6585 // `meta` is the parsed data that has been embedded into the dragging event.
6586 // `dropLocation` is an object that contains the new start/end/allDay values for the event.
6587 reportExternalDrop: function(meta, dropLocation, el, ev, ui) {
6588 var eventProps = meta.eventProps;
6589 var eventInput;
6590 var event;
6591
6592 // Try to build an event object and render it. TODO: decouple the two
6593 if (eventProps) {
6594 eventInput = $.extend({}, eventProps, dropLocation);
6595 event = this.calendar.renderEvent(eventInput, meta.stick)[0]; // renderEvent returns an array
6596 }
6597
6598 this.triggerExternalDrop(event, dropLocation, el, ev, ui);
6599 },
6600
6601
6602 // Triggers external-drop handlers that have subscribed via the API
6603 triggerExternalDrop: function(event, dropLocation, el, ev, ui) {
6604
6605 // trigger 'drop' regardless of whether element represents an event
6606 this.trigger('drop', el[0], dropLocation.start, ev, ui);
6607
6608 if (event) {
6609 this.trigger('eventReceive', null, event); // signal an external event landed
6610 }
6611 },
6612
6613
6614 /* Drag-n-Drop Rendering (for both events and external elements)
6615 ------------------------------------------------------------------------------------------------------------------*/
6616
6617
6618 // Renders a visual indication of a event or external-element drag over the given drop zone.
6619 // If an external-element, seg will be `null`
6620 renderDrag: function(dropLocation, seg) {
6621 // subclasses must implement
6622 },
6623
6624
6625 // Unrenders a visual indication of an event or external-element being dragged.
6626 destroyDrag: function() {
6627 // subclasses must implement
6628 },
6629
6630
6631 /* Event Resizing
6632 ------------------------------------------------------------------------------------------------------------------*/
6633
6634
6635 // Computes if the given event is allowed to be resize by the user
6636 isEventResizable: function(event) {
6637 var source = event.source || {};
6638
6639 return firstDefined(
6640 event.durationEditable,
6641 source.durationEditable,
6642 this.opt('eventDurationEditable'),
6643 event.editable,
6644 source.editable,
6645 this.opt('editable')
6646 );
6647 },
6648
6649
6650 // Must be called when an event in the view has been resized to a new length
6651 reportEventResize: function(event, newEnd, el, ev) {
6652 var calendar = this.calendar;
6653 var mutateResult = calendar.mutateEvent(event, { end: newEnd });
6654 var undoFunc = function() {
6655 mutateResult.undo();
6656 calendar.reportEventChange();
6657 };
6658
6659 this.triggerEventResize(event, mutateResult.durationDelta, undoFunc, el, ev);
6660 calendar.reportEventChange(); // will rerender events
6661 },
6662
6663
6664 // Triggers event-resize handlers that have subscribed via the API
6665 triggerEventResize: function(event, durationDelta, undoFunc, el, ev) {
6666 this.trigger('eventResize', el[0], event, durationDelta, undoFunc, ev, {}); // {} = jqui dummy
6667 },
6668
6669
6670 /* Selection
6671 ------------------------------------------------------------------------------------------------------------------*/
6672
6673
6674 // Selects a date range on the view. `start` and `end` are both Moments.
6675 // `ev` is the native mouse event that begin the interaction.
6676 select: function(range, ev) {
6677 this.unselect(ev);
6678 this.renderSelection(range);
6679 this.reportSelection(range, ev);
6680 },
6681
6682
6683 // Renders a visual indication of the selection
6684 renderSelection: function(range) {
6685 // subclasses should implement
6686 },
6687
6688
6689 // Called when a new selection is made. Updates internal state and triggers handlers.
6690 reportSelection: function(range, ev) {
6691 this.isSelected = true;
6692 this.trigger('select', null, range.start, range.end, ev);
6693 },
6694
6695
6696 // Undoes a selection. updates in the internal state and triggers handlers.
6697 // `ev` is the native mouse event that began the interaction.
6698 unselect: function(ev) {
6699 if (this.isSelected) {
6700 this.isSelected = false;
6701 this.destroySelection();
6702 this.trigger('unselect', null, ev);
6703 }
6704 },
6705
6706
6707 // Unrenders a visual indication of selection
6708 destroySelection: function() {
6709 // subclasses should implement
6710 },
6711
6712
6713 // Handler for unselecting when the user clicks something and the 'unselectAuto' setting is on
6714 documentMousedown: function(ev) {
6715 var ignore;
6716
6717 // is there a selection, and has the user made a proper left click?
6718 if (this.isSelected && this.opt('unselectAuto') && isPrimaryMouseButton(ev)) {
6719
6720 // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element
6721 ignore = this.opt('unselectCancel');
6722 if (!ignore || !$(ev.target).closest(ignore).length) {
6723 this.unselect(ev);
6724 }
6725 }
6726 },
6727
6728
6729 /* Date Utils
6730 ------------------------------------------------------------------------------------------------------------------*/
6731
6732
6733 // Initializes internal variables related to calculating hidden days-of-week
6734 initHiddenDays: function() {
6735 var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden
6736 var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool)
6737 var dayCnt = 0;
6738 var i;
6739
6740 if (this.opt('weekends') === false) {
6741 hiddenDays.push(0, 6); // 0=sunday, 6=saturday
6742 }
6743
6744 for (i = 0; i < 7; i++) {
6745 if (
6746 !(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1)
6747 ) {
6748 dayCnt++;
6749 }
6750 }
6751
6752 if (!dayCnt) {
6753 throw 'invalid hiddenDays'; // all days were hidden? bad.
6754 }
6755
6756 this.isHiddenDayHash = isHiddenDayHash;
6757 },
6758
6759
6760 // Is the current day hidden?
6761 // `day` is a day-of-week index (0-6), or a Moment
6762 isHiddenDay: function(day) {
6763 if (moment.isMoment(day)) {
6764 day = day.day();
6765 }
6766 return this.isHiddenDayHash[day];
6767 },
6768
6769
6770 // Incrementing the current day until it is no longer a hidden day, returning a copy.
6771 // If the initial value of `date` is not a hidden day, don't do anything.
6772 // Pass `isExclusive` as `true` if you are dealing with an end date.
6773 // `inc` defaults to `1` (increment one day forward each time)
6774 skipHiddenDays: function(date, inc, isExclusive) {
6775 var out = date.clone();
6776 inc = inc || 1;
6777 while (
6778 this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7]
6779 ) {
6780 out.add(inc, 'days');
6781 }
6782 return out;
6783 },
6784
6785
6786 // Returns the date range of the full days the given range visually appears to occupy.
6787 // Returns a new range object.
6788 computeDayRange: function(range) {
6789 var startDay = range.start.clone().stripTime(); // the beginning of the day the range starts
6790 var end = range.end;
6791 var endDay = null;
6792 var endTimeMS;
6793
6794 if (end) {
6795 endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends
6796 endTimeMS = +end.time(); // # of milliseconds into `endDay`
6797
6798 // If the end time is actually inclusively part of the next day and is equal to or
6799 // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`.
6800 // Otherwise, leaving it as inclusive will cause it to exclude `endDay`.
6801 if (endTimeMS && endTimeMS >= this.nextDayThreshold) {
6802 endDay.add(1, 'days');
6803 }
6804 }
6805
6806 // If no end was specified, or if it is within `startDay` but not past nextDayThreshold,
6807 // assign the default duration of one day.
6808 if (!end || endDay <= startDay) {
6809 endDay = startDay.clone().add(1, 'days');
6810 }
6811
6812 return { start: startDay, end: endDay };
6813 },
6814
6815
6816 // Does the given event visually appear to occupy more than one day?
6817 isMultiDayEvent: function(event) {
6818 var range = this.computeDayRange(event); // event is range-ish
6819
6820 return range.end.diff(range.start, 'days') > 1;
6821 }
6822
6823});
6824
6825 function Calendar(element, instanceOptions) {
6826 var t = this;
6827
6828
6829
6830 // Build options object
6831 // -----------------------------------------------------------------------------------
6832 // Precedence (lowest to highest): defaults, rtlDefaults, langOptions, instanceOptions
6833
6834 instanceOptions = instanceOptions || {};
6835
6836 var options = mergeOptions({}, defaults, instanceOptions);
6837 var langOptions;
6838
6839 // determine language options
6840 if (options.lang in langOptionHash) {
6841 langOptions = langOptionHash[options.lang];
6842 }
6843 else {
6844 langOptions = langOptionHash[defaults.lang];
6845 }
6846
6847 if (langOptions) { // if language options exist, rebuild...
6848 options = mergeOptions({}, defaults, langOptions, instanceOptions);
6849 }
6850
6851 if (options.isRTL) { // is isRTL, rebuild...
6852 options = mergeOptions({}, defaults, rtlDefaults, langOptions || {}, instanceOptions);
6853 }
6854
6855
6856
6857 // Exports
6858 // -----------------------------------------------------------------------------------
6859
6860 t.options = options;
6861 t.render = render;
6862 t.destroy = destroy;
6863 t.refetchEvents = refetchEvents;
6864 t.reportEvents = reportEvents;
6865 t.reportEventChange = reportEventChange;
6866 t.rerenderEvents = renderEvents; // `renderEvents` serves as a rerender. an API method
6867 t.changeView = changeView;
6868 t.select = select;
6869 t.unselect = unselect;
6870 t.prev = prev;
6871 t.next = next;
6872 t.prevYear = prevYear;
6873 t.nextYear = nextYear;
6874 t.today = today;
6875 t.gotoDate = gotoDate;
6876 t.incrementDate = incrementDate;
6877 t.zoomTo = zoomTo;
6878 t.getDate = getDate;
6879 t.getCalendar = getCalendar;
6880 t.getView = getView;
6881 t.option = option;
6882 t.trigger = trigger;
6883 t.isValidViewType = isValidViewType;
6884 t.getViewButtonText = getViewButtonText;
6885
6886
6887
6888 // Language-data Internals
6889 // -----------------------------------------------------------------------------------
6890 // Apply overrides to the current language's data
6891
6892
6893 var localeData = createObject( // make a cheap copy
6894 getMomentLocaleData(options.lang) // will fall back to en
6895 );
6896
6897 if (options.monthNames) {
6898 localeData._months = options.monthNames;
6899 }
6900 if (options.monthNamesShort) {
6901 localeData._monthsShort = options.monthNamesShort;
6902 }
6903 if (options.dayNames) {
6904 localeData._weekdays = options.dayNames;
6905 }
6906 if (options.dayNamesShort) {
6907 localeData._weekdaysShort = options.dayNamesShort;
6908 }
6909 if (options.firstDay != null) {
6910 var _week = createObject(localeData._week); // _week: { dow: # }
6911 _week.dow = options.firstDay;
6912 localeData._week = _week;
6913 }
6914
6915
6916
6917 // Calendar-specific Date Utilities
6918 // -----------------------------------------------------------------------------------
6919
6920
6921 t.defaultAllDayEventDuration = moment.duration(options.defaultAllDayEventDuration);
6922 t.defaultTimedEventDuration = moment.duration(options.defaultTimedEventDuration);
6923
6924
6925 // Builds a moment using the settings of the current calendar: timezone and language.
6926 // Accepts anything the vanilla moment() constructor accepts.
6927 t.moment = function() {
6928 var mom;
6929
6930 if (options.timezone === 'local') {
6931 mom = fc.moment.apply(null, arguments);
6932
6933 // Force the moment to be local, because fc.moment doesn't guarantee it.
6934 if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone
6935 mom.local();
6936 }
6937 }
6938 else if (options.timezone === 'UTC') {
6939 mom = fc.moment.utc.apply(null, arguments); // process as UTC
6940 }
6941 else {
6942 mom = fc.moment.parseZone.apply(null, arguments); // let the input decide the zone
6943 }
6944
6945 if ('_locale' in mom) { // moment 2.8 and above
6946 mom._locale = localeData;
6947 }
6948 else { // pre-moment-2.8
6949 mom._lang = localeData;
6950 }
6951
6952 return mom;
6953 };
6954
6955
6956 // Returns a boolean about whether or not the calendar knows how to calculate
6957 // the timezone offset of arbitrary dates in the current timezone.
6958 t.getIsAmbigTimezone = function() {
6959 return options.timezone !== 'local' && options.timezone !== 'UTC';
6960 };
6961
6962
6963 // Returns a copy of the given date in the current timezone of it is ambiguously zoned.
6964 // This will also give the date an unambiguous time.
6965 t.rezoneDate = function(date) {
6966 return t.moment(date.toArray());
6967 };
6968
6969
6970 // Returns a moment for the current date, as defined by the client's computer,
6971 // or overridden by the `now` option.
6972 t.getNow = function() {
6973 var now = options.now;
6974 if (typeof now === 'function') {
6975 now = now();
6976 }
6977 return t.moment(now);
6978 };
6979
6980
6981 // Calculates the week number for a moment according to the calendar's
6982 // `weekNumberCalculation` setting.
6983 t.calculateWeekNumber = function(mom) {
6984 var calc = options.weekNumberCalculation;
6985
6986 if (typeof calc === 'function') {
6987 return calc(mom);
6988 }
6989 else if (calc === 'local') {
6990 return mom.week();
6991 }
6992 else if (calc.toUpperCase() === 'ISO') {
6993 return mom.isoWeek();
6994 }
6995 };
6996
6997
6998 // Get an event's normalized end date. If not present, calculate it from the defaults.
6999 t.getEventEnd = function(event) {
7000 if (event.end) {
7001 return event.end.clone();
7002 }
7003 else {
7004 return t.getDefaultEventEnd(event.allDay, event.start);
7005 }
7006 };
7007
7008
7009 // Given an event's allDay status and start date, return swhat its fallback end date should be.
7010 t.getDefaultEventEnd = function(allDay, start) { // TODO: rename to computeDefaultEventEnd
7011 var end = start.clone();
7012
7013 if (allDay) {
7014 end.stripTime().add(t.defaultAllDayEventDuration);
7015 }
7016 else {
7017 end.add(t.defaultTimedEventDuration);
7018 }
7019
7020 if (t.getIsAmbigTimezone()) {
7021 end.stripZone(); // we don't know what the tzo should be
7022 }
7023
7024 return end;
7025 };
7026
7027
7028 // Produces a human-readable string for the given duration.
7029 // Side-effect: changes the locale of the given duration.
7030 function humanizeDuration(duration) {
7031 return (duration.locale || duration.lang).call(duration, options.lang) // works moment-pre-2.8
7032 .humanize();
7033 }
7034
7035
7036
7037 // Imports
7038 // -----------------------------------------------------------------------------------
7039
7040
7041 EventManager.call(t, options);
7042 var isFetchNeeded = t.isFetchNeeded;
7043 var fetchEvents = t.fetchEvents;
7044
7045
7046
7047 // Locals
7048 // -----------------------------------------------------------------------------------
7049
7050
7051 var _element = element[0];
7052 var header;
7053 var headerElement;
7054 var content;
7055 var tm; // for making theme classes
7056 var viewSpecCache = {};
7057 var currentView;
7058 var suggestedViewHeight;
7059 var windowResizeProxy; // wraps the windowResize function
7060 var ignoreWindowResize = 0;
7061 var date;
7062 var events = [];
7063
7064
7065
7066 // Main Rendering
7067 // -----------------------------------------------------------------------------------
7068
7069
7070 if (options.defaultDate != null) {
7071 date = t.moment(options.defaultDate);
7072 }
7073 else {
7074 date = t.getNow();
7075 }
7076
7077
7078 function render(inc) {
7079 if (!content) {
7080 initialRender();
7081 }
7082 else if (elementVisible()) {
7083 // mainly for the public API
7084 calcSize();
7085 renderView(inc);
7086 }
7087 }
7088
7089
7090 function initialRender() {
7091 tm = options.theme ? 'ui' : 'fc';
7092 element.addClass('fc');
7093
7094 if (options.isRTL) {
7095 element.addClass('fc-rtl');
7096 }
7097 else {
7098 element.addClass('fc-ltr');
7099 }
7100
7101 if (options.theme) {
7102 element.addClass('ui-widget');
7103 }
7104 else {
7105 element.addClass('fc-unthemed');
7106 }
7107
7108 content = $("<div class='fc-view-container'/>").prependTo(element);
7109
7110 header = new Header(t, options);
7111 headerElement = header.render();
7112 if (headerElement) {
7113 element.prepend(headerElement);
7114 }
7115
7116 changeView(options.defaultView);
7117
7118 if (options.handleWindowResize) {
7119 windowResizeProxy = debounce(windowResize, options.windowResizeDelay); // prevents rapid calls
7120 $(window).resize(windowResizeProxy);
7121 }
7122 }
7123
7124
7125 function destroy() {
7126
7127 if (currentView) {
7128 currentView.destroyView();
7129 }
7130
7131 header.destroy();
7132 content.remove();
7133 element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget');
7134
7135 $(window).unbind('resize', windowResizeProxy);
7136 }
7137
7138
7139 function elementVisible() {
7140 return element.is(':visible');
7141 }
7142
7143
7144
7145 // View Rendering
7146 // -----------------------------------------------------------------------------------
7147
7148
7149 function changeView(viewType) {
7150 renderView(0, viewType);
7151 }
7152
7153
7154 // Renders a view because of a date change, view-type change, or for the first time
7155 function renderView(delta, viewType) {
7156 ignoreWindowResize++;
7157
7158 // if viewType is changing, destroy the old view
7159 if (currentView && viewType && currentView.type !== viewType) {
7160 header.deactivateButton(currentView.type);
7161 freezeContentHeight(); // prevent a scroll jump when view element is removed
7162 if (currentView.start) { // rendered before?
7163 currentView.destroyView();
7164 }
7165 currentView.el.remove();
7166 currentView = null;
7167 }
7168
7169 // if viewType changed, or the view was never created, create a fresh view
7170 if (!currentView && viewType) {
7171 currentView = instantiateView(viewType);
7172 currentView.el = $("<div class='fc-view fc-" + viewType + "-view' />").appendTo(content);
7173 header.activateButton(viewType);
7174 }
7175
7176 if (currentView) {
7177
7178 // let the view determine what the delta means
7179 if (delta < 0) {
7180 date = currentView.computePrevDate(date);
7181 }
7182 else if (delta > 0) {
7183 date = currentView.computeNextDate(date);
7184 }
7185
7186 // render or rerender the view
7187 if (
7188 !currentView.start || // never rendered before
7189 delta || // explicit date window change
7190 !date.isWithin(currentView.intervalStart, currentView.intervalEnd) // implicit date window change
7191 ) {
7192 if (elementVisible()) {
7193
7194 freezeContentHeight();
7195 if (currentView.start) { // rendered before?
7196 currentView.destroyView();
7197 }
7198 currentView.setDate(date);
7199 currentView.renderView();
7200 unfreezeContentHeight();
7201
7202 // need to do this after View::render, so dates are calculated
7203 updateTitle();
7204 updateTodayButton();
7205
7206 getAndRenderEvents();
7207 }
7208 }
7209 }
7210
7211 unfreezeContentHeight(); // undo any lone freezeContentHeight calls
7212 ignoreWindowResize--;
7213 }
7214
7215
7216
7217 // View Instantiation
7218 // -----------------------------------------------------------------------------------
7219
7220
7221 // Given a view name for a custom view or a standard view, creates a ready-to-go View object
7222 function instantiateView(viewType) {
7223 var spec = getViewSpec(viewType);
7224
7225 return new spec['class'](t, spec.options, viewType);
7226 }
7227
7228
7229 // Gets information about how to create a view
7230 function getViewSpec(requestedViewType) {
7231 var allDefaultButtonText = options.defaultButtonText || {};
7232 var allButtonText = options.buttonText || {};
7233 var hash = options.views || {}; // the `views` option object
7234 var viewType = requestedViewType;
7235 var viewOptionsChain = [];
7236 var viewOptions;
7237 var viewClass;
7238 var duration, unit, unitIsSingle = false;
7239 var buttonText;
7240
7241 if (viewSpecCache[requestedViewType]) {
7242 return viewSpecCache[requestedViewType];
7243 }
7244
7245 function processSpecInput(input) {
7246 if (typeof input === 'function') {
7247 viewClass = input;
7248 }
7249 else if (typeof input === 'object') {
7250 $.extend(viewOptions, input);
7251 }
7252 }
7253
7254 // iterate up a view's spec ancestor chain util we find a class to instantiate
7255 while (viewType && !viewClass) {
7256 viewOptions = {}; // only for this specific view in the ancestry
7257 processSpecInput(fcViews[viewType]); // $.fullCalendar.views, lower precedence
7258 processSpecInput(hash[viewType]); // options at initialization, higher precedence
7259 viewOptionsChain.unshift(viewOptions); // record older ancestors first
7260 viewType = viewOptions.type;
7261 }
7262
7263 viewOptionsChain.unshift({}); // jQuery's extend needs at least one arg
7264 viewOptions = $.extend.apply($, viewOptionsChain); // combine all, newer ancestors overwritting old
7265
7266 if (viewClass) {
7267
7268 duration = viewOptions.duration || viewClass.duration;
7269 if (duration) {
7270 duration = moment.duration(duration);
7271 unit = computeIntervalUnit(duration);
7272 unitIsSingle = computeIntervalAs(unit, duration) === 1;
7273 }
7274
7275 // options that are specified per the view's duration, like "week" or "day"
7276 if (unitIsSingle && hash[unit]) {
7277 viewOptions = $.extend({}, hash[unit], viewOptions); // lowest priority
7278 }
7279
7280 // compute the final text for the button representing this view
7281 buttonText =
7282 allButtonText[requestedViewType] || // init options, like "agendaWeek"
7283 (unitIsSingle ? allButtonText[unit] : null) || // init options, like "week"
7284 allDefaultButtonText[requestedViewType] || // lang data, like "agendaWeek"
7285 (unitIsSingle ? allDefaultButtonText[unit] : null) || // lang data, like "week"
7286 viewOptions.buttonText ||
7287 viewClass.buttonText ||
7288 (duration ? humanizeDuration(duration) : null) ||
7289 requestedViewType;
7290
7291 return (viewSpecCache[requestedViewType] = {
7292 'class': viewClass,
7293 options: viewOptions,
7294 buttonText: buttonText
7295 });
7296 }
7297 }
7298
7299
7300 // Returns a boolean about whether the view is okay to instantiate at some point
7301 function isValidViewType(viewType) {
7302 return Boolean(getViewSpec(viewType));
7303 }
7304
7305
7306 // Gets the text that should be displayed on a view's button in the header
7307 function getViewButtonText(viewType) {
7308 var spec = getViewSpec(viewType);
7309
7310 if (spec) {
7311 return spec.buttonText;
7312 }
7313 }
7314
7315
7316
7317 // Resizing
7318 // -----------------------------------------------------------------------------------
7319
7320
7321 t.getSuggestedViewHeight = function() {
7322 if (suggestedViewHeight === undefined) {
7323 calcSize();
7324 }
7325 return suggestedViewHeight;
7326 };
7327
7328
7329 t.isHeightAuto = function() {
7330 return options.contentHeight === 'auto' || options.height === 'auto';
7331 };
7332
7333
7334 function updateSize(shouldRecalc) {
7335 if (elementVisible()) {
7336
7337 if (shouldRecalc) {
7338 _calcSize();
7339 }
7340
7341 ignoreWindowResize++;
7342 currentView.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto()
7343 ignoreWindowResize--;
7344
7345 return true; // signal success
7346 }
7347 }
7348
7349
7350 function calcSize() {
7351 if (elementVisible()) {
7352 _calcSize();
7353 }
7354 }
7355
7356
7357 function _calcSize() { // assumes elementVisible
7358 if (typeof options.contentHeight === 'number') { // exists and not 'auto'
7359 suggestedViewHeight = options.contentHeight;
7360 }
7361 else if (typeof options.height === 'number') { // exists and not 'auto'
7362 suggestedViewHeight = options.height - (headerElement ? headerElement.outerHeight(true) : 0);
7363 }
7364 else {
7365 suggestedViewHeight = Math.round(content.width() / Math.max(options.aspectRatio, .5));
7366 }
7367 }
7368
7369
7370 function windowResize(ev) {
7371 if (
7372 !ignoreWindowResize &&
7373 ev.target === window && // so we don't process jqui "resize" events that have bubbled up
7374 currentView.start // view has already been rendered
7375 ) {
7376 if (updateSize(true)) {
7377 currentView.trigger('windowResize', _element);
7378 }
7379 }
7380 }
7381
7382
7383
7384 /* Event Fetching/Rendering
7385 -----------------------------------------------------------------------------*/
7386 // TODO: going forward, most of this stuff should be directly handled by the view
7387
7388
7389 function refetchEvents() { // can be called as an API method
7390 destroyEvents(); // so that events are cleared before user starts waiting for AJAX
7391 fetchAndRenderEvents();
7392 }
7393
7394
7395 function renderEvents() { // destroys old events if previously rendered
7396 if (elementVisible()) {
7397 freezeContentHeight();
7398 currentView.destroyViewEvents(); // no performance cost if never rendered
7399 currentView.renderViewEvents(events);
7400 unfreezeContentHeight();
7401 }
7402 }
7403
7404
7405 function destroyEvents() {
7406 freezeContentHeight();
7407 currentView.destroyViewEvents();
7408 unfreezeContentHeight();
7409 }
7410
7411
7412 function getAndRenderEvents() {
7413 if (!options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) {
7414 fetchAndRenderEvents();
7415 }
7416 else {
7417 renderEvents();
7418 }
7419 }
7420
7421
7422 function fetchAndRenderEvents() {
7423 fetchEvents(currentView.start, currentView.end);
7424 // ... will call reportEvents
7425 // ... which will call renderEvents
7426 }
7427
7428
7429 // called when event data arrives
7430 function reportEvents(_events) {
7431 events = _events;
7432 renderEvents();
7433 }
7434
7435
7436 // called when a single event's data has been changed
7437 function reportEventChange() {
7438 renderEvents();
7439 }
7440
7441
7442
7443 /* Header Updating
7444 -----------------------------------------------------------------------------*/
7445
7446
7447 function updateTitle() {
7448 header.updateTitle(currentView.computeTitle());
7449 }
7450
7451
7452 function updateTodayButton() {
7453 var now = t.getNow();
7454 if (now.isWithin(currentView.intervalStart, currentView.intervalEnd)) {
7455 header.disableButton('today');
7456 }
7457 else {
7458 header.enableButton('today');
7459 }
7460 }
7461
7462
7463
7464 /* Selection
7465 -----------------------------------------------------------------------------*/
7466
7467
7468 function select(start, end) {
7469
7470 start = t.moment(start);
7471 if (end) {
7472 end = t.moment(end);
7473 }
7474 else if (start.hasTime()) {
7475 end = start.clone().add(t.defaultTimedEventDuration);
7476 }
7477 else {
7478 end = start.clone().add(t.defaultAllDayEventDuration);
7479 }
7480
7481 currentView.select({ start: start, end: end }); // accepts a range
7482 }
7483
7484
7485 function unselect() { // safe to be called before renderView
7486 if (currentView) {
7487 currentView.unselect();
7488 }
7489 }
7490
7491
7492
7493 /* Date
7494 -----------------------------------------------------------------------------*/
7495
7496
7497 function prev() {
7498 renderView(-1);
7499 }
7500
7501
7502 function next() {
7503 renderView(1);
7504 }
7505
7506
7507 function prevYear() {
7508 date.add(-1, 'years');
7509 renderView();
7510 }
7511
7512
7513 function nextYear() {
7514 date.add(1, 'years');
7515 renderView();
7516 }
7517
7518
7519 function today() {
7520 date = t.getNow();
7521 renderView();
7522 }
7523
7524
7525 function gotoDate(dateInput) {
7526 date = t.moment(dateInput);
7527 renderView();
7528 }
7529
7530
7531 function incrementDate(delta) {
7532 date.add(moment.duration(delta));
7533 renderView();
7534 }
7535
7536
7537 // Forces navigation to a view for the given date.
7538 // `viewType` can be a specific view name or a generic one like "week" or "day".
7539 function zoomTo(newDate, viewType) {
7540 var viewStr;
7541 var match;
7542
7543 if (!viewType || !isValidViewType(viewType)) { // a general view name, or "auto"
7544 viewType = viewType || 'day';
7545 viewStr = header.getViewsWithButtons().join(' '); // space-separated string of all the views in the header
7546
7547 // try to match a general view name, like "week", against a specific one, like "agendaWeek"
7548 match = viewStr.match(new RegExp('\\w+' + capitaliseFirstLetter(viewType)));
7549
7550 // fall back to the day view being used in the header
7551 if (!match) {
7552 match = viewStr.match(/\w+Day/);
7553 }
7554
7555 viewType = match ? match[0] : 'agendaDay'; // fall back to agendaDay
7556 }
7557
7558 date = newDate;
7559 changeView(viewType);
7560 }
7561
7562
7563 function getDate() {
7564 return date.clone();
7565 }
7566
7567
7568
7569 /* Height "Freezing"
7570 -----------------------------------------------------------------------------*/
7571
7572
7573 function freezeContentHeight() {
7574 content.css({
7575 width: '100%',
7576 height: content.height(),
7577 overflow: 'hidden'
7578 });
7579 }
7580
7581
7582 function unfreezeContentHeight() {
7583 content.css({
7584 width: '',
7585 height: '',
7586 overflow: ''
7587 });
7588 }
7589
7590
7591
7592 /* Misc
7593 -----------------------------------------------------------------------------*/
7594
7595
7596 function getCalendar() {
7597 return t;
7598 }
7599
7600
7601 function getView() {
7602 return currentView;
7603 }
7604
7605
7606 function option(name, value) {
7607 if (value === undefined) {
7608 return options[name];
7609 }
7610 if (name == 'height' || name == 'contentHeight' || name == 'aspectRatio') {
7611 options[name] = value;
7612 updateSize(true); // true = allow recalculation of height
7613 }
7614 }
7615
7616
7617 function trigger(name, thisObj) {
7618 if (options[name]) {
7619 return options[name].apply(
7620 thisObj || _element,
7621 Array.prototype.slice.call(arguments, 2)
7622 );
7623 }
7624 }
7625
7626}
7627
7628 /* Top toolbar area with buttons and title
7629----------------------------------------------------------------------------------------------------------------------*/
7630// TODO: rename all header-related things to "toolbar"
7631
7632function Header(calendar, options) {
7633 var t = this;
7634
7635 // exports
7636 t.render = render;
7637 t.destroy = destroy;
7638 t.updateTitle = updateTitle;
7639 t.activateButton = activateButton;
7640 t.deactivateButton = deactivateButton;
7641 t.disableButton = disableButton;
7642 t.enableButton = enableButton;
7643 t.getViewsWithButtons = getViewsWithButtons;
7644
7645 // locals
7646 var el = $();
7647 var viewsWithButtons = [];
7648 var tm;
7649
7650
7651 function render() {
7652 var sections = options.header;
7653
7654 tm = options.theme ? 'ui' : 'fc';
7655
7656 if (sections) {
7657 el = $("<div class='fc-toolbar'/>")
7658 .append(renderSection('left'))
7659 .append(renderSection('right'))
7660 .append(renderSection('center'))
7661 .append('<div class="fc-clear"/>');
7662
7663 return el;
7664 }
7665 }
7666
7667
7668 function destroy() {
7669 el.remove();
7670 }
7671
7672
7673 function renderSection(position) {
7674 var sectionEl = $('<div class="fc-' + position + '"/>');
7675 var buttonStr = options.header[position];
7676
7677 if (buttonStr) {
7678 $.each(buttonStr.split(' '), function(i) {
7679 var groupChildren = $();
7680 var isOnlyButtons = true;
7681 var groupEl;
7682
7683 $.each(this.split(','), function(j, buttonName) {
7684 var buttonClick;
7685 var themeIcon;
7686 var normalIcon;
7687 var defaultText;
7688 var viewText; // highest priority
7689 var customText;
7690 var innerHtml;
7691 var classes;
7692 var button;
7693
7694 if (buttonName == 'title') {
7695 groupChildren = groupChildren.add($('<h2>&nbsp;</h2>')); // we always want it to take up height
7696 isOnlyButtons = false;
7697 }
7698 else {
7699 if (calendar[buttonName]) { // a calendar method
7700 buttonClick = function() {
7701 calendar[buttonName]();
7702 };
7703 }
7704 else if (calendar.isValidViewType(buttonName)) { // a view type
7705 buttonClick = function() {
7706 calendar.changeView(buttonName);
7707 };
7708 viewsWithButtons.push(buttonName);
7709 viewText = calendar.getViewButtonText(buttonName);
7710 }
7711 if (buttonClick) {
7712
7713 // smartProperty allows different text per view button (ex: "Agenda Week" vs "Basic Week")
7714 themeIcon = smartProperty(options.themeButtonIcons, buttonName);
7715 normalIcon = smartProperty(options.buttonIcons, buttonName);
7716 defaultText = smartProperty(options.defaultButtonText, buttonName); // from languages
7717 customText = smartProperty(options.buttonText, buttonName);
7718
7719 if (viewText || customText) {
7720 innerHtml = htmlEscape(viewText || customText);
7721 }
7722 else if (themeIcon && options.theme) {
7723 innerHtml = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>";
7724 }
7725 else if (normalIcon && !options.theme) {
7726 innerHtml = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>";
7727 }
7728 else {
7729 innerHtml = htmlEscape(defaultText || buttonName);
7730 }
7731
7732 classes = [
7733 'fc-' + buttonName + '-button',
7734 tm + '-button',
7735 tm + '-state-default'
7736 ];
7737
7738 button = $( // type="button" so that it doesn't submit a form
7739 '<button type="button" class="' + classes.join(' ') + '">' +
7740 innerHtml +
7741 '</button>'
7742 )
7743 .click(function() {
7744 // don't process clicks for disabled buttons
7745 if (!button.hasClass(tm + '-state-disabled')) {
7746
7747 buttonClick();
7748
7749 // after the click action, if the button becomes the "active" tab, or disabled,
7750 // it should never have a hover class, so remove it now.
7751 if (
7752 button.hasClass(tm + '-state-active') ||
7753 button.hasClass(tm + '-state-disabled')
7754 ) {
7755 button.removeClass(tm + '-state-hover');
7756 }
7757 }
7758 })
7759 .mousedown(function() {
7760 // the *down* effect (mouse pressed in).
7761 // only on buttons that are not the "active" tab, or disabled
7762 button
7763 .not('.' + tm + '-state-active')
7764 .not('.' + tm + '-state-disabled')
7765 .addClass(tm + '-state-down');
7766 })
7767 .mouseup(function() {
7768 // undo the *down* effect
7769 button.removeClass(tm + '-state-down');
7770 })
7771 .hover(
7772 function() {
7773 // the *hover* effect.
7774 // only on buttons that are not the "active" tab, or disabled
7775 button
7776 .not('.' + tm + '-state-active')
7777 .not('.' + tm + '-state-disabled')
7778 .addClass(tm + '-state-hover');
7779 },
7780 function() {
7781 // undo the *hover* effect
7782 button
7783 .removeClass(tm + '-state-hover')
7784 .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup
7785 }
7786 );
7787
7788 groupChildren = groupChildren.add(button);
7789 }
7790 }
7791 });
7792
7793 if (isOnlyButtons) {
7794 groupChildren
7795 .first().addClass(tm + '-corner-left').end()
7796 .last().addClass(tm + '-corner-right').end();
7797 }
7798
7799 if (groupChildren.length > 1) {
7800 groupEl = $('<div/>');
7801 if (isOnlyButtons) {
7802 groupEl.addClass('fc-button-group');
7803 }
7804 groupEl.append(groupChildren);
7805 sectionEl.append(groupEl);
7806 }
7807 else {
7808 sectionEl.append(groupChildren); // 1 or 0 children
7809 }
7810 });
7811 }
7812
7813 return sectionEl;
7814 }
7815
7816
7817 function updateTitle(text) {
7818 el.find('h2').text(text);
7819 }
7820
7821
7822 function activateButton(buttonName) {
7823 el.find('.fc-' + buttonName + '-button')
7824 .addClass(tm + '-state-active');
7825 }
7826
7827
7828 function deactivateButton(buttonName) {
7829 el.find('.fc-' + buttonName + '-button')
7830 .removeClass(tm + '-state-active');
7831 }
7832
7833
7834 function disableButton(buttonName) {
7835 el.find('.fc-' + buttonName + '-button')
7836 .attr('disabled', 'disabled')
7837 .addClass(tm + '-state-disabled');
7838 }
7839
7840
7841 function enableButton(buttonName) {
7842 el.find('.fc-' + buttonName + '-button')
7843 .removeAttr('disabled')
7844 .removeClass(tm + '-state-disabled');
7845 }
7846
7847
7848 function getViewsWithButtons() {
7849 return viewsWithButtons;
7850 }
7851
7852}
7853
7854 fc.sourceNormalizers = [];
7855fc.sourceFetchers = [];
7856
7857var ajaxDefaults = {
7858 dataType: 'json',
7859 cache: false
7860};
7861
7862var eventGUID = 1;
7863
7864
7865function EventManager(options) { // assumed to be a calendar
7866 var t = this;
7867
7868
7869 // exports
7870 t.isFetchNeeded = isFetchNeeded;
7871 t.fetchEvents = fetchEvents;
7872 t.addEventSource = addEventSource;
7873 t.removeEventSource = removeEventSource;
7874 t.updateEvent = updateEvent;
7875 t.renderEvent = renderEvent;
7876 t.removeEvents = removeEvents;
7877 t.clientEvents = clientEvents;
7878 t.mutateEvent = mutateEvent;
7879 t.normalizeEventDateProps = normalizeEventDateProps;
7880 t.ensureVisibleEventRange = ensureVisibleEventRange;
7881
7882
7883 // imports
7884 var trigger = t.trigger;
7885 var getView = t.getView;
7886 var reportEvents = t.reportEvents;
7887
7888
7889 // locals
7890 var stickySource = { events: [] };
7891 var sources = [ stickySource ];
7892 var rangeStart, rangeEnd;
7893 var currentFetchID = 0;
7894 var pendingSourceCnt = 0;
7895 var loadingLevel = 0;
7896 var cache = []; // holds events that have already been expanded
7897
7898
7899 $.each(
7900 (options.events ? [ options.events ] : []).concat(options.eventSources || []),
7901 function(i, sourceInput) {
7902 var source = buildEventSource(sourceInput);
7903 if (source) {
7904 sources.push(source);
7905 }
7906 }
7907 );
7908
7909
7910
7911 /* Fetching
7912 -----------------------------------------------------------------------------*/
7913
7914
7915 function isFetchNeeded(start, end) {
7916 return !rangeStart || // nothing has been fetched yet?
7917 // or, a part of the new range is outside of the old range? (after normalizing)
7918 start.clone().stripZone() < rangeStart.clone().stripZone() ||
7919 end.clone().stripZone() > rangeEnd.clone().stripZone();
7920 }
7921
7922
7923 function fetchEvents(start, end) {
7924 rangeStart = start;
7925 rangeEnd = end;
7926 cache = [];
7927 var fetchID = ++currentFetchID;
7928 var len = sources.length;
7929 pendingSourceCnt = len;
7930 for (var i=0; i<len; i++) {
7931 fetchEventSource(sources[i], fetchID);
7932 }
7933 }
7934
7935
7936 function fetchEventSource(source, fetchID) {
7937 _fetchEventSource(source, function(eventInputs) {
7938 var isArraySource = $.isArray(source.events);
7939 var i, eventInput;
7940 var abstractEvent;
7941
7942 if (fetchID == currentFetchID) {
7943
7944 if (eventInputs) {
7945 for (i = 0; i < eventInputs.length; i++) {
7946 eventInput = eventInputs[i];
7947
7948 if (isArraySource) { // array sources have already been convert to Event Objects
7949 abstractEvent = eventInput;
7950 }
7951 else {
7952 abstractEvent = buildEventFromInput(eventInput, source);
7953 }
7954
7955 if (abstractEvent) { // not false (an invalid event)
7956 cache.push.apply(
7957 cache,
7958 expandEvent(abstractEvent) // add individual expanded events to the cache
7959 );
7960 }
7961 }
7962 }
7963
7964 pendingSourceCnt--;
7965 if (!pendingSourceCnt) {
7966 reportEvents(cache);
7967 }
7968 }
7969 });
7970 }
7971
7972
7973 function _fetchEventSource(source, callback) {
7974 var i;
7975 var fetchers = fc.sourceFetchers;
7976 var res;
7977
7978 for (i=0; i<fetchers.length; i++) {
7979 res = fetchers[i].call(
7980 t, // this, the Calendar object
7981 source,
7982 rangeStart.clone(),
7983 rangeEnd.clone(),
7984 options.timezone,
7985 callback
7986 );
7987
7988 if (res === true) {
7989 // the fetcher is in charge. made its own async request
7990 return;
7991 }
7992 else if (typeof res == 'object') {
7993 // the fetcher returned a new source. process it
7994 _fetchEventSource(res, callback);
7995 return;
7996 }
7997 }
7998
7999 var events = source.events;
8000 if (events) {
8001 if ($.isFunction(events)) {
8002 pushLoading();
8003 events.call(
8004 t, // this, the Calendar object
8005 rangeStart.clone(),
8006 rangeEnd.clone(),
8007 options.timezone,
8008 function(events) {
8009 callback(events);
8010 popLoading();
8011 }
8012 );
8013 }
8014 else if ($.isArray(events)) {
8015 callback(events);
8016 }
8017 else {
8018 callback();
8019 }
8020 }else{
8021 var url = source.url;
8022 if (url) {
8023 var success = source.success;
8024 var error = source.error;
8025 var complete = source.complete;
8026
8027 // retrieve any outbound GET/POST $.ajax data from the options
8028 var customData;
8029 if ($.isFunction(source.data)) {
8030 // supplied as a function that returns a key/value object
8031 customData = source.data();
8032 }
8033 else {
8034 // supplied as a straight key/value object
8035 customData = source.data;
8036 }
8037
8038 // use a copy of the custom data so we can modify the parameters
8039 // and not affect the passed-in object.
8040 var data = $.extend({}, customData || {});
8041
8042 var startParam = firstDefined(source.startParam, options.startParam);
8043 var endParam = firstDefined(source.endParam, options.endParam);
8044 var timezoneParam = firstDefined(source.timezoneParam, options.timezoneParam);
8045
8046 if (startParam) {
8047 data[startParam] = rangeStart.format();
8048 }
8049 if (endParam) {
8050 data[endParam] = rangeEnd.format();
8051 }
8052 if (options.timezone && options.timezone != 'local') {
8053 data[timezoneParam] = options.timezone;
8054 }
8055
8056 pushLoading();
8057 $.ajax($.extend({}, ajaxDefaults, source, {
8058 data: data,
8059 success: function(events) {
8060 events = events || [];
8061 var res = applyAll(success, this, arguments);
8062 if ($.isArray(res)) {
8063 events = res;
8064 }
8065 callback(events);
8066 },
8067 error: function() {
8068 applyAll(error, this, arguments);
8069 callback();
8070 },
8071 complete: function() {
8072 applyAll(complete, this, arguments);
8073 popLoading();
8074 }
8075 }));
8076 }else{
8077 callback();
8078 }
8079 }
8080 }
8081
8082
8083
8084 /* Sources
8085 -----------------------------------------------------------------------------*/
8086
8087
8088 function addEventSource(sourceInput) {
8089 var source = buildEventSource(sourceInput);
8090 if (source) {
8091 sources.push(source);
8092 pendingSourceCnt++;
8093 fetchEventSource(source, currentFetchID); // will eventually call reportEvents
8094 }
8095 }
8096
8097
8098 function buildEventSource(sourceInput) { // will return undefined if invalid source
8099 var normalizers = fc.sourceNormalizers;
8100 var source;
8101 var i;
8102
8103 if ($.isFunction(sourceInput) || $.isArray(sourceInput)) {
8104 source = { events: sourceInput };
8105 }
8106 else if (typeof sourceInput === 'string') {
8107 source = { url: sourceInput };
8108 }
8109 else if (typeof sourceInput === 'object') {
8110 source = $.extend({}, sourceInput); // shallow copy
8111 }
8112
8113 if (source) {
8114
8115 // TODO: repeat code, same code for event classNames
8116 if (source.className) {
8117 if (typeof source.className === 'string') {
8118 source.className = source.className.split(/\s+/);
8119 }
8120 // otherwise, assumed to be an array
8121 }
8122 else {
8123 source.className = [];
8124 }
8125
8126 // for array sources, we convert to standard Event Objects up front
8127 if ($.isArray(source.events)) {
8128 source.origArray = source.events; // for removeEventSource
8129 source.events = $.map(source.events, function(eventInput) {
8130 return buildEventFromInput(eventInput, source);
8131 });
8132 }
8133
8134 for (i=0; i<normalizers.length; i++) {
8135 normalizers[i].call(t, source);
8136 }
8137
8138 return source;
8139 }
8140 }
8141
8142
8143 function removeEventSource(source) {
8144 sources = $.grep(sources, function(src) {
8145 return !isSourcesEqual(src, source);
8146 });
8147 // remove all client events from that source
8148 cache = $.grep(cache, function(e) {
8149 return !isSourcesEqual(e.source, source);
8150 });
8151 reportEvents(cache);
8152 }
8153
8154
8155 function isSourcesEqual(source1, source2) {
8156 return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2);
8157 }
8158
8159
8160 function getSourcePrimitive(source) {
8161 return (
8162 (typeof source === 'object') ? // a normalized event source?
8163 (source.origArray || source.googleCalendarId || source.url || source.events) : // get the primitive
8164 null
8165 ) ||
8166 source; // the given argument *is* the primitive
8167 }
8168
8169
8170
8171 /* Manipulation
8172 -----------------------------------------------------------------------------*/
8173
8174
8175 // Only ever called from the externally-facing API
8176 function updateEvent(event) {
8177
8178 // massage start/end values, even if date string values
8179 event.start = t.moment(event.start);
8180 if (event.end) {
8181 event.end = t.moment(event.end);
8182 }
8183 else {
8184 event.end = null;
8185 }
8186
8187 mutateEvent(event, getMiscEventProps(event)); // will handle start/end/allDay normalization
8188 reportEvents(cache); // reports event modifications (so we can redraw)
8189 }
8190
8191
8192 // Returns a hash of misc event properties that should be copied over to related events.
8193 function getMiscEventProps(event) {
8194 var props = {};
8195
8196 $.each(event, function(name, val) {
8197 if (isMiscEventPropName(name)) {
8198 if (val !== undefined && isAtomic(val)) { // a defined non-object
8199 props[name] = val;
8200 }
8201 }
8202 });
8203
8204 return props;
8205 }
8206
8207 // non-date-related, non-id-related, non-secret
8208 function isMiscEventPropName(name) {
8209 return !/^_|^(id|allDay|start|end)$/.test(name);
8210 }
8211
8212
8213 // returns the expanded events that were created
8214 function renderEvent(eventInput, stick) {
8215 var abstractEvent = buildEventFromInput(eventInput);
8216 var events;
8217 var i, event;
8218
8219 if (abstractEvent) { // not false (a valid input)
8220 events = expandEvent(abstractEvent);
8221
8222 for (i = 0; i < events.length; i++) {
8223 event = events[i];
8224
8225 if (!event.source) {
8226 if (stick) {
8227 stickySource.events.push(event);
8228 event.source = stickySource;
8229 }
8230 cache.push(event);
8231 }
8232 }
8233
8234 reportEvents(cache);
8235
8236 return events;
8237 }
8238
8239 return [];
8240 }
8241
8242
8243 function removeEvents(filter) {
8244 var eventID;
8245 var i;
8246
8247 if (filter == null) { // null or undefined. remove all events
8248 filter = function() { return true; }; // will always match
8249 }
8250 else if (!$.isFunction(filter)) { // an event ID
8251 eventID = filter + '';
8252 filter = function(event) {
8253 return event._id == eventID;
8254 };
8255 }
8256
8257 // Purge event(s) from our local cache
8258 cache = $.grep(cache, filter, true); // inverse=true
8259
8260 // Remove events from array sources.
8261 // This works because they have been converted to official Event Objects up front.
8262 // (and as a result, event._id has been calculated).
8263 for (i=0; i<sources.length; i++) {
8264 if ($.isArray(sources[i].events)) {
8265 sources[i].events = $.grep(sources[i].events, filter, true);
8266 }
8267 }
8268
8269 reportEvents(cache);
8270 }
8271
8272
8273 function clientEvents(filter) {
8274 if ($.isFunction(filter)) {
8275 return $.grep(cache, filter);
8276 }
8277 else if (filter != null) { // not null, not undefined. an event ID
8278 filter += '';
8279 return $.grep(cache, function(e) {
8280 return e._id == filter;
8281 });
8282 }
8283 return cache; // else, return all
8284 }
8285
8286
8287
8288 /* Loading State
8289 -----------------------------------------------------------------------------*/
8290
8291
8292 function pushLoading() {
8293 if (!(loadingLevel++)) {
8294 trigger('loading', null, true, getView());
8295 }
8296 }
8297
8298
8299 function popLoading() {
8300 if (!(--loadingLevel)) {
8301 trigger('loading', null, false, getView());
8302 }
8303 }
8304
8305
8306
8307 /* Event Normalization
8308 -----------------------------------------------------------------------------*/
8309
8310
8311 // Given a raw object with key/value properties, returns an "abstract" Event object.
8312 // An "abstract" event is an event that, if recurring, will not have been expanded yet.
8313 // Will return `false` when input is invalid.
8314 // `source` is optional
8315 function buildEventFromInput(input, source) {
8316 var out = {};
8317 var start, end;
8318 var allDay;
8319
8320 if (options.eventDataTransform) {
8321 input = options.eventDataTransform(input);
8322 }
8323 if (source && source.eventDataTransform) {
8324 input = source.eventDataTransform(input);
8325 }
8326
8327 // Copy all properties over to the resulting object.
8328 // The special-case properties will be copied over afterwards.
8329 $.extend(out, input);
8330
8331 if (source) {
8332 out.source = source;
8333 }
8334
8335 out._id = input._id || (input.id === undefined ? '_fc' + eventGUID++ : input.id + '');
8336
8337 if (input.className) {
8338 if (typeof input.className == 'string') {
8339 out.className = input.className.split(/\s+/);
8340 }
8341 else { // assumed to be an array
8342 out.className = input.className;
8343 }
8344 }
8345 else {
8346 out.className = [];
8347 }
8348
8349 start = input.start || input.date; // "date" is an alias for "start"
8350 end = input.end;
8351
8352 // parse as a time (Duration) if applicable
8353 if (isTimeString(start)) {
8354 start = moment.duration(start);
8355 }
8356 if (isTimeString(end)) {
8357 end = moment.duration(end);
8358 }
8359
8360 if (input.dow || moment.isDuration(start) || moment.isDuration(end)) {
8361
8362 // the event is "abstract" (recurring) so don't calculate exact start/end dates just yet
8363 out.start = start ? moment.duration(start) : null; // will be a Duration or null
8364 out.end = end ? moment.duration(end) : null; // will be a Duration or null
8365 out._recurring = true; // our internal marker
8366 }
8367 else {
8368
8369 if (start) {
8370 start = t.moment(start);
8371 if (!start.isValid()) {
8372 return false;
8373 }
8374 }
8375
8376 if (end) {
8377 end = t.moment(end);
8378 if (!end.isValid()) {
8379 end = null; // let defaults take over
8380 }
8381 }
8382
8383 allDay = input.allDay;
8384 if (allDay === undefined) { // still undefined? fallback to default
8385 allDay = firstDefined(
8386 source ? source.allDayDefault : undefined,
8387 options.allDayDefault
8388 );
8389 // still undefined? normalizeEventDateProps will calculate it
8390 }
8391
8392 assignDatesToEvent(start, end, allDay, out);
8393 }
8394
8395 return out;
8396 }
8397
8398
8399 // Normalizes and assigns the given dates to the given partially-formed event object.
8400 // NOTE: mutates the given start/end moments. does not make a copy.
8401 function assignDatesToEvent(start, end, allDay, event) {
8402 event.start = start;
8403 event.end = end;
8404 event.allDay = allDay;
8405 normalizeEventDateProps(event);
8406 backupEventDates(event);
8407 }
8408
8409
8410 // Ensures the allDay property exists.
8411 // Ensures the start/end dates are consistent with allDay and forceEventDuration.
8412 // Accepts an Event object, or a plain object with event-ish properties.
8413 // NOTE: Will modify the given object.
8414 function normalizeEventDateProps(props) {
8415
8416 if (props.allDay == null) {
8417 props.allDay = !(props.start.hasTime() || (props.end && props.end.hasTime()));
8418 }
8419
8420 if (props.allDay) {
8421 props.start.stripTime();
8422 if (props.end) {
8423 props.end.stripTime();
8424 }
8425 }
8426 else {
8427 if (!props.start.hasTime()) {
8428 props.start = t.rezoneDate(props.start); // will also give it a 00:00 time
8429 }
8430 if (props.end && !props.end.hasTime()) {
8431 props.end = t.rezoneDate(props.end); // will also give it a 00:00 time
8432 }
8433 }
8434
8435 if (props.end && !props.end.isAfter(props.start)) {
8436 props.end = null;
8437 }
8438
8439 if (!props.end) {
8440 if (options.forceEventDuration) {
8441 props.end = t.getDefaultEventEnd(props.allDay, props.start);
8442 }
8443 else {
8444 props.end = null;
8445 }
8446 }
8447 }
8448
8449
8450 // If `range` is a proper range with a start and end, returns the original object.
8451 // If missing an end, computes a new range with an end, computing it as if it were an event.
8452 // TODO: make this a part of the event -> eventRange system
8453 function ensureVisibleEventRange(range) {
8454 var allDay;
8455
8456 if (!range.end) {
8457
8458 allDay = range.allDay; // range might be more event-ish than we think
8459 if (allDay == null) {
8460 allDay = !range.start.hasTime();
8461 }
8462
8463 range = {
8464 start: range.start,
8465 end: t.getDefaultEventEnd(allDay, range.start)
8466 };
8467 }
8468 return range;
8469 }
8470
8471
8472 // If the given event is a recurring event, break it down into an array of individual instances.
8473 // If not a recurring event, return an array with the single original event.
8474 // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array.
8475 // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours).
8476 function expandEvent(abstractEvent, _rangeStart, _rangeEnd) {
8477 var events = [];
8478 var dowHash;
8479 var dow;
8480 var i;
8481 var date;
8482 var startTime, endTime;
8483 var start, end;
8484 var event;
8485
8486 _rangeStart = _rangeStart || rangeStart;
8487 _rangeEnd = _rangeEnd || rangeEnd;
8488
8489 if (abstractEvent) {
8490 if (abstractEvent._recurring) {
8491
8492 // make a boolean hash as to whether the event occurs on each day-of-week
8493 if ((dow = abstractEvent.dow)) {
8494 dowHash = {};
8495 for (i = 0; i < dow.length; i++) {
8496 dowHash[dow[i]] = true;
8497 }
8498 }
8499
8500 // iterate through every day in the current range
8501 date = _rangeStart.clone().stripTime(); // holds the date of the current day
8502 while (date.isBefore(_rangeEnd)) {
8503
8504 if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week
8505
8506 startTime = abstractEvent.start; // the stored start and end properties are times (Durations)
8507 endTime = abstractEvent.end; // "
8508 start = date.clone();
8509 end = null;
8510
8511 if (startTime) {
8512 start = start.time(startTime);
8513 }
8514 if (endTime) {
8515 end = date.clone().time(endTime);
8516 }
8517
8518 event = $.extend({}, abstractEvent); // make a copy of the original
8519 assignDatesToEvent(
8520 start, end,
8521 !startTime && !endTime, // allDay?
8522 event
8523 );
8524 events.push(event);
8525 }
8526
8527 date.add(1, 'days');
8528 }
8529 }
8530 else {
8531 events.push(abstractEvent); // return the original event. will be a one-item array
8532 }
8533 }
8534
8535 return events;
8536 }
8537
8538
8539
8540 /* Event Modification Math
8541 -----------------------------------------------------------------------------------------*/
8542
8543
8544 // Modifies an event and all related events by applying the given properties.
8545 // Special date-diffing logic is used for manipulation of dates.
8546 // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end.
8547 // All date comparisons are done against the event's pristine _start and _end dates.
8548 // Returns an object with delta information and a function to undo all operations.
8549 //
8550 function mutateEvent(event, props) {
8551 var miscProps = {};
8552 var clearEnd;
8553 var dateDelta;
8554 var durationDelta;
8555 var undoFunc;
8556
8557 props = props || {};
8558
8559 // ensure new date-related values to compare against
8560 if (!props.start) {
8561 props.start = event.start.clone();
8562 }
8563 if (props.end === undefined) {
8564 props.end = event.end ? event.end.clone() : null;
8565 }
8566 if (props.allDay == null) { // is null or undefined?
8567 props.allDay = event.allDay;
8568 }
8569
8570 normalizeEventDateProps(props); // massages start/end/allDay
8571
8572 // clear the end date if explicitly changed to null
8573 clearEnd = event._end !== null && props.end === null;
8574
8575 // compute the delta for moving the start and end dates together
8576 if (props.allDay) {
8577 dateDelta = diffDay(props.start, event._start); // whole-day diff from start-of-day
8578 }
8579 else {
8580 dateDelta = diffDayTime(props.start, event._start);
8581 }
8582
8583 // compute the delta for moving the end date (after applying dateDelta)
8584 if (!clearEnd && props.end) {
8585 durationDelta = diffDayTime(
8586 // new duration
8587 props.end,
8588 props.start
8589 ).subtract(diffDayTime(
8590 // subtract old duration
8591 event._end || t.getDefaultEventEnd(event._allDay, event._start),
8592 event._start
8593 ));
8594 }
8595
8596 // gather all non-date-related properties
8597 $.each(props, function(name, val) {
8598 if (isMiscEventPropName(name)) {
8599 if (val !== undefined) {
8600 miscProps[name] = val;
8601 }
8602 }
8603 });
8604
8605 // apply the operations to the event and all related events
8606 undoFunc = mutateEvents(
8607 clientEvents(event._id), // get events with this ID
8608 clearEnd,
8609 props.allDay,
8610 dateDelta,
8611 durationDelta,
8612 miscProps
8613 );
8614
8615 return {
8616 dateDelta: dateDelta,
8617 durationDelta: durationDelta,
8618 undo: undoFunc
8619 };
8620 }
8621
8622
8623 // Modifies an array of events in the following ways (operations are in order):
8624 // - clear the event's `end`
8625 // - convert the event to allDay
8626 // - add `dateDelta` to the start and end
8627 // - add `durationDelta` to the event's duration
8628 // - assign `miscProps` to the event
8629 //
8630 // Returns a function that can be called to undo all the operations.
8631 //
8632 // TODO: don't use so many closures. possible memory issues when lots of events with same ID.
8633 //
8634 function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) {
8635 var isAmbigTimezone = t.getIsAmbigTimezone();
8636 var undoFunctions = [];
8637
8638 // normalize zero-length deltas to be null
8639 if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; }
8640 if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; }
8641
8642 $.each(events, function(i, event) {
8643 var oldProps;
8644 var newProps;
8645
8646 // build an object holding all the old values, both date-related and misc.
8647 // for the undo function.
8648 oldProps = {
8649 start: event.start.clone(),
8650 end: event.end ? event.end.clone() : null,
8651 allDay: event.allDay
8652 };
8653 $.each(miscProps, function(name) {
8654 oldProps[name] = event[name];
8655 });
8656
8657 // new date-related properties. work off the original date snapshot.
8658 // ok to use references because they will be thrown away when backupEventDates is called.
8659 newProps = {
8660 start: event._start,
8661 end: event._end,
8662 allDay: event._allDay
8663 };
8664
8665 if (clearEnd) {
8666 newProps.end = null;
8667 }
8668
8669 newProps.allDay = allDay;
8670
8671 normalizeEventDateProps(newProps); // massages start/end/allDay
8672
8673 if (dateDelta) {
8674 newProps.start.add(dateDelta);
8675 if (newProps.end) {
8676 newProps.end.add(dateDelta);
8677 }
8678 }
8679
8680 if (durationDelta) {
8681 if (!newProps.end) {
8682 newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start);
8683 }
8684 newProps.end.add(durationDelta);
8685 }
8686
8687 // if the dates have changed, and we know it is impossible to recompute the
8688 // timezone offsets, strip the zone.
8689 if (
8690 isAmbigTimezone &&
8691 !newProps.allDay &&
8692 (dateDelta || durationDelta)
8693 ) {
8694 newProps.start.stripZone();
8695 if (newProps.end) {
8696 newProps.end.stripZone();
8697 }
8698 }
8699
8700 $.extend(event, miscProps, newProps); // copy over misc props, then date-related props
8701 backupEventDates(event); // regenerate internal _start/_end/_allDay
8702
8703 undoFunctions.push(function() {
8704 $.extend(event, oldProps);
8705 backupEventDates(event); // regenerate internal _start/_end/_allDay
8706 });
8707 });
8708
8709 return function() {
8710 for (var i = 0; i < undoFunctions.length; i++) {
8711 undoFunctions[i]();
8712 }
8713 };
8714 }
8715
8716
8717 /* Business Hours
8718 -----------------------------------------------------------------------------------------*/
8719
8720 t.getBusinessHoursEvents = getBusinessHoursEvents;
8721
8722
8723 // Returns an array of events as to when the business hours occur in the given view.
8724 // Abuse of our event system :(
8725 function getBusinessHoursEvents() {
8726 var optionVal = options.businessHours;
8727 var defaultVal = {
8728 className: 'fc-nonbusiness',
8729 start: '09:00',
8730 end: '17:00',
8731 dow: [ 1, 2, 3, 4, 5 ], // monday - friday
8732 rendering: 'inverse-background'
8733 };
8734 var view = t.getView();
8735 var eventInput;
8736
8737 if (optionVal) {
8738 if (typeof optionVal === 'object') {
8739 // option value is an object that can override the default business hours
8740 eventInput = $.extend({}, defaultVal, optionVal);
8741 }
8742 else {
8743 // option value is `true`. use default business hours
8744 eventInput = defaultVal;
8745 }
8746 }
8747
8748 if (eventInput) {
8749 return expandEvent(
8750 buildEventFromInput(eventInput),
8751 view.start,
8752 view.end
8753 );
8754 }
8755
8756 return [];
8757 }
8758
8759
8760 /* Overlapping / Constraining
8761 -----------------------------------------------------------------------------------------*/
8762
8763 t.isEventRangeAllowed = isEventRangeAllowed;
8764 t.isSelectionRangeAllowed = isSelectionRangeAllowed;
8765 t.isExternalDropRangeAllowed = isExternalDropRangeAllowed;
8766
8767
8768 function isEventRangeAllowed(range, event) {
8769 var source = event.source || {};
8770 var constraint = firstDefined(
8771 event.constraint,
8772 source.constraint,
8773 options.eventConstraint
8774 );
8775 var overlap = firstDefined(
8776 event.overlap,
8777 source.overlap,
8778 options.eventOverlap
8779 );
8780
8781 range = ensureVisibleEventRange(range); // ensure a proper range with an end for isRangeAllowed
8782
8783 return isRangeAllowed(range, constraint, overlap, event);
8784 }
8785
8786
8787 function isSelectionRangeAllowed(range) {
8788 return isRangeAllowed(range, options.selectConstraint, options.selectOverlap);
8789 }
8790
8791
8792 // when `eventProps` is defined, consider this an event.
8793 // `eventProps` can contain misc non-date-related info about the event.
8794 function isExternalDropRangeAllowed(range, eventProps) {
8795 var eventInput;
8796 var event;
8797
8798 // note: very similar logic is in View's reportExternalDrop
8799 if (eventProps) {
8800 eventInput = $.extend({}, eventProps, range);
8801 event = expandEvent(buildEventFromInput(eventInput))[0];
8802 }
8803
8804 if (event) {
8805 return isEventRangeAllowed(range, event);
8806 }
8807 else { // treat it as a selection
8808
8809 range = ensureVisibleEventRange(range); // ensure a proper range with an end for isSelectionRangeAllowed
8810
8811 return isSelectionRangeAllowed(range);
8812 }
8813 }
8814
8815
8816 // Returns true if the given range (caused by an event drop/resize or a selection) is allowed to exist
8817 // according to the constraint/overlap settings.
8818 // `event` is not required if checking a selection.
8819 function isRangeAllowed(range, constraint, overlap, event) {
8820 var constraintEvents;
8821 var anyContainment;
8822 var i, otherEvent;
8823 var otherOverlap;
8824
8825 // normalize. fyi, we're normalizing in too many places :(
8826 range = {
8827 start: range.start.clone().stripZone(),
8828 end: range.end.clone().stripZone()
8829 };
8830
8831 // the range must be fully contained by at least one of produced constraint events
8832 if (constraint != null) {
8833
8834 // not treated as an event! intermediate data structure
8835 // TODO: use ranges in the future
8836 constraintEvents = constraintToEvents(constraint);
8837
8838 anyContainment = false;
8839 for (i = 0; i < constraintEvents.length; i++) {
8840 if (eventContainsRange(constraintEvents[i], range)) {
8841 anyContainment = true;
8842 break;
8843 }
8844 }
8845
8846 if (!anyContainment) {
8847 return false;
8848 }
8849 }
8850
8851 for (i = 0; i < cache.length; i++) { // loop all events and detect overlap
8852 otherEvent = cache[i];
8853
8854 // don't compare the event to itself or other related [repeating] events
8855 if (event && event._id === otherEvent._id) {
8856 continue;
8857 }
8858
8859 // there needs to be an actual intersection before disallowing anything
8860 if (eventIntersectsRange(otherEvent, range)) {
8861
8862 // evaluate overlap for the given range and short-circuit if necessary
8863 if (overlap === false) {
8864 return false;
8865 }
8866 else if (typeof overlap === 'function' && !overlap(otherEvent, event)) {
8867 return false;
8868 }
8869
8870 // if we are computing if the given range is allowable for an event, consider the other event's
8871 // EventObject-specific or Source-specific `overlap` property
8872 if (event) {
8873 otherOverlap = firstDefined(
8874 otherEvent.overlap,
8875 (otherEvent.source || {}).overlap
8876 // we already considered the global `eventOverlap`
8877 );
8878 if (otherOverlap === false) {
8879 return false;
8880 }
8881 if (typeof otherOverlap === 'function' && !otherOverlap(event, otherEvent)) {
8882 return false;
8883 }
8884 }
8885 }
8886 }
8887
8888 return true;
8889 }
8890
8891
8892 // Given an event input from the API, produces an array of event objects. Possible event inputs:
8893 // 'businessHours'
8894 // An event ID (number or string)
8895 // An object with specific start/end dates or a recurring event (like what businessHours accepts)
8896 function constraintToEvents(constraintInput) {
8897
8898 if (constraintInput === 'businessHours') {
8899 return getBusinessHoursEvents();
8900 }
8901
8902 if (typeof constraintInput === 'object') {
8903 return expandEvent(buildEventFromInput(constraintInput));
8904 }
8905
8906 return clientEvents(constraintInput); // probably an ID
8907 }
8908
8909
8910 // Does the event's date range fully contain the given range?
8911 // start/end already assumed to have stripped zones :(
8912 function eventContainsRange(event, range) {
8913 var eventStart = event.start.clone().stripZone();
8914 var eventEnd = t.getEventEnd(event).stripZone();
8915
8916 return range.start >= eventStart && range.end <= eventEnd;
8917 }
8918
8919
8920 // Does the event's date range intersect with the given range?
8921 // start/end already assumed to have stripped zones :(
8922 function eventIntersectsRange(event, range) {
8923 var eventStart = event.start.clone().stripZone();
8924 var eventEnd = t.getEventEnd(event).stripZone();
8925
8926 return range.start < eventEnd && range.end > eventStart;
8927 }
8928
8929}
8930
8931
8932// updates the "backup" properties, which are preserved in order to compute diffs later on.
8933function backupEventDates(event) {
8934 event._allDay = event.allDay;
8935 event._start = event.start.clone();
8936 event._end = event.end ? event.end.clone() : null;
8937}
8938
8939 /* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells.
8940----------------------------------------------------------------------------------------------------------------------*/
8941// It is a manager for a DayGrid subcomponent, which does most of the heavy lifting.
8942// It is responsible for managing width/height.
8943
8944var BasicView = fcViews.basic = View.extend({
8945
8946 dayGrid: null, // the main subcomponent that does most of the heavy lifting
8947
8948 dayNumbersVisible: false, // display day numbers on each day cell?
8949 weekNumbersVisible: false, // display week numbers along the side?
8950
8951 weekNumberWidth: null, // width of all the week-number cells running down the side
8952
8953 headRowEl: null, // the fake row element of the day-of-week header
8954
8955
8956 initialize: function() {
8957 this.dayGrid = new DayGrid(this);
8958 this.coordMap = this.dayGrid.coordMap; // the view's date-to-cell mapping is identical to the subcomponent's
8959 },
8960
8961
8962 // Sets the display range and computes all necessary dates
8963 setRange: function(range) {
8964 View.prototype.setRange.call(this, range); // call the super-method
8965
8966 this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange
8967 this.dayGrid.setRange(range);
8968 },
8969
8970
8971 // Compute the value to feed into setRange. Overrides superclass.
8972 computeRange: function(date) {
8973 var range = View.prototype.computeRange.call(this, date); // get value from the super-method
8974
8975 // year and month views should be aligned with weeks. this is already done for week
8976 if (/year|month/.test(range.intervalUnit)) {
8977 range.start.startOf('week');
8978 range.start = this.skipHiddenDays(range.start);
8979
8980 // make end-of-week if not already
8981 if (range.end.weekday()) {
8982 range.end.add(1, 'week').startOf('week');
8983 range.end = this.skipHiddenDays(range.end, -1, true); // exclusively move backwards
8984 }
8985 }
8986
8987 return range;
8988 },
8989
8990
8991 // Renders the view into `this.el`, which should already be assigned
8992 render: function() {
8993
8994 this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible
8995 this.weekNumbersVisible = this.opt('weekNumbers');
8996 this.dayGrid.numbersVisible = this.dayNumbersVisible || this.weekNumbersVisible;
8997
8998 this.el.addClass('fc-basic-view').html(this.renderHtml());
8999
9000 this.headRowEl = this.el.find('thead .fc-row');
9001
9002 this.scrollerEl = this.el.find('.fc-day-grid-container');
9003 this.dayGrid.coordMap.containerEl = this.scrollerEl; // constrain clicks/etc to the dimensions of the scroller
9004
9005 this.dayGrid.el = this.el.find('.fc-day-grid');
9006 this.dayGrid.render(this.hasRigidRows());
9007 },
9008
9009
9010 // Make subcomponents ready for cleanup
9011 destroy: function() {
9012 this.dayGrid.destroy();
9013 View.prototype.destroy.call(this); // call the super-method
9014 },
9015
9016
9017 // Builds the HTML skeleton for the view.
9018 // The day-grid component will render inside of a container defined by this HTML.
9019 renderHtml: function() {
9020 return '' +
9021 '<table>' +
9022 '<thead>' +
9023 '<tr>' +
9024 '<td class="' + this.widgetHeaderClass + '">' +
9025 this.dayGrid.headHtml() + // render the day-of-week headers
9026 '</td>' +
9027 '</tr>' +
9028 '</thead>' +
9029 '<tbody>' +
9030 '<tr>' +
9031 '<td class="' + this.widgetContentClass + '">' +
9032 '<div class="fc-day-grid-container">' +
9033 '<div class="fc-day-grid"/>' +
9034 '</div>' +
9035 '</td>' +
9036 '</tr>' +
9037 '</tbody>' +
9038 '</table>';
9039 },
9040
9041
9042 // Generates the HTML that will go before the day-of week header cells.
9043 // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL.
9044 headIntroHtml: function() {
9045 if (this.weekNumbersVisible) {
9046 return '' +
9047 '<th class="fc-week-number ' + this.widgetHeaderClass + '" ' + this.weekNumberStyleAttr() + '>' +
9048 '<span>' + // needed for matchCellWidths
9049 htmlEscape(this.opt('weekNumberTitle')) +
9050 '</span>' +
9051 '</th>';
9052 }
9053 },
9054
9055
9056 // Generates the HTML that will go before content-skeleton cells that display the day/week numbers.
9057 // Queried by the DayGrid subcomponent. Ordering depends on isRTL.
9058 numberIntroHtml: function(row) {
9059 if (this.weekNumbersVisible) {
9060 return '' +
9061 '<td class="fc-week-number" ' + this.weekNumberStyleAttr() + '>' +
9062 '<span>' + // needed for matchCellWidths
9063 this.calendar.calculateWeekNumber(this.dayGrid.getCell(row, 0).start) +
9064 '</span>' +
9065 '</td>';
9066 }
9067 },
9068
9069
9070 // Generates the HTML that goes before the day bg cells for each day-row.
9071 // Queried by the DayGrid subcomponent. Ordering depends on isRTL.
9072 dayIntroHtml: function() {
9073 if (this.weekNumbersVisible) {
9074 return '<td class="fc-week-number ' + this.widgetContentClass + '" ' +
9075 this.weekNumberStyleAttr() + '></td>';
9076 }
9077 },
9078
9079
9080 // Generates the HTML that goes before every other type of row generated by DayGrid. Ordering depends on isRTL.
9081 // Affects helper-skeleton and highlight-skeleton rows.
9082 introHtml: function() {
9083 if (this.weekNumbersVisible) {
9084 return '<td class="fc-week-number" ' + this.weekNumberStyleAttr() + '></td>';
9085 }
9086 },
9087
9088
9089 // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton.
9090 // The number row will only exist if either day numbers or week numbers are turned on.
9091 numberCellHtml: function(cell) {
9092 var date = cell.start;
9093 var classes;
9094
9095 if (!this.dayNumbersVisible) { // if there are week numbers but not day numbers
9096 return '<td/>'; // will create an empty space above events :(
9097 }
9098
9099 classes = this.dayGrid.getDayClasses(date);
9100 classes.unshift('fc-day-number');
9101
9102 return '' +
9103 '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '">' +
9104 date.date() +
9105 '</td>';
9106 },
9107
9108
9109 // Generates an HTML attribute string for setting the width of the week number column, if it is known
9110 weekNumberStyleAttr: function() {
9111 if (this.weekNumberWidth !== null) {
9112 return 'style="width:' + this.weekNumberWidth + 'px"';
9113 }
9114 return '';
9115 },
9116
9117
9118 // Determines whether each row should have a constant height
9119 hasRigidRows: function() {
9120 var eventLimit = this.opt('eventLimit');
9121 return eventLimit && typeof eventLimit !== 'number';
9122 },
9123
9124
9125 /* Dimensions
9126 ------------------------------------------------------------------------------------------------------------------*/
9127
9128
9129 // Refreshes the horizontal dimensions of the view
9130 updateWidth: function() {
9131 if (this.weekNumbersVisible) {
9132 // Make sure all week number cells running down the side have the same width.
9133 // Record the width for cells created later.
9134 this.weekNumberWidth = matchCellWidths(
9135 this.el.find('.fc-week-number')
9136 );
9137 }
9138 },
9139
9140
9141 // Adjusts the vertical dimensions of the view to the specified values
9142 setHeight: function(totalHeight, isAuto) {
9143 var eventLimit = this.opt('eventLimit');
9144 var scrollerHeight;
9145
9146 // reset all heights to be natural
9147 unsetScroller(this.scrollerEl);
9148 uncompensateScroll(this.headRowEl);
9149
9150 this.dayGrid.destroySegPopover(); // kill the "more" popover if displayed
9151
9152 // is the event limit a constant level number?
9153 if (eventLimit && typeof eventLimit === 'number') {
9154 this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after
9155 }
9156
9157 scrollerHeight = this.computeScrollerHeight(totalHeight);
9158 this.setGridHeight(scrollerHeight, isAuto);
9159
9160 // is the event limit dynamically calculated?
9161 if (eventLimit && typeof eventLimit !== 'number') {
9162 this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set
9163 }
9164
9165 if (!isAuto && setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars?
9166
9167 compensateScroll(this.headRowEl, getScrollbarWidths(this.scrollerEl));
9168
9169 // doing the scrollbar compensation might have created text overflow which created more height. redo
9170 scrollerHeight = this.computeScrollerHeight(totalHeight);
9171 this.scrollerEl.height(scrollerHeight);
9172
9173 this.restoreScroll();
9174 }
9175 },
9176
9177
9178 // Sets the height of just the DayGrid component in this view
9179 setGridHeight: function(height, isAuto) {
9180 if (isAuto) {
9181 undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding
9182 }
9183 else {
9184 distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows
9185 }
9186 },
9187
9188
9189 /* Events
9190 ------------------------------------------------------------------------------------------------------------------*/
9191
9192
9193 // Renders the given events onto the view and populates the segments array
9194 renderEvents: function(events) {
9195 this.dayGrid.renderEvents(events);
9196
9197 this.updateHeight(); // must compensate for events that overflow the row
9198 },
9199
9200
9201 // Retrieves all segment objects that are rendered in the view
9202 getEventSegs: function() {
9203 return this.dayGrid.getEventSegs();
9204 },
9205
9206
9207 // Unrenders all event elements and clears internal segment data
9208 destroyEvents: function() {
9209 this.recordScroll(); // removing events will reduce height and mess with the scroll, so record beforehand
9210 this.dayGrid.destroyEvents();
9211
9212 // we DON'T need to call updateHeight() because:
9213 // A) a renderEvents() call always happens after this, which will eventually call updateHeight()
9214 // B) in IE8, this causes a flash whenever events are rerendered
9215 },
9216
9217
9218 /* Dragging (for both events and external elements)
9219 ------------------------------------------------------------------------------------------------------------------*/
9220
9221
9222 // A returned value of `true` signals that a mock "helper" event has been rendered.
9223 renderDrag: function(dropLocation, seg) {
9224 return this.dayGrid.renderDrag(dropLocation, seg);
9225 },
9226
9227
9228 destroyDrag: function() {
9229 this.dayGrid.destroyDrag();
9230 },
9231
9232
9233 /* Selection
9234 ------------------------------------------------------------------------------------------------------------------*/
9235
9236
9237 // Renders a visual indication of a selection
9238 renderSelection: function(range) {
9239 this.dayGrid.renderSelection(range);
9240 },
9241
9242
9243 // Unrenders a visual indications of a selection
9244 destroySelection: function() {
9245 this.dayGrid.destroySelection();
9246 }
9247
9248});
9249
9250 /* A month view with day cells running in rows (one-per-week) and columns
9251----------------------------------------------------------------------------------------------------------------------*/
9252
9253setDefaults({
9254 fixedWeekCount: true
9255});
9256
9257var MonthView = fcViews.month = BasicView.extend({
9258
9259 // Produces information about what range to display
9260 computeRange: function(date) {
9261 var range = BasicView.prototype.computeRange.call(this, date); // get value from super-method
9262
9263 if (this.isFixedWeeks()) {
9264 // ensure 6 weeks
9265 range.end.add(
9266 6 - range.end.diff(range.start, 'weeks'),
9267 'weeks'
9268 );
9269 }
9270
9271 return range;
9272 },
9273
9274
9275 // Overrides the default BasicView behavior to have special multi-week auto-height logic
9276 setGridHeight: function(height, isAuto) {
9277
9278 isAuto = isAuto || this.opt('weekMode') === 'variable'; // LEGACY: weekMode is deprecated
9279
9280 // if auto, make the height of each row the height that it would be if there were 6 weeks
9281 if (isAuto) {
9282 height *= this.rowCnt / 6;
9283 }
9284
9285 distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows
9286 },
9287
9288
9289 isFixedWeeks: function() {
9290 var weekMode = this.opt('weekMode'); // LEGACY: weekMode is deprecated
9291 if (weekMode) {
9292 return weekMode === 'fixed'; // if any other type of weekMode, assume NOT fixed
9293 }
9294
9295 return this.opt('fixedWeekCount');
9296 }
9297
9298});
9299
9300MonthView.duration = { months: 1 };
9301
9302 /* A week view with simple day cells running horizontally
9303----------------------------------------------------------------------------------------------------------------------*/
9304
9305fcViews.basicWeek = {
9306 type: 'basic',
9307 duration: { weeks: 1 }
9308};
9309 /* A view with a single simple day cell
9310----------------------------------------------------------------------------------------------------------------------*/
9311
9312fcViews.basicDay = {
9313 type: 'basic',
9314 duration: { days: 1 }
9315};
9316 /* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically.
9317----------------------------------------------------------------------------------------------------------------------*/
9318// Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on).
9319// Responsible for managing width/height.
9320
9321setDefaults({
9322 allDaySlot: true,
9323 allDayText: 'all-day',
9324 scrollTime: '06:00:00',
9325 slotDuration: '00:30:00',
9326 minTime: '00:00:00',
9327 maxTime: '24:00:00',
9328 slotEventOverlap: true
9329});
9330
9331var AGENDA_ALL_DAY_EVENT_LIMIT = 5;
9332
9333fcViews.agenda = View.extend({ // AgendaView
9334
9335 timeGrid: null, // the main time-grid subcomponent of this view
9336 dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null
9337
9338 axisWidth: null, // the width of the time axis running down the side
9339
9340 noScrollRowEls: null, // set of fake row elements that must compensate when scrollerEl has scrollbars
9341
9342 // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath
9343 bottomRuleEl: null,
9344 bottomRuleHeight: null,
9345
9346
9347 initialize: function() {
9348 this.timeGrid = new TimeGrid(this);
9349
9350 if (this.opt('allDaySlot')) { // should we display the "all-day" area?
9351 this.dayGrid = new DayGrid(this); // the all-day subcomponent of this view
9352
9353 // the coordinate grid will be a combination of both subcomponents' grids
9354 this.coordMap = new ComboCoordMap([
9355 this.dayGrid.coordMap,
9356 this.timeGrid.coordMap
9357 ]);
9358 }
9359 else {
9360 this.coordMap = this.timeGrid.coordMap;
9361 }
9362 },
9363
9364
9365 /* Rendering
9366 ------------------------------------------------------------------------------------------------------------------*/
9367
9368
9369 // Sets the display range and computes all necessary dates
9370 setRange: function(range) {
9371 View.prototype.setRange.call(this, range); // call the super-method
9372
9373 this.timeGrid.setRange(range);
9374 if (this.dayGrid) {
9375 this.dayGrid.setRange(range);
9376 }
9377 },
9378
9379
9380 // Renders the view into `this.el`, which has already been assigned
9381 render: function() {
9382
9383 this.el.addClass('fc-agenda-view').html(this.renderHtml());
9384
9385 // the element that wraps the time-grid that will probably scroll
9386 this.scrollerEl = this.el.find('.fc-time-grid-container');
9387 this.timeGrid.coordMap.containerEl = this.scrollerEl; // don't accept clicks/etc outside of this
9388
9389 this.timeGrid.el = this.el.find('.fc-time-grid');
9390 this.timeGrid.render();
9391
9392 // the <hr> that sometimes displays under the time-grid
9393 this.bottomRuleEl = $('<hr class="' + this.widgetHeaderClass + '"/>')
9394 .appendTo(this.timeGrid.el); // inject it into the time-grid
9395
9396 if (this.dayGrid) {
9397 this.dayGrid.el = this.el.find('.fc-day-grid');
9398 this.dayGrid.render();
9399
9400 // have the day-grid extend it's coordinate area over the <hr> dividing the two grids
9401 this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight();
9402 }
9403
9404 this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller
9405 },
9406
9407
9408 // Make subcomponents ready for cleanup
9409 destroy: function() {
9410 this.timeGrid.destroy();
9411 if (this.dayGrid) {
9412 this.dayGrid.destroy();
9413 }
9414 View.prototype.destroy.call(this); // call the super-method
9415 },
9416
9417
9418 // Builds the HTML skeleton for the view.
9419 // The day-grid and time-grid components will render inside containers defined by this HTML.
9420 renderHtml: function() {
9421 return '' +
9422 '<table>' +
9423 '<thead>' +
9424 '<tr>' +
9425 '<td class="' + this.widgetHeaderClass + '">' +
9426 this.timeGrid.headHtml() + // render the day-of-week headers
9427 '</td>' +
9428 '</tr>' +
9429 '</thead>' +
9430 '<tbody>' +
9431 '<tr>' +
9432 '<td class="' + this.widgetContentClass + '">' +
9433 (this.dayGrid ?
9434 '<div class="fc-day-grid"/>' +
9435 '<hr class="' + this.widgetHeaderClass + '"/>' :
9436 ''
9437 ) +
9438 '<div class="fc-time-grid-container">' +
9439 '<div class="fc-time-grid"/>' +
9440 '</div>' +
9441 '</td>' +
9442 '</tr>' +
9443 '</tbody>' +
9444 '</table>';
9445 },
9446
9447
9448 // Generates the HTML that will go before the day-of week header cells.
9449 // Queried by the TimeGrid subcomponent when generating rows. Ordering depends on isRTL.
9450 headIntroHtml: function() {
9451 var date;
9452 var weekNumber;
9453 var weekTitle;
9454 var weekText;
9455
9456 if (this.opt('weekNumbers')) {
9457 date = this.timeGrid.getCell(0).start;
9458 weekNumber = this.calendar.calculateWeekNumber(date);
9459 weekTitle = this.opt('weekNumberTitle');
9460
9461 if (this.opt('isRTL')) {
9462 weekText = weekNumber + weekTitle;
9463 }
9464 else {
9465 weekText = weekTitle + weekNumber;
9466 }
9467
9468 return '' +
9469 '<th class="fc-axis fc-week-number ' + this.widgetHeaderClass + '" ' + this.axisStyleAttr() + '>' +
9470 '<span>' + // needed for matchCellWidths
9471 htmlEscape(weekText) +
9472 '</span>' +
9473 '</th>';
9474 }
9475 else {
9476 return '<th class="fc-axis ' + this.widgetHeaderClass + '" ' + this.axisStyleAttr() + '></th>';
9477 }
9478 },
9479
9480
9481 // Generates the HTML that goes before the all-day cells.
9482 // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL.
9483 dayIntroHtml: function() {
9484 return '' +
9485 '<td class="fc-axis ' + this.widgetContentClass + '" ' + this.axisStyleAttr() + '>' +
9486 '<span>' + // needed for matchCellWidths
9487 (this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'))) +
9488 '</span>' +
9489 '</td>';
9490 },
9491
9492
9493 // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column.
9494 slotBgIntroHtml: function() {
9495 return '<td class="fc-axis ' + this.widgetContentClass + '" ' + this.axisStyleAttr() + '></td>';
9496 },
9497
9498
9499 // Generates the HTML that goes before all other types of cells.
9500 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid.
9501 // Queried by the TimeGrid and DayGrid subcomponents when generating rows. Ordering depends on isRTL.
9502 introHtml: function() {
9503 return '<td class="fc-axis" ' + this.axisStyleAttr() + '></td>';
9504 },
9505
9506
9507 // Generates an HTML attribute string for setting the width of the axis, if it is known
9508 axisStyleAttr: function() {
9509 if (this.axisWidth !== null) {
9510 return 'style="width:' + this.axisWidth + 'px"';
9511 }
9512 return '';
9513 },
9514
9515
9516 /* Dimensions
9517 ------------------------------------------------------------------------------------------------------------------*/
9518
9519
9520 updateSize: function(isResize) {
9521 if (isResize) {
9522 this.timeGrid.resize();
9523 }
9524 View.prototype.updateSize.call(this, isResize);
9525 },
9526
9527
9528 // Refreshes the horizontal dimensions of the view
9529 updateWidth: function() {
9530 // make all axis cells line up, and record the width so newly created axis cells will have it
9531 this.axisWidth = matchCellWidths(this.el.find('.fc-axis'));
9532 },
9533
9534
9535 // Adjusts the vertical dimensions of the view to the specified values
9536 setHeight: function(totalHeight, isAuto) {
9537 var eventLimit;
9538 var scrollerHeight;
9539
9540 if (this.bottomRuleHeight === null) {
9541 // calculate the height of the rule the very first time
9542 this.bottomRuleHeight = this.bottomRuleEl.outerHeight();
9543 }
9544 this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary
9545
9546 // reset all dimensions back to the original state
9547 this.scrollerEl.css('overflow', '');
9548 unsetScroller(this.scrollerEl);
9549 uncompensateScroll(this.noScrollRowEls);
9550
9551 // limit number of events in the all-day area
9552 if (this.dayGrid) {
9553 this.dayGrid.destroySegPopover(); // kill the "more" popover if displayed
9554
9555 eventLimit = this.opt('eventLimit');
9556 if (eventLimit && typeof eventLimit !== 'number') {
9557 eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number
9558 }
9559 if (eventLimit) {
9560 this.dayGrid.limitRows(eventLimit);
9561 }
9562 }
9563
9564 if (!isAuto) { // should we force dimensions of the scroll container, or let the contents be natural height?
9565
9566 scrollerHeight = this.computeScrollerHeight(totalHeight);
9567 if (setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars?
9568
9569 // make the all-day and header rows lines up
9570 compensateScroll(this.noScrollRowEls, getScrollbarWidths(this.scrollerEl));
9571
9572 // the scrollbar compensation might have changed text flow, which might affect height, so recalculate
9573 // and reapply the desired height to the scroller.
9574 scrollerHeight = this.computeScrollerHeight(totalHeight);
9575 this.scrollerEl.height(scrollerHeight);
9576
9577 this.restoreScroll();
9578 }
9579 else { // no scrollbars
9580 // still, force a height and display the bottom rule (marks the end of day)
9581 this.scrollerEl.height(scrollerHeight).css('overflow', 'hidden'); // in case <hr> goes outside
9582 this.bottomRuleEl.show();
9583 }
9584 }
9585 },
9586
9587
9588 // Sets the scroll value of the scroller to the initial pre-configured state prior to allowing the user to change it
9589 initializeScroll: function() {
9590 var _this = this;
9591 var scrollTime = moment.duration(this.opt('scrollTime'));
9592 var top = this.timeGrid.computeTimeTop(scrollTime);
9593
9594 // zoom can give weird floating-point values. rather scroll a little bit further
9595 top = Math.ceil(top);
9596
9597 if (top) {
9598 top++; // to overcome top border that slots beyond the first have. looks better
9599 }
9600
9601 function scroll() {
9602 _this.scrollerEl.scrollTop(top);
9603 }
9604
9605 scroll();
9606 setTimeout(scroll, 0); // overrides any previous scroll state made by the browser
9607 },
9608
9609
9610 /* Events
9611 ------------------------------------------------------------------------------------------------------------------*/
9612
9613
9614 // Renders events onto the view and populates the View's segment array
9615 renderEvents: function(events) {
9616 var dayEvents = [];
9617 var timedEvents = [];
9618 var daySegs = [];
9619 var timedSegs;
9620 var i;
9621
9622 // separate the events into all-day and timed
9623 for (i = 0; i < events.length; i++) {
9624 if (events[i].allDay) {
9625 dayEvents.push(events[i]);
9626 }
9627 else {
9628 timedEvents.push(events[i]);
9629 }
9630 }
9631
9632 // render the events in the subcomponents
9633 timedSegs = this.timeGrid.renderEvents(timedEvents);
9634 if (this.dayGrid) {
9635 daySegs = this.dayGrid.renderEvents(dayEvents);
9636 }
9637
9638 // the all-day area is flexible and might have a lot of events, so shift the height
9639 this.updateHeight();
9640 },
9641
9642
9643 // Retrieves all segment objects that are rendered in the view
9644 getEventSegs: function() {
9645 return this.timeGrid.getEventSegs().concat(
9646 this.dayGrid ? this.dayGrid.getEventSegs() : []
9647 );
9648 },
9649
9650
9651 // Unrenders all event elements and clears internal segment data
9652 destroyEvents: function() {
9653
9654 // if destroyEvents is being called as part of an event rerender, renderEvents will be called shortly
9655 // after, so remember what the scroll value was so we can restore it.
9656 this.recordScroll();
9657
9658 // destroy the events in the subcomponents
9659 this.timeGrid.destroyEvents();
9660 if (this.dayGrid) {
9661 this.dayGrid.destroyEvents();
9662 }
9663
9664 // we DON'T need to call updateHeight() because:
9665 // A) a renderEvents() call always happens after this, which will eventually call updateHeight()
9666 // B) in IE8, this causes a flash whenever events are rerendered
9667 },
9668
9669
9670 /* Dragging (for events and external elements)
9671 ------------------------------------------------------------------------------------------------------------------*/
9672
9673
9674 // A returned value of `true` signals that a mock "helper" event has been rendered.
9675 renderDrag: function(dropLocation, seg) {
9676 if (dropLocation.start.hasTime()) {
9677 return this.timeGrid.renderDrag(dropLocation, seg);
9678 }
9679 else if (this.dayGrid) {
9680 return this.dayGrid.renderDrag(dropLocation, seg);
9681 }
9682 },
9683
9684
9685 destroyDrag: function() {
9686 this.timeGrid.destroyDrag();
9687 if (this.dayGrid) {
9688 this.dayGrid.destroyDrag();
9689 }
9690 },
9691
9692
9693 /* Selection
9694 ------------------------------------------------------------------------------------------------------------------*/
9695
9696
9697 // Renders a visual indication of a selection
9698 renderSelection: function(range) {
9699 if (range.start.hasTime() || range.end.hasTime()) {
9700 this.timeGrid.renderSelection(range);
9701 }
9702 else if (this.dayGrid) {
9703 this.dayGrid.renderSelection(range);
9704 }
9705 },
9706
9707
9708 // Unrenders a visual indications of a selection
9709 destroySelection: function() {
9710 this.timeGrid.destroySelection();
9711 if (this.dayGrid) {
9712 this.dayGrid.destroySelection();
9713 }
9714 }
9715
9716});
9717
9718 /* A week view with an all-day cell area at the top, and a time grid below
9719----------------------------------------------------------------------------------------------------------------------*/
9720
9721fcViews.agendaWeek = {
9722 type: 'agenda',
9723 duration: { weeks: 1 }
9724};
9725 /* A day view with an all-day cell area at the top, and a time grid below
9726----------------------------------------------------------------------------------------------------------------------*/
9727
9728fcViews.agendaDay = {
9729 type: 'agenda',
9730 duration: { days: 1 }
9731};
9732});