3 * Docs & License: http://fullcalendar.io/
8 if (typeof define === 'function' && define.amd) {
9 define([ 'jquery', 'moment' ], factory);
11 else if (typeof exports === 'object') { // Node/CommonJS
12 module.exports = factory(require('jquery'), require('moment'));
15 factory(jQuery, moment);
17 })(function($, moment) {
21 var FC = $.fullCalendar = {
25 var fcViews = FC.views = {};
28 $.fn.fullCalendar = function(options) {
29 var args = Array.prototype.slice.call(arguments, 1); // for a possible method call
30 var res = this; // what this function will return (this jQuery object by default)
32 this.each(function(i, _element) { // loop each DOM element involved
33 var element = $(_element);
34 var calendar = element.data('fullCalendar'); // get the existing calendar object (if any)
35 var singleRes; // the returned value of this single method call
38 if (typeof options === 'string') {
39 if (calendar && $.isFunction(calendar[options])) {
40 singleRes = calendar[options].apply(calendar, args);
42 res = singleRes; // record the first method call result
44 if (options === 'destroy') { // for the destroy method, must remove Calendar object data
45 element.removeData('fullCalendar');
49 // a new calendar initialization
50 else if (!calendar) { // don't initialize twice
51 calendar = new Calendar(element, options);
52 element.data('fullCalendar', calendar);
61 var complexOptions = [ // names of options that are objects whose properties should be combined
69 // Merges an array of option objects into a single object
70 function mergeOptions(optionObjs) {
71 return mergeProps(optionObjs, complexOptions);
77 FC.intersectRanges = intersectRanges;
78 FC.applyAll = applyAll;
79 FC.debounce = debounce;
81 FC.htmlEscape = htmlEscape;
82 FC.cssToStr = cssToStr;
84 FC.capitaliseFirstLetter = capitaliseFirstLetter;
87 /* FullCalendar-specific DOM Utilities
88 ----------------------------------------------------------------------------------------------------------------------*/
91 // Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left
92 // and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that.
93 function compensateScroll(rowEls, scrollbarWidths) {
94 if (scrollbarWidths.left) {
96 'border-left-width': 1,
97 'margin-left': scrollbarWidths.left - 1
100 if (scrollbarWidths.right) {
102 'border-right-width': 1,
103 'margin-right': scrollbarWidths.right - 1
109 // Undoes compensateScroll and restores all borders/margins
110 function uncompensateScroll(rowEls) {
114 'border-left-width': '',
115 'border-right-width': ''
120 // Make the mouse cursor express that an event is not allowed in the current area
121 function disableCursor() {
122 $('body').addClass('fc-not-allowed');
126 // Returns the mouse cursor to its original look
127 function enableCursor() {
128 $('body').removeClass('fc-not-allowed');
132 // Given a total available height to fill, have `els` (essentially child rows) expand to accomodate.
133 // By default, all elements that are shorter than the recommended height are expanded uniformly, not considering
134 // any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and
135 // reduces the available height.
136 function distributeHeight(els, availableHeight, shouldRedistribute) {
138 // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions,
139 // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars.
141 var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element
142 var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE*
143 var flexEls = []; // elements that are allowed to expand. array of DOM nodes
144 var flexOffsets = []; // amount of vertical space it takes up
145 var flexHeights = []; // actual css height
148 undistributeHeight(els); // give all elements their natural height
150 // find elements that are below the recommended height (expandable).
151 // important to query for heights in a single first pass (to avoid reflow oscillation).
152 els.each(function(i, el) {
153 var minOffset = i === els.length - 1 ? minOffset2 : minOffset1;
154 var naturalOffset = $(el).outerHeight(true);
156 if (naturalOffset < minOffset) {
158 flexOffsets.push(naturalOffset);
159 flexHeights.push($(el).height());
162 // this element stretches past recommended height (non-expandable). mark the space as occupied.
163 usedHeight += naturalOffset;
167 // readjust the recommended height to only consider the height available to non-maxed-out rows.
168 if (shouldRedistribute) {
169 availableHeight -= usedHeight;
170 minOffset1 = Math.floor(availableHeight / flexEls.length);
171 minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE*
174 // assign heights to all expandable elements
175 $(flexEls).each(function(i, el) {
176 var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1;
177 var naturalOffset = flexOffsets[i];
178 var naturalHeight = flexHeights[i];
179 var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding
181 if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things
182 $(el).height(newHeight);
188 // Undoes distrubuteHeight, restoring all els to their natural height
189 function undistributeHeight(els) {
194 // Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the
195 // cells to be that width.
196 // PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline
197 function matchCellWidths(els) {
198 var maxInnerWidth = 0;
200 els.find('> *').each(function(i, innerEl) {
201 var innerWidth = $(innerEl).outerWidth();
202 if (innerWidth > maxInnerWidth) {
203 maxInnerWidth = innerWidth;
207 maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance
209 els.width(maxInnerWidth);
211 return maxInnerWidth;
215 // Given one element that resides inside another,
216 // Subtracts the height of the inner element from the outer element.
217 function subtractInnerElHeight(outerEl, innerEl) {
218 var both = outerEl.add(innerEl);
221 // effin' IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked
223 position: 'relative', // cause a reflow, which will force fresh dimension recalculation
224 left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll
226 diff = outerEl.outerHeight() - innerEl.outerHeight(); // grab the dimensions
227 both.css({ position: '', left: '' }); // undo hack
233 /* Element Geom Utilities
234 ----------------------------------------------------------------------------------------------------------------------*/
236 FC.getOuterRect = getOuterRect;
237 FC.getClientRect = getClientRect;
238 FC.getContentRect = getContentRect;
239 FC.getScrollbarWidths = getScrollbarWidths;
242 // borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51
243 function getScrollParent(el) {
244 var position = el.css('position'),
245 scrollParent = el.parents().filter(function() {
246 var parent = $(this);
247 return (/(auto|scroll)/).test(
248 parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x')
252 return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent;
256 // Queries the outer bounding area of a jQuery element.
257 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
258 // Origin is optional.
259 function getOuterRect(el, origin) {
260 var offset = el.offset();
261 var left = offset.left - (origin ? origin.left : 0);
262 var top = offset.top - (origin ? origin.top : 0);
266 right: left + el.outerWidth(),
268 bottom: top + el.outerHeight()
273 // Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding.
274 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
275 // Origin is optional.
276 // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser.
277 function getClientRect(el, origin) {
278 var offset = el.offset();
279 var scrollbarWidths = getScrollbarWidths(el);
280 var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left - (origin ? origin.left : 0);
281 var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top - (origin ? origin.top : 0);
285 right: left + el[0].clientWidth, // clientWidth includes padding but NOT scrollbars
287 bottom: top + el[0].clientHeight // clientHeight includes padding but NOT scrollbars
292 // Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars.
293 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
294 // Origin is optional.
295 function getContentRect(el, origin) {
296 var offset = el.offset(); // just outside of border, margin not included
297 var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left') -
298 (origin ? origin.left : 0);
299 var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top') -
300 (origin ? origin.top : 0);
304 right: left + el.width(),
306 bottom: top + el.height()
311 // Returns the computed left/right/top/bottom scrollbar widths for the given jQuery element.
312 // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser.
313 function getScrollbarWidths(el) {
314 var leftRightWidth = el.innerWidth() - el[0].clientWidth; // the paddings cancel out, leaving the scrollbars
319 bottom: el.innerHeight() - el[0].clientHeight // the paddings cancel out, leaving the bottom scrollbar
322 if (getIsLeftRtlScrollbars() && el.css('direction') == 'rtl') { // is the scrollbar on the left side?
323 widths.left = leftRightWidth;
326 widths.right = leftRightWidth;
333 // Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side
335 var _isLeftRtlScrollbars = null;
337 function getIsLeftRtlScrollbars() { // responsible for caching the computation
338 if (_isLeftRtlScrollbars === null) {
339 _isLeftRtlScrollbars = computeIsLeftRtlScrollbars();
341 return _isLeftRtlScrollbars;
344 function computeIsLeftRtlScrollbars() { // creates an offscreen test element, then removes it
345 var el = $('<div><div/></div>')
347 position: 'absolute',
356 var innerEl = el.children();
357 var res = innerEl.offset().left > el.offset().left; // is the inner div shifted to accommodate a left scrollbar?
363 // Retrieves a jQuery element's computed CSS value as a floating-point number.
364 // If the queried value is non-numeric (ex: IE can return "medium" for border width), will just return zero.
365 function getCssFloat(el, prop) {
366 return parseFloat(el.css(prop)) || 0;
370 /* Mouse / Touch Utilities
371 ----------------------------------------------------------------------------------------------------------------------*/
373 FC.preventDefault = preventDefault;
376 // Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac)
377 function isPrimaryMouseButton(ev) {
378 return ev.which == 1 && !ev.ctrlKey;
382 function getEvX(ev) {
383 if (ev.pageX !== undefined) {
386 var touches = ev.originalEvent.touches;
388 return touches[0].pageX;
393 function getEvY(ev) {
394 if (ev.pageY !== undefined) {
397 var touches = ev.originalEvent.touches;
399 return touches[0].pageY;
404 function getEvIsTouch(ev) {
405 return /^touch/.test(ev.type);
409 function preventSelection(el) {
410 el.addClass('fc-unselectable')
411 .on('selectstart', preventDefault);
415 // Stops a mouse/touch event from doing it's native browser action
416 function preventDefault(ev) {
421 // attach a handler to get called when ANY scroll action happens on the page.
422 // this was impossible to do with normal on/off because 'scroll' doesn't bubble.
423 // http://stackoverflow.com/a/32954565/96342
424 // returns `true` on success.
425 function bindAnyScroll(handler) {
426 if (window.addEventListener) {
427 window.addEventListener('scroll', handler, true); // useCapture=true
434 // undoes bindAnyScroll. must pass in the original function.
435 // returns `true` on success.
436 function unbindAnyScroll(handler) {
437 if (window.removeEventListener) {
438 window.removeEventListener('scroll', handler, true); // useCapture=true
445 /* General Geometry Utils
446 ----------------------------------------------------------------------------------------------------------------------*/
448 FC.intersectRects = intersectRects;
450 // Returns a new rectangle that is the intersection of the two rectangles. If they don't intersect, returns false
451 function intersectRects(rect1, rect2) {
453 left: Math.max(rect1.left, rect2.left),
454 right: Math.min(rect1.right, rect2.right),
455 top: Math.max(rect1.top, rect2.top),
456 bottom: Math.min(rect1.bottom, rect2.bottom)
459 if (res.left < res.right && res.top < res.bottom) {
466 // Returns a new point that will have been moved to reside within the given rectangle
467 function constrainPoint(point, rect) {
469 left: Math.min(Math.max(point.left, rect.left), rect.right),
470 top: Math.min(Math.max(point.top, rect.top), rect.bottom)
475 // Returns a point that is the center of the given rectangle
476 function getRectCenter(rect) {
478 left: (rect.left + rect.right) / 2,
479 top: (rect.top + rect.bottom) / 2
484 // Subtracts point2's coordinates from point1's coordinates, returning a delta
485 function diffPoints(point1, point2) {
487 left: point1.left - point2.left,
488 top: point1.top - point2.top
493 /* Object Ordering by Field
494 ----------------------------------------------------------------------------------------------------------------------*/
496 FC.parseFieldSpecs = parseFieldSpecs;
497 FC.compareByFieldSpecs = compareByFieldSpecs;
498 FC.compareByFieldSpec = compareByFieldSpec;
499 FC.flexibleCompare = flexibleCompare;
502 function parseFieldSpecs(input) {
507 if (typeof input === 'string') {
508 tokens = input.split(/\s*,\s*/);
510 else if (typeof input === 'function') {
513 else if ($.isArray(input)) {
517 for (i = 0; i < tokens.length; i++) {
520 if (typeof token === 'string') {
522 token.charAt(0) == '-' ?
523 { field: token.substring(1), order: -1 } :
524 { field: token, order: 1 }
527 else if (typeof token === 'function') {
528 specs.push({ func: token });
536 function compareByFieldSpecs(obj1, obj2, fieldSpecs) {
540 for (i = 0; i < fieldSpecs.length; i++) {
541 cmp = compareByFieldSpec(obj1, obj2, fieldSpecs[i]);
551 function compareByFieldSpec(obj1, obj2, fieldSpec) {
552 if (fieldSpec.func) {
553 return fieldSpec.func(obj1, obj2);
555 return flexibleCompare(obj1[fieldSpec.field], obj2[fieldSpec.field]) *
556 (fieldSpec.order || 1);
560 function flexibleCompare(a, b) {
570 if ($.type(a) === 'string' || $.type(b) === 'string') {
571 return String(a).localeCompare(String(b));
577 /* FullCalendar-specific Misc Utilities
578 ----------------------------------------------------------------------------------------------------------------------*/
581 // Computes the intersection of the two ranges. Will return fresh date clones in a range.
582 // Returns undefined if no intersection.
583 // Expects all dates to be normalized to the same timezone beforehand.
584 // TODO: move to date section?
585 function intersectRanges(subjectRange, constraintRange) {
586 var subjectStart = subjectRange.start;
587 var subjectEnd = subjectRange.end;
588 var constraintStart = constraintRange.start;
589 var constraintEnd = constraintRange.end;
590 var segStart, segEnd;
593 if (subjectEnd > constraintStart && subjectStart < constraintEnd) { // in bounds at all?
595 if (subjectStart >= constraintStart) {
596 segStart = subjectStart.clone();
600 segStart = constraintStart.clone();
604 if (subjectEnd <= constraintEnd) {
605 segEnd = subjectEnd.clone();
609 segEnd = constraintEnd.clone();
624 ----------------------------------------------------------------------------------------------------------------------*/
626 FC.computeIntervalUnit = computeIntervalUnit;
627 FC.divideRangeByDuration = divideRangeByDuration;
628 FC.divideDurationByDuration = divideDurationByDuration;
629 FC.multiplyDuration = multiplyDuration;
630 FC.durationHasTime = durationHasTime;
632 var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ];
633 var intervalUnits = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ];
636 // Diffs the two moments into a Duration where full-days are recorded first, then the remaining time.
637 // Moments will have their timezones normalized.
638 function diffDayTime(a, b) {
639 return moment.duration({
640 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'),
641 ms: a.time() - b.time() // time-of-day from day start. disregards timezone
646 // Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations.
647 function diffDay(a, b) {
648 return moment.duration({
649 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days')
654 // Diffs two moments, producing a duration, made of a whole-unit-increment of the given unit. Uses rounding.
655 function diffByUnit(a, b, unit) {
656 return moment.duration(
657 Math.round(a.diff(b, unit, true)), // returnFloat=true
663 // Computes the unit name of the largest whole-unit period of time.
664 // For example, 48 hours will be "days" whereas 49 hours will be "hours".
665 // Accepts start/end, a range object, or an original duration object.
666 function computeIntervalUnit(start, end) {
670 for (i = 0; i < intervalUnits.length; i++) {
671 unit = intervalUnits[i];
672 val = computeRangeAs(unit, start, end);
674 if (val >= 1 && isInt(val)) {
679 return unit; // will be "milliseconds" if nothing else matches
683 // Computes the number of units (like "hours") in the given range.
684 // Range can be a {start,end} object, separate start/end args, or a Duration.
685 // Results are based on Moment's .as() and .diff() methods, so results can depend on internal handling
686 // of month-diffing logic (which tends to vary from version to version).
687 function computeRangeAs(unit, start, end) {
689 if (end != null) { // given start, end
690 return end.diff(start, unit, true);
692 else if (moment.isDuration(start)) { // given duration
693 return start.as(unit);
695 else { // given { start, end } range object
696 return start.end.diff(start.start, unit, true);
701 // Intelligently divides a range (specified by a start/end params) by a duration
702 function divideRangeByDuration(start, end, dur) {
705 if (durationHasTime(dur)) {
706 return (end - start) / dur;
708 months = dur.asMonths();
709 if (Math.abs(months) >= 1 && isInt(months)) {
710 return end.diff(start, 'months', true) / months;
712 return end.diff(start, 'days', true) / dur.asDays();
716 // Intelligently divides one duration by another
717 function divideDurationByDuration(dur1, dur2) {
718 var months1, months2;
720 if (durationHasTime(dur1) || durationHasTime(dur2)) {
723 months1 = dur1.asMonths();
724 months2 = dur2.asMonths();
726 Math.abs(months1) >= 1 && isInt(months1) &&
727 Math.abs(months2) >= 1 && isInt(months2)
729 return months1 / months2;
731 return dur1.asDays() / dur2.asDays();
735 // Intelligently multiplies a duration by a number
736 function multiplyDuration(dur, n) {
739 if (durationHasTime(dur)) {
740 return moment.duration(dur * n);
742 months = dur.asMonths();
743 if (Math.abs(months) >= 1 && isInt(months)) {
744 return moment.duration({ months: months * n });
746 return moment.duration({ days: dur.asDays() * n });
750 // Returns a boolean about whether the given duration has any time parts (hours/minutes/seconds/ms)
751 function durationHasTime(dur) {
752 return Boolean(dur.hours() || dur.minutes() || dur.seconds() || dur.milliseconds());
756 function isNativeDate(input) {
757 return Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date;
761 // Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00"
762 function isTimeString(str) {
763 return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str);
768 ----------------------------------------------------------------------------------------------------------------------*/
770 FC.log = function() {
771 var console = window.console;
773 if (console && console.log) {
774 return console.log.apply(console, arguments);
778 FC.warn = function() {
779 var console = window.console;
781 if (console && console.warn) {
782 return console.warn.apply(console, arguments);
785 return FC.log.apply(FC, arguments);
791 ----------------------------------------------------------------------------------------------------------------------*/
793 var hasOwnPropMethod = {}.hasOwnProperty;
796 // Merges an array of objects into a single object.
797 // The second argument allows for an array of property names who's object values will be merged together.
798 function mergeProps(propObjs, complexProps) {
806 for (i = 0; i < complexProps.length; i++) {
807 name = complexProps[i];
810 // collect the trailing object values, stopping when a non-object is discovered
811 for (j = propObjs.length - 1; j >= 0; j--) {
812 val = propObjs[j][name];
814 if (typeof val === 'object') {
815 complexObjs.unshift(val);
817 else if (val !== undefined) {
818 dest[name] = val; // if there were no objects, this value will be used
823 // if the trailing values were objects, use the merged value
824 if (complexObjs.length) {
825 dest[name] = mergeProps(complexObjs);
830 // copy values into the destination, going from last to first
831 for (i = propObjs.length - 1; i >= 0; i--) {
834 for (name in props) {
835 if (!(name in dest)) { // if already assigned by previous props or complex props, don't reassign
836 dest[name] = props[name];
845 // Create an object that has the given prototype. Just like Object.create
846 function createObject(proto) {
847 var f = function() {};
853 function copyOwnProps(src, dest) {
854 for (var name in src) {
855 if (hasOwnProp(src, name)) {
856 dest[name] = src[name];
862 function hasOwnProp(obj, name) {
863 return hasOwnPropMethod.call(obj, name);
867 // Is the given value a non-object non-function value?
868 function isAtomic(val) {
869 return /undefined|null|boolean|number|string/.test($.type(val));
873 function applyAll(functions, thisObj, args) {
874 if ($.isFunction(functions)) {
875 functions = [ functions ];
880 for (i=0; i<functions.length; i++) {
881 ret = functions[i].apply(thisObj, args) || ret;
888 function firstDefined() {
889 for (var i=0; i<arguments.length; i++) {
890 if (arguments[i] !== undefined) {
897 function htmlEscape(s) {
898 return (s + '').replace(/&/g, '&')
899 .replace(/</g, '<')
900 .replace(/>/g, '>')
901 .replace(/'/g, ''')
902 .replace(/"/g, '"')
903 .replace(/\n/g, '<br />');
907 function stripHtmlEntities(text) {
908 return text.replace(/&.*?;/g, '');
912 // Given a hash of CSS properties, returns a string of CSS.
913 // Uses property names as-is (no camel-case conversion). Will not make statements for null/undefined values.
914 function cssToStr(cssProps) {
917 $.each(cssProps, function(name, val) {
919 statements.push(name + ':' + val);
923 return statements.join(';');
927 // Given an object hash of HTML attribute names to values,
928 // generates a string that can be injected between < > in HTML
929 function attrsToStr(attrs) {
932 $.each(attrs, function(name, val) {
934 parts.push(name + '="' + htmlEscape(val) + '"');
938 return parts.join(' ');
942 function capitaliseFirstLetter(str) {
943 return str.charAt(0).toUpperCase() + str.slice(1);
947 function compareNumbers(a, b) { // for .sort()
957 // Returns a method bound to the given object context.
958 // Just like one of the jQuery.proxy signatures, but without the undesired behavior of treating the same method with
959 // different contexts as identical when binding/unbinding events.
960 function proxy(obj, methodName) {
961 var method = obj[methodName];
964 return method.apply(obj, arguments);
969 // Returns a function, that, as long as it continues to be invoked, will not
970 // be triggered. The function will be called after it stops being called for
971 // N milliseconds. If `immediate` is passed, trigger the function on the
972 // leading edge, instead of the trailing.
973 // https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714
974 function debounce(func, wait, immediate) {
975 var timeout, args, context, timestamp, result;
977 var later = function() {
978 var last = +new Date() - timestamp;
980 timeout = setTimeout(later, wait - last);
985 result = func.apply(context, args);
986 context = args = null;
994 timestamp = +new Date();
995 var callNow = immediate && !timeout;
997 timeout = setTimeout(later, wait);
1000 result = func.apply(context, args);
1001 context = args = null;
1008 // HACK around jQuery's now A+ promises: execute callback synchronously if already resolved.
1009 // thenFunc shouldn't accept args.
1010 // similar to whenResources in Scheduler plugin.
1011 function syncThen(promise, thenFunc) {
1012 // not a promise, or an already-resolved promise?
1013 if (!promise || !promise.then || promise.state() === 'resolved') {
1014 return $.when(thenFunc()); // resolve immediately
1016 else if (thenFunc) {
1017 return promise.then(thenFunc);
1024 GENERAL NOTE on moments throughout the *entire rest* of the codebase:
1025 All moments are assumed to be ambiguously-zoned unless otherwise noted,
1026 with the NOTABLE EXCEOPTION of start/end dates that live on *Event Objects*.
1027 Ambiguously-TIMED moments are assumed to be ambiguously-zoned by nature.
1030 var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/;
1031 var ambigTimeOrZoneRegex =
1032 /^\s*\d{4}-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?)?$/;
1033 var newMomentProto = moment.fn; // where we will attach our new methods
1034 var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods
1036 // tell momentjs to transfer these properties upon clone
1037 var momentProperties = moment.momentProperties;
1038 momentProperties.push('_fullCalendar');
1039 momentProperties.push('_ambigTime');
1040 momentProperties.push('_ambigZone');
1044 // -------------------------------------------------------------------------------------------------
1046 // Creates a new moment, similar to the vanilla moment(...) constructor, but with
1047 // extra features (ambiguous time, enhanced formatting). When given an existing moment,
1048 // it will function as a clone (and retain the zone of the moment). Anything else will
1049 // result in a moment in the local zone.
1050 FC.moment = function() {
1051 return makeMoment(arguments);
1054 // Sames as FC.moment, but forces the resulting moment to be in the UTC timezone.
1055 FC.moment.utc = function() {
1056 var mom = makeMoment(arguments, true);
1058 // Force it into UTC because makeMoment doesn't guarantee it
1059 // (if given a pre-existing moment for example)
1060 if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone
1067 // Same as FC.moment, but when given an ISO8601 string, the timezone offset is preserved.
1068 // ISO8601 strings with no timezone offset will become ambiguously zoned.
1069 FC.moment.parseZone = function() {
1070 return makeMoment(arguments, true, true);
1073 // Builds an enhanced moment from args. When given an existing moment, it clones. When given a
1074 // native Date, or called with no arguments (the current time), the resulting moment will be local.
1075 // Anything else needs to be "parsed" (a string or an array), and will be affected by:
1076 // parseAsUTC - if there is no zone information, should we parse the input in UTC?
1077 // parseZone - if there is zone information, should we force the zone of the moment?
1078 function makeMoment(args, parseAsUTC, parseZone) {
1079 var input = args[0];
1080 var isSingleString = args.length == 1 && typeof input === 'string';
1086 if (moment.isMoment(input) || isNativeDate(input) || input === undefined) {
1087 mom = moment.apply(null, args);
1089 else { // "parsing" is required
1090 isAmbigTime = false;
1091 isAmbigZone = false;
1093 if (isSingleString) {
1094 if (ambigDateOfMonthRegex.test(input)) {
1095 // accept strings like '2014-05', but convert to the first of the month
1097 args = [ input ]; // for when we pass it on to moment's constructor
1101 else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) {
1102 isAmbigTime = !ambigMatch[5]; // no time part?
1106 else if ($.isArray(input)) {
1107 // arrays have no timezone information, so assume ambiguous zone
1110 // otherwise, probably a string with a format
1112 if (parseAsUTC || isAmbigTime) {
1113 mom = moment.utc.apply(moment, args);
1116 mom = moment.apply(null, args);
1120 mom._ambigTime = true;
1121 mom._ambigZone = true; // ambiguous time always means ambiguous zone
1123 else if (parseZone) { // let's record the inputted zone somehow
1125 mom._ambigZone = true;
1127 else if (isSingleString) {
1128 mom.utcOffset(input); // if not a valid zone, will assign UTC
1133 mom._fullCalendar = true; // flag for extended functionality
1140 // -------------------------------------------------------------------------------------------------
1143 // Returns the week number, considering the locale's custom week number calcuation
1144 // `weeks` is an alias for `week`
1145 newMomentProto.week = newMomentProto.weeks = function(input) {
1146 var weekCalc = this._locale._fullCalendar_weekCalc;
1148 if (input == null && typeof weekCalc === 'function') { // custom function only works for getter
1149 return weekCalc(this);
1151 else if (weekCalc === 'ISO') {
1152 return oldMomentProto.isoWeek.apply(this, arguments); // ISO getter/setter
1155 return oldMomentProto.week.apply(this, arguments); // local getter/setter
1160 // -------------------------------------------------------------------------------------------------
1163 // Returns a Duration with the hours/minutes/seconds/ms values of the moment.
1164 // If the moment has an ambiguous time, a duration of 00:00 will be returned.
1167 // You can supply a Duration, a Moment, or a Duration-like argument.
1168 // When setting the time, and the moment has an ambiguous time, it then becomes unambiguous.
1169 newMomentProto.time = function(time) {
1171 // Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar.
1172 // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins.
1173 if (!this._fullCalendar) {
1174 return oldMomentProto.time.apply(this, arguments);
1177 if (time == null) { // getter
1178 return moment.duration({
1179 hours: this.hours(),
1180 minutes: this.minutes(),
1181 seconds: this.seconds(),
1182 milliseconds: this.milliseconds()
1187 this._ambigTime = false; // mark that the moment now has a time
1189 if (!moment.isDuration(time) && !moment.isMoment(time)) {
1190 time = moment.duration(time);
1193 // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day).
1194 // Only for Duration times, not Moment times.
1196 if (moment.isDuration(time)) {
1197 dayHours = Math.floor(time.asDays()) * 24;
1200 // We need to set the individual fields.
1201 // Can't use startOf('day') then add duration. In case of DST at start of day.
1202 return this.hours(dayHours + time.hours())
1203 .minutes(time.minutes())
1204 .seconds(time.seconds())
1205 .milliseconds(time.milliseconds());
1209 // Converts the moment to UTC, stripping out its time-of-day and timezone offset,
1210 // but preserving its YMD. A moment with a stripped time will display no time
1211 // nor timezone offset when .format() is called.
1212 newMomentProto.stripTime = function() {
1214 if (!this._ambigTime) {
1216 this.utc(true); // keepLocalTime=true (for keeping *date* value)
1226 // Mark the time as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(),
1227 // which clears all ambig flags.
1228 this._ambigTime = true;
1229 this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset
1232 return this; // for chaining
1235 // Returns if the moment has a non-ambiguous time (boolean)
1236 newMomentProto.hasTime = function() {
1237 return !this._ambigTime;
1242 // -------------------------------------------------------------------------------------------------
1244 // Converts the moment to UTC, stripping out its timezone offset, but preserving its
1245 // YMD and time-of-day. A moment with a stripped timezone offset will display no
1246 // timezone offset when .format() is called.
1247 newMomentProto.stripZone = function() {
1250 if (!this._ambigZone) {
1252 wasAmbigTime = this._ambigTime;
1254 this.utc(true); // keepLocalTime=true (for keeping date and time values)
1256 // the above call to .utc()/.utcOffset() unfortunately might clear the ambig flags, so restore
1257 this._ambigTime = wasAmbigTime || false;
1259 // Mark the zone as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(),
1260 // which clears the ambig flags.
1261 this._ambigZone = true;
1264 return this; // for chaining
1267 // Returns of the moment has a non-ambiguous timezone offset (boolean)
1268 newMomentProto.hasZone = function() {
1269 return !this._ambigZone;
1273 // implicitly marks a zone
1274 newMomentProto.local = function(keepLocalTime) {
1276 // for when converting from ambiguously-zoned to local,
1277 // keep the time values when converting from UTC -> local
1278 oldMomentProto.local.call(this, this._ambigZone || keepLocalTime);
1280 // ensure non-ambiguous
1281 // this probably already happened via local() -> utcOffset(), but don't rely on Moment's internals
1282 this._ambigTime = false;
1283 this._ambigZone = false;
1285 return this; // for chaining
1289 // implicitly marks a zone
1290 newMomentProto.utc = function(keepLocalTime) {
1292 oldMomentProto.utc.call(this, keepLocalTime);
1294 // ensure non-ambiguous
1295 // this probably already happened via utc() -> utcOffset(), but don't rely on Moment's internals
1296 this._ambigTime = false;
1297 this._ambigZone = false;
1303 // implicitly marks a zone (will probably get called upon .utc() and .local())
1304 newMomentProto.utcOffset = function(tzo) {
1306 if (tzo != null) { // setter
1307 // these assignments needs to happen before the original zone method is called.
1308 // I forget why, something to do with a browser crash.
1309 this._ambigTime = false;
1310 this._ambigZone = false;
1313 return oldMomentProto.utcOffset.apply(this, arguments);
1318 // -------------------------------------------------------------------------------------------------
1320 newMomentProto.format = function() {
1321 if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided?
1322 return formatDate(this, arguments[0]); // our extended formatting
1324 if (this._ambigTime) {
1325 return oldMomentFormat(this, 'YYYY-MM-DD');
1327 if (this._ambigZone) {
1328 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss');
1330 return oldMomentProto.format.apply(this, arguments);
1333 newMomentProto.toISOString = function() {
1334 if (this._ambigTime) {
1335 return oldMomentFormat(this, 'YYYY-MM-DD');
1337 if (this._ambigZone) {
1338 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss');
1340 return oldMomentProto.toISOString.apply(this, arguments);
1345 // Single Date Formatting
1346 // -------------------------------------------------------------------------------------------------
1349 // call this if you want Moment's original format method to be used
1350 function oldMomentFormat(mom, formatStr) {
1351 return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js
1355 // Formats `date` with a Moment formatting string, but allow our non-zero areas and
1356 // additional token.
1357 function formatDate(date, formatStr) {
1358 return formatDateWithChunks(date, getFormatStringChunks(formatStr));
1362 function formatDateWithChunks(date, chunks) {
1366 for (i=0; i<chunks.length; i++) {
1367 s += formatDateWithChunk(date, chunks[i]);
1374 // addition formatting tokens we want recognized
1375 var tokenOverrides = {
1376 t: function(date) { // "a" or "p"
1377 return oldMomentFormat(date, 'a').charAt(0);
1379 T: function(date) { // "A" or "P"
1380 return oldMomentFormat(date, 'A').charAt(0);
1385 function formatDateWithChunk(date, chunk) {
1389 if (typeof chunk === 'string') { // a literal string
1392 else if ((token = chunk.token)) { // a token, like "YYYY"
1393 if (tokenOverrides[token]) {
1394 return tokenOverrides[token](date); // use our custom token
1396 return oldMomentFormat(date, token);
1398 else if (chunk.maybe) { // a grouping of other chunks that must be non-zero
1399 maybeStr = formatDateWithChunks(date, chunk.maybe);
1400 if (maybeStr.match(/[1-9]/)) {
1409 // Date Range Formatting
1410 // -------------------------------------------------------------------------------------------------
1411 // TODO: make it work with timezone offset
1413 // Using a formatting string meant for a single date, generate a range string, like
1414 // "Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ.
1415 // If the dates are the same as far as the format string is concerned, just return a single
1416 // rendering of one date, without any separator.
1417 function formatRange(date1, date2, formatStr, separator, isRTL) {
1420 date1 = FC.moment.parseZone(date1);
1421 date2 = FC.moment.parseZone(date2);
1423 localeData = date1.localeData();
1425 // Expand localized format strings, like "LL" -> "MMMM D YYYY"
1426 formatStr = localeData.longDateFormat(formatStr) || formatStr;
1427 // BTW, this is not important for `formatDate` because it is impossible to put custom tokens
1428 // or non-zero areas in Moment's localized format strings.
1430 separator = separator || ' - ';
1432 return formatRangeWithChunks(
1435 getFormatStringChunks(formatStr),
1440 FC.formatRange = formatRange; // expose
1443 function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) {
1444 var unzonedDate1 = date1.clone().stripZone(); // for formatSimilarChunk
1445 var unzonedDate2 = date2.clone().stripZone(); // "
1446 var chunkStr; // the rendering of the chunk
1452 var middleStr1 = '';
1453 var middleStr2 = '';
1456 // Start at the leftmost side of the formatting string and continue until you hit a token
1457 // that is not the same between dates.
1458 for (leftI=0; leftI<chunks.length; leftI++) {
1459 chunkStr = formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunks[leftI]);
1460 if (chunkStr === false) {
1463 leftStr += chunkStr;
1466 // Similarly, start at the rightmost side of the formatting string and move left
1467 for (rightI=chunks.length-1; rightI>leftI; rightI--) {
1468 chunkStr = formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunks[rightI]);
1469 if (chunkStr === false) {
1472 rightStr = chunkStr + rightStr;
1475 // The area in the middle is different for both of the dates.
1476 // Collect them distinctly so we can jam them together later.
1477 for (middleI=leftI; middleI<=rightI; middleI++) {
1478 middleStr1 += formatDateWithChunk(date1, chunks[middleI]);
1479 middleStr2 += formatDateWithChunk(date2, chunks[middleI]);
1482 if (middleStr1 || middleStr2) {
1484 middleStr = middleStr2 + separator + middleStr1;
1487 middleStr = middleStr1 + separator + middleStr2;
1491 return leftStr + middleStr + rightStr;
1495 var similarUnitMap = {
1498 D: 'day', // day of month
1499 d: 'day', // day of week
1500 // prevents a separator between anything time-related...
1501 A: 'second', // AM/PM
1502 a: 'second', // am/pm
1505 H: 'second', // hour (24)
1506 h: 'second', // hour (12)
1507 m: 'second', // minute
1508 s: 'second' // second
1510 // TODO: week maybe?
1513 // Given a formatting chunk, and given that both dates are similar in the regard the
1514 // formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`.
1515 function formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunk) {
1519 if (typeof chunk === 'string') { // a literal string
1522 else if ((token = chunk.token)) {
1523 unit = similarUnitMap[token.charAt(0)];
1525 // are the dates the same for this unit of measurement?
1526 // use the unzoned dates for this calculation because unreliable when near DST (bug #2396)
1527 if (unit && unzonedDate1.isSame(unzonedDate2, unit)) {
1528 return oldMomentFormat(date1, token); // would be the same if we used `date2`
1529 // BTW, don't support custom tokens
1533 return false; // the chunk is NOT the same for the two dates
1534 // BTW, don't support splitting on non-zero areas
1539 // -------------------------------------------------------------------------------------------------
1542 var formatStringChunkCache = {};
1545 function getFormatStringChunks(formatStr) {
1546 if (formatStr in formatStringChunkCache) {
1547 return formatStringChunkCache[formatStr];
1549 return (formatStringChunkCache[formatStr] = chunkFormatString(formatStr));
1553 // Break the formatting string into an array of chunks
1554 function chunkFormatString(formatStr) {
1556 var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g; // TODO: more descrimination
1559 while ((match = chunker.exec(formatStr))) {
1560 if (match[1]) { // a literal string inside [ ... ]
1561 chunks.push(match[1]);
1563 else if (match[2]) { // non-zero formatting inside ( ... )
1564 chunks.push({ maybe: chunkFormatString(match[2]) });
1566 else if (match[3]) { // a formatting token
1567 chunks.push({ token: match[3] });
1569 else if (match[5]) { // an unenclosed literal string
1570 chunks.push(match[5]);
1579 // -------------------------------------------------------------------------------------------------
1582 // granularity only goes up until day
1583 // TODO: unify with similarUnitMap
1584 var tokenGranularities = {
1585 Y: { value: 1, unit: 'year' },
1586 M: { value: 2, unit: 'month' },
1587 W: { value: 3, unit: 'week' },
1588 w: { value: 3, unit: 'week' },
1589 D: { value: 4, unit: 'day' }, // day of month
1590 d: { value: 4, unit: 'day' } // day of week
1593 // returns a unit string, either 'year', 'month', 'day', or null
1594 // for the most granular formatting token in the string.
1595 FC.queryMostGranularFormatUnit = function(formatStr) {
1596 var chunks = getFormatStringChunks(formatStr);
1601 for (i = 0; i < chunks.length; i++) {
1604 candidate = tokenGranularities[chunk.token.charAt(0)];
1606 if (!best || candidate.value > best.value) {
1622 FC.Class = Class; // export
1624 // Class that all other classes will inherit from
1625 function Class() { }
1628 // Called on a class to create a subclass.
1629 // Last argument contains instance methods. Any argument before the last are considered mixins.
1630 Class.extend = function() {
1631 var len = arguments.length;
1635 for (i = 0; i < len; i++) {
1636 members = arguments[i];
1637 if (i < len - 1) { // not the last argument?
1638 mixIntoClass(this, members);
1642 return extendClass(this, members || {}); // members will be undefined if no arguments
1646 // Adds new member variables/methods to the class's prototype.
1647 // Can be called with another class, or a plain object hash containing new members.
1648 Class.mixin = function(members) {
1649 mixIntoClass(this, members);
1653 function extendClass(superClass, members) {
1656 // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist
1657 if (hasOwnProp(members, 'constructor')) {
1658 subClass = members.constructor;
1660 if (typeof subClass !== 'function') {
1661 subClass = members.constructor = function() {
1662 superClass.apply(this, arguments);
1666 // build the base prototype for the subclass, which is an new object chained to the superclass's prototype
1667 subClass.prototype = createObject(superClass.prototype);
1669 // copy each member variable/method onto the the subclass's prototype
1670 copyOwnProps(members, subClass.prototype);
1672 // copy over all class variables/methods to the subclass, such as `extend` and `mixin`
1673 copyOwnProps(superClass, subClass);
1679 function mixIntoClass(theClass, members) {
1680 copyOwnProps(members, theClass.prototype);
1684 var EmitterMixin = FC.EmitterMixin = {
1686 // jQuery-ification via $(this) allows a non-DOM object to have
1687 // the same event handling capabilities (including namespaces).
1690 on: function(types, handler) {
1692 // handlers are always called with an "event" object as their first param.
1693 // sneak the `this` context and arguments into the extra parameter object
1694 // and forward them on to the original handler.
1695 var intercept = function(ev, extra) {
1696 return handler.apply(
1697 extra.context || this,
1702 // mimick jQuery's internal "proxy" system (risky, I know)
1703 // causing all functions with the same .guid to appear to be the same.
1704 // https://github.com/jquery/jquery/blob/2.2.4/src/core.js#L448
1705 // this is needed for calling .off with the original non-intercept handler.
1706 if (!handler.guid) {
1707 handler.guid = $.guid++;
1709 intercept.guid = handler.guid;
1711 $(this).on(types, intercept);
1713 return this; // for chaining
1717 off: function(types, handler) {
1718 $(this).off(types, handler);
1720 return this; // for chaining
1724 trigger: function(types) {
1725 var args = Array.prototype.slice.call(arguments, 1); // arguments after the first
1727 // pass in "extra" info to the intercept
1728 $(this).triggerHandler(types, { args: args });
1730 return this; // for chaining
1734 triggerWith: function(types, context, args) {
1736 // `triggerHandler` is less reliant on the DOM compared to `trigger`.
1737 // pass in "extra" info to the intercept.
1738 $(this).triggerHandler(types, { context: context, args: args });
1740 return this; // for chaining
1748 Utility methods for easily listening to events on another object,
1749 and more importantly, easily unlistening from them.
1751 var ListenerMixin = FC.ListenerMixin = (function() {
1753 var ListenerMixin = {
1758 Given an `other` object that has on/off methods, bind the given `callback` to an event by the given name.
1759 The `callback` will be called with the `this` context of the object that .listenTo is being called on.
1761 .listenTo(other, eventName, callback)
1764 eventName1: callback1,
1765 eventName2: callback2
1768 listenTo: function(other, arg, callback) {
1769 if (typeof arg === 'object') { // given dictionary of callbacks
1770 for (var eventName in arg) {
1771 if (arg.hasOwnProperty(eventName)) {
1772 this.listenTo(other, eventName, arg[eventName]);
1776 else if (typeof arg === 'string') {
1778 arg + '.' + this.getListenerNamespace(), // use event namespacing to identify this object
1779 $.proxy(callback, this) // always use `this` context
1780 // the usually-undesired jQuery guid behavior doesn't matter,
1781 // because we always unbind via namespace
1787 Causes the current object to stop listening to events on the `other` object.
1788 `eventName` is optional. If omitted, will stop listening to ALL events on `other`.
1790 stopListeningTo: function(other, eventName) {
1791 other.off((eventName || '') + '.' + this.getListenerNamespace());
1795 Returns a string, unique to this object, to be used for event namespacing
1797 getListenerNamespace: function() {
1798 if (this.listenerId == null) {
1799 this.listenerId = guid++;
1801 return '_listener' + this.listenerId;
1805 return ListenerMixin;
1809 // simple class for toggle a `isIgnoringMouse` flag on delay
1810 // initMouseIgnoring must first be called, with a millisecond delay setting.
1811 var MouseIgnorerMixin = {
1813 isIgnoringMouse: false, // bool
1814 delayUnignoreMouse: null, // method
1817 initMouseIgnoring: function(delay) {
1818 this.delayUnignoreMouse = debounce(proxy(this, 'unignoreMouse'), delay || 1000);
1822 // temporarily ignore mouse actions on segments
1823 tempIgnoreMouse: function() {
1824 this.isIgnoringMouse = true;
1825 this.delayUnignoreMouse();
1829 // delayUnignoreMouse eventually calls this
1830 unignoreMouse: function() {
1831 this.isIgnoringMouse = false;
1838 /* A rectangular panel that is absolutely positioned over other content
1839 ------------------------------------------------------------------------------------------------------------------------
1841 - className (string)
1842 - content (HTML string or jQuery element set)
1846 - right (the x coord of where the right edge should be. not a "CSS" right)
1847 - autoHide (boolean)
1852 var Popover = Class.extend(ListenerMixin, {
1856 el: null, // the container element for the popover. generated by this object
1857 margin: 10, // the space required between the popover and the edges of the scroll container
1860 constructor: function(options) {
1861 this.options = options || {};
1865 // Shows the popover on the specified position. Renders it if not already
1867 if (this.isHidden) {
1873 this.isHidden = false;
1874 this.trigger('show');
1879 // Hides the popover, through CSS, but does not remove it from the DOM
1881 if (!this.isHidden) {
1883 this.isHidden = true;
1884 this.trigger('hide');
1889 // Creates `this.el` and renders content inside of it
1890 render: function() {
1892 var options = this.options;
1894 this.el = $('<div class="fc-popover"/>')
1895 .addClass(options.className || '')
1897 // position initially to the top left to avoid creating scrollbars
1901 .append(options.content)
1902 .appendTo(options.parentEl);
1904 // when a click happens on anything inside with a 'fc-close' className, hide the popover
1905 this.el.on('click', '.fc-close', function() {
1909 if (options.autoHide) {
1910 this.listenTo($(document), 'mousedown', this.documentMousedown);
1915 // Triggered when the user clicks *anywhere* in the document, for the autoHide feature
1916 documentMousedown: function(ev) {
1917 // only hide the popover if the click happened outside the popover
1918 if (this.el && !$(ev.target).closest(this.el).length) {
1924 // Hides and unregisters any handlers
1925 removeElement: function() {
1933 this.stopListeningTo($(document), 'mousedown');
1937 // Positions the popover optimally, using the top/left/right options
1938 position: function() {
1939 var options = this.options;
1940 var origin = this.el.offsetParent().offset();
1941 var width = this.el.outerWidth();
1942 var height = this.el.outerHeight();
1943 var windowEl = $(window);
1944 var viewportEl = getScrollParent(this.el);
1948 var top; // the "position" (not "offset") values for the popover
1951 // compute top and left
1952 top = options.top || 0;
1953 if (options.left !== undefined) {
1954 left = options.left;
1956 else if (options.right !== undefined) {
1957 left = options.right - width; // derive the left value from the right value
1963 if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result
1964 viewportEl = windowEl;
1965 viewportTop = 0; // the window is always at the top left
1966 viewportLeft = 0; // (and .offset() won't work if called here)
1969 viewportOffset = viewportEl.offset();
1970 viewportTop = viewportOffset.top;
1971 viewportLeft = viewportOffset.left;
1974 // if the window is scrolled, it causes the visible area to be further down
1975 viewportTop += windowEl.scrollTop();
1976 viewportLeft += windowEl.scrollLeft();
1978 // constrain to the view port. if constrained by two edges, give precedence to top/left
1979 if (options.viewportConstrain !== false) {
1980 top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin);
1981 top = Math.max(top, viewportTop + this.margin);
1982 left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin);
1983 left = Math.max(left, viewportLeft + this.margin);
1987 top: top - origin.top,
1988 left: left - origin.left
1993 // Triggers a callback. Calls a function in the option hash of the same name.
1994 // Arguments beyond the first `name` are forwarded on.
1995 // TODO: better code reuse for this. Repeat code
1996 trigger: function(name) {
1997 if (this.options[name]) {
1998 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
2007 A cache for the left/right/top/bottom/width/height values for one or more elements.
2008 Works with both offset (from topleft document) and position (from offsetParent).
2015 var CoordCache = FC.CoordCache = Class.extend({
2017 els: null, // jQuery set (assumed to be siblings)
2018 forcedOffsetParentEl: null, // options can override the natural offsetParent
2019 origin: null, // {left,top} position of offsetParent of els
2020 boundingRect: null, // constrain cordinates to this rectangle. {left,right,top,bottom} or null
2021 isHorizontal: false, // whether to query for left/right/width
2022 isVertical: false, // whether to query for top/bottom/height
2024 // arrays of coordinates (offsets from topleft of document)
2031 constructor: function(options) {
2032 this.els = $(options.els);
2033 this.isHorizontal = options.isHorizontal;
2034 this.isVertical = options.isVertical;
2035 this.forcedOffsetParentEl = options.offsetParent ? $(options.offsetParent) : null;
2039 // Queries the els for coordinates and stores them.
2040 // Call this method before using and of the get* methods below.
2042 var offsetParentEl = this.forcedOffsetParentEl || this.els.eq(0).offsetParent();
2044 this.origin = offsetParentEl.offset();
2045 this.boundingRect = this.queryBoundingRect();
2047 if (this.isHorizontal) {
2048 this.buildElHorizontals();
2050 if (this.isVertical) {
2051 this.buildElVerticals();
2056 // Destroys all internal data about coordinates, freeing memory
2059 this.boundingRect = null;
2063 this.bottoms = null;
2067 // When called, if coord caches aren't built, builds them
2068 ensureBuilt: function() {
2075 // Populates the left/right internal coordinate arrays
2076 buildElHorizontals: function() {
2080 this.els.each(function(i, node) {
2082 var left = el.offset().left;
2083 var width = el.outerWidth();
2086 rights.push(left + width);
2090 this.rights = rights;
2094 // Populates the top/bottom internal coordinate arrays
2095 buildElVerticals: function() {
2099 this.els.each(function(i, node) {
2101 var top = el.offset().top;
2102 var height = el.outerHeight();
2105 bottoms.push(top + height);
2109 this.bottoms = bottoms;
2113 // Given a left offset (from document left), returns the index of the el that it horizontally intersects.
2114 // If no intersection is made, returns undefined.
2115 getHorizontalIndex: function(leftOffset) {
2118 var lefts = this.lefts;
2119 var rights = this.rights;
2120 var len = lefts.length;
2123 for (i = 0; i < len; i++) {
2124 if (leftOffset >= lefts[i] && leftOffset < rights[i]) {
2131 // Given a top offset (from document top), returns the index of the el that it vertically intersects.
2132 // If no intersection is made, returns undefined.
2133 getVerticalIndex: function(topOffset) {
2136 var tops = this.tops;
2137 var bottoms = this.bottoms;
2138 var len = tops.length;
2141 for (i = 0; i < len; i++) {
2142 if (topOffset >= tops[i] && topOffset < bottoms[i]) {
2149 // Gets the left offset (from document left) of the element at the given index
2150 getLeftOffset: function(leftIndex) {
2152 return this.lefts[leftIndex];
2156 // Gets the left position (from offsetParent left) of the element at the given index
2157 getLeftPosition: function(leftIndex) {
2159 return this.lefts[leftIndex] - this.origin.left;
2163 // Gets the right offset (from document left) of the element at the given index.
2164 // This value is NOT relative to the document's right edge, like the CSS concept of "right" would be.
2165 getRightOffset: function(leftIndex) {
2167 return this.rights[leftIndex];
2171 // Gets the right position (from offsetParent left) of the element at the given index.
2172 // This value is NOT relative to the offsetParent's right edge, like the CSS concept of "right" would be.
2173 getRightPosition: function(leftIndex) {
2175 return this.rights[leftIndex] - this.origin.left;
2179 // Gets the width of the element at the given index
2180 getWidth: function(leftIndex) {
2182 return this.rights[leftIndex] - this.lefts[leftIndex];
2186 // Gets the top offset (from document top) of the element at the given index
2187 getTopOffset: function(topIndex) {
2189 return this.tops[topIndex];
2193 // Gets the top position (from offsetParent top) of the element at the given position
2194 getTopPosition: function(topIndex) {
2196 return this.tops[topIndex] - this.origin.top;
2199 // Gets the bottom offset (from the document top) of the element at the given index.
2200 // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be.
2201 getBottomOffset: function(topIndex) {
2203 return this.bottoms[topIndex];
2207 // Gets the bottom position (from the offsetParent top) of the element at the given index.
2208 // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be.
2209 getBottomPosition: function(topIndex) {
2211 return this.bottoms[topIndex] - this.origin.top;
2215 // Gets the height of the element at the given index
2216 getHeight: function(topIndex) {
2218 return this.bottoms[topIndex] - this.tops[topIndex];
2223 // TODO: decouple this from CoordCache
2225 // Compute and return what the elements' bounding rectangle is, from the user's perspective.
2226 // Right now, only returns a rectangle if constrained by an overflow:scroll element.
2227 queryBoundingRect: function() {
2228 var scrollParentEl = getScrollParent(this.els.eq(0));
2230 if (!scrollParentEl.is(document)) {
2231 return getClientRect(scrollParentEl);
2235 isPointInBounds: function(leftOffset, topOffset) {
2236 return this.isLeftInBounds(leftOffset) && this.isTopInBounds(topOffset);
2239 isLeftInBounds: function(leftOffset) {
2240 return !this.boundingRect || (leftOffset >= this.boundingRect.left && leftOffset < this.boundingRect.right);
2243 isTopInBounds: function(topOffset) {
2244 return !this.boundingRect || (topOffset >= this.boundingRect.top && topOffset < this.boundingRect.bottom);
2251 /* Tracks a drag's mouse movement, firing various handlers
2252 ----------------------------------------------------------------------------------------------------------------------*/
2253 // TODO: use Emitter
2255 var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMixin, {
2260 // coordinates of the initial mousedown
2264 // the wrapping element that scrolls, or MIGHT scroll if there's overflow.
2265 // TODO: do this for wrappers that have overflow:hidden as well.
2268 isInteracting: false,
2269 isDistanceSurpassed: false,
2270 isDelayEnded: false,
2275 delayTimeoutId: null,
2278 handleTouchScrollProxy: null, // calls handleTouchScroll, always bound to `this`
2281 constructor: function(options) {
2282 this.options = options || {};
2283 this.handleTouchScrollProxy = proxy(this, 'handleTouchScroll');
2284 this.initMouseIgnoring(500);
2288 // Interaction (high-level)
2289 // -----------------------------------------------------------------------------------------------------------------
2292 startInteraction: function(ev, extraOptions) {
2293 var isTouch = getEvIsTouch(ev);
2295 if (ev.type === 'mousedown') {
2296 if (this.isIgnoringMouse) {
2299 else if (!isPrimaryMouseButton(ev)) {
2303 ev.preventDefault(); // prevents native selection in most browsers
2307 if (!this.isInteracting) {
2310 extraOptions = extraOptions || {};
2311 this.delay = firstDefined(extraOptions.delay, this.options.delay, 0);
2312 this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0);
2313 this.subjectEl = this.options.subjectEl;
2315 this.isInteracting = true;
2316 this.isTouch = isTouch;
2317 this.isDelayEnded = false;
2318 this.isDistanceSurpassed = false;
2320 this.originX = getEvX(ev);
2321 this.originY = getEvY(ev);
2322 this.scrollEl = getScrollParent($(ev.target));
2324 this.bindHandlers();
2325 this.initAutoScroll();
2326 this.handleInteractionStart(ev);
2327 this.startDelay(ev);
2329 if (!this.minDistance) {
2330 this.handleDistanceSurpassed(ev);
2336 handleInteractionStart: function(ev) {
2337 this.trigger('interactionStart', ev);
2341 endInteraction: function(ev, isCancelled) {
2342 if (this.isInteracting) {
2345 if (this.delayTimeoutId) {
2346 clearTimeout(this.delayTimeoutId);
2347 this.delayTimeoutId = null;
2350 this.destroyAutoScroll();
2351 this.unbindHandlers();
2353 this.isInteracting = false;
2354 this.handleInteractionEnd(ev, isCancelled);
2356 // a touchstart+touchend on the same element will result in the following addition simulated events:
2357 // mouseover + mouseout + click
2358 // let's ignore these bogus events
2360 this.tempIgnoreMouse();
2366 handleInteractionEnd: function(ev, isCancelled) {
2367 this.trigger('interactionEnd', ev, isCancelled || false);
2372 // -----------------------------------------------------------------------------------------------------------------
2375 bindHandlers: function() {
2377 var touchStartIgnores = 1;
2380 this.listenTo($(document), {
2381 touchmove: this.handleTouchMove,
2382 touchend: this.endInteraction,
2383 touchcancel: this.endInteraction,
2385 // Sometimes touchend doesn't fire
2386 // (can't figure out why. touchcancel doesn't fire either. has to do with scrolling?)
2387 // If another touchstart happens, we know it's bogus, so cancel the drag.
2388 // touchend will continue to be broken until user does a shorttap/scroll, but this is best we can do.
2389 touchstart: function(ev) {
2390 if (touchStartIgnores) { // bindHandlers is called from within a touchstart,
2391 touchStartIgnores--; // and we don't want this to fire immediately, so ignore.
2394 _this.endInteraction(ev, true); // isCancelled=true
2399 // listen to ALL scroll actions on the page
2401 !bindAnyScroll(this.handleTouchScrollProxy) && // hopefully this works and short-circuits the rest
2402 this.scrollEl // otherwise, attach a single handler to this
2404 this.listenTo(this.scrollEl, 'scroll', this.handleTouchScroll);
2408 this.listenTo($(document), {
2409 mousemove: this.handleMouseMove,
2410 mouseup: this.endInteraction
2414 this.listenTo($(document), {
2415 selectstart: preventDefault, // don't allow selection while dragging
2416 contextmenu: preventDefault // long taps would open menu on Chrome dev tools
2421 unbindHandlers: function() {
2422 this.stopListeningTo($(document));
2424 // unbind scroll listening
2425 unbindAnyScroll(this.handleTouchScrollProxy);
2426 if (this.scrollEl) {
2427 this.stopListeningTo(this.scrollEl, 'scroll');
2432 // Drag (high-level)
2433 // -----------------------------------------------------------------------------------------------------------------
2436 // extraOptions ignored if drag already started
2437 startDrag: function(ev, extraOptions) {
2438 this.startInteraction(ev, extraOptions); // ensure interaction began
2440 if (!this.isDragging) {
2441 this.isDragging = true;
2442 this.handleDragStart(ev);
2447 handleDragStart: function(ev) {
2448 this.trigger('dragStart', ev);
2452 handleMove: function(ev) {
2453 var dx = getEvX(ev) - this.originX;
2454 var dy = getEvY(ev) - this.originY;
2455 var minDistance = this.minDistance;
2456 var distanceSq; // current distance from the origin, squared
2458 if (!this.isDistanceSurpassed) {
2459 distanceSq = dx * dx + dy * dy;
2460 if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem
2461 this.handleDistanceSurpassed(ev);
2465 if (this.isDragging) {
2466 this.handleDrag(dx, dy, ev);
2471 // Called while the mouse is being moved and when we know a legitimate drag is taking place
2472 handleDrag: function(dx, dy, ev) {
2473 this.trigger('drag', dx, dy, ev);
2474 this.updateAutoScroll(ev); // will possibly cause scrolling
2478 endDrag: function(ev) {
2479 if (this.isDragging) {
2480 this.isDragging = false;
2481 this.handleDragEnd(ev);
2486 handleDragEnd: function(ev) {
2487 this.trigger('dragEnd', ev);
2492 // -----------------------------------------------------------------------------------------------------------------
2495 startDelay: function(initialEv) {
2499 this.delayTimeoutId = setTimeout(function() {
2500 _this.handleDelayEnd(initialEv);
2504 this.handleDelayEnd(initialEv);
2509 handleDelayEnd: function(initialEv) {
2510 this.isDelayEnded = true;
2512 if (this.isDistanceSurpassed) {
2513 this.startDrag(initialEv);
2519 // -----------------------------------------------------------------------------------------------------------------
2522 handleDistanceSurpassed: function(ev) {
2523 this.isDistanceSurpassed = true;
2525 if (this.isDelayEnded) {
2532 // -----------------------------------------------------------------------------------------------------------------
2535 handleTouchMove: function(ev) {
2536 // prevent inertia and touchmove-scrolling while dragging
2537 if (this.isDragging) {
2538 ev.preventDefault();
2541 this.handleMove(ev);
2545 handleMouseMove: function(ev) {
2546 this.handleMove(ev);
2550 // Scrolling (unrelated to auto-scroll)
2551 // -----------------------------------------------------------------------------------------------------------------
2554 handleTouchScroll: function(ev) {
2555 // if the drag is being initiated by touch, but a scroll happens before
2556 // the drag-initiating delay is over, cancel the drag
2557 if (!this.isDragging) {
2558 this.endInteraction(ev, true); // isCancelled=true
2564 // -----------------------------------------------------------------------------------------------------------------
2567 // Triggers a callback. Calls a function in the option hash of the same name.
2568 // Arguments beyond the first `name` are forwarded on.
2569 trigger: function(name) {
2570 if (this.options[name]) {
2571 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
2573 // makes _methods callable by event name. TODO: kill this
2574 if (this['_' + name]) {
2575 this['_' + name].apply(this, Array.prototype.slice.call(arguments, 1));
2584 this.scrollEl is set in DragListener
2586 DragListener.mixin({
2588 isAutoScroll: false,
2590 scrollBounds: null, // { top, bottom, left, right }
2591 scrollTopVel: null, // pixels per second
2592 scrollLeftVel: null, // pixels per second
2593 scrollIntervalId: null, // ID of setTimeout for scrolling animation loop
2596 scrollSensitivity: 30, // pixels from edge for scrolling to start
2597 scrollSpeed: 200, // pixels per second, at maximum speed
2598 scrollIntervalMs: 50, // millisecond wait between scroll increment
2601 initAutoScroll: function() {
2602 var scrollEl = this.scrollEl;
2605 this.options.scroll &&
2607 !scrollEl.is(window) &&
2608 !scrollEl.is(document);
2610 if (this.isAutoScroll) {
2611 // debounce makes sure rapid calls don't happen
2612 this.listenTo(scrollEl, 'scroll', debounce(this.handleDebouncedScroll, 100));
2617 destroyAutoScroll: function() {
2618 this.endAutoScroll(); // kill any animation loop
2620 // remove the scroll handler if there is a scrollEl
2621 if (this.isAutoScroll) {
2622 this.stopListeningTo(this.scrollEl, 'scroll'); // will probably get removed by unbindHandlers too :(
2627 // Computes and stores the bounding rectangle of scrollEl
2628 computeScrollBounds: function() {
2629 if (this.isAutoScroll) {
2630 this.scrollBounds = getOuterRect(this.scrollEl);
2631 // TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars
2636 // Called when the dragging is in progress and scrolling should be updated
2637 updateAutoScroll: function(ev) {
2638 var sensitivity = this.scrollSensitivity;
2639 var bounds = this.scrollBounds;
2640 var topCloseness, bottomCloseness;
2641 var leftCloseness, rightCloseness;
2645 if (bounds) { // only scroll if scrollEl exists
2647 // compute closeness to edges. valid range is from 0.0 - 1.0
2648 topCloseness = (sensitivity - (getEvY(ev) - bounds.top)) / sensitivity;
2649 bottomCloseness = (sensitivity - (bounds.bottom - getEvY(ev))) / sensitivity;
2650 leftCloseness = (sensitivity - (getEvX(ev) - bounds.left)) / sensitivity;
2651 rightCloseness = (sensitivity - (bounds.right - getEvX(ev))) / sensitivity;
2653 // translate vertical closeness into velocity.
2654 // mouse must be completely in bounds for velocity to happen.
2655 if (topCloseness >= 0 && topCloseness <= 1) {
2656 topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up
2658 else if (bottomCloseness >= 0 && bottomCloseness <= 1) {
2659 topVel = bottomCloseness * this.scrollSpeed;
2662 // translate horizontal closeness into velocity
2663 if (leftCloseness >= 0 && leftCloseness <= 1) {
2664 leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left
2666 else if (rightCloseness >= 0 && rightCloseness <= 1) {
2667 leftVel = rightCloseness * this.scrollSpeed;
2671 this.setScrollVel(topVel, leftVel);
2675 // Sets the speed-of-scrolling for the scrollEl
2676 setScrollVel: function(topVel, leftVel) {
2678 this.scrollTopVel = topVel;
2679 this.scrollLeftVel = leftVel;
2681 this.constrainScrollVel(); // massages into realistic values
2683 // if there is non-zero velocity, and an animation loop hasn't already started, then START
2684 if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) {
2685 this.scrollIntervalId = setInterval(
2686 proxy(this, 'scrollIntervalFunc'), // scope to `this`
2687 this.scrollIntervalMs
2693 // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way
2694 constrainScrollVel: function() {
2695 var el = this.scrollEl;
2697 if (this.scrollTopVel < 0) { // scrolling up?
2698 if (el.scrollTop() <= 0) { // already scrolled all the way up?
2699 this.scrollTopVel = 0;
2702 else if (this.scrollTopVel > 0) { // scrolling down?
2703 if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down?
2704 this.scrollTopVel = 0;
2708 if (this.scrollLeftVel < 0) { // scrolling left?
2709 if (el.scrollLeft() <= 0) { // already scrolled all the left?
2710 this.scrollLeftVel = 0;
2713 else if (this.scrollLeftVel > 0) { // scrolling right?
2714 if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right?
2715 this.scrollLeftVel = 0;
2721 // This function gets called during every iteration of the scrolling animation loop
2722 scrollIntervalFunc: function() {
2723 var el = this.scrollEl;
2724 var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by
2726 // change the value of scrollEl's scroll
2727 if (this.scrollTopVel) {
2728 el.scrollTop(el.scrollTop() + this.scrollTopVel * frac);
2730 if (this.scrollLeftVel) {
2731 el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac);
2734 this.constrainScrollVel(); // since the scroll values changed, recompute the velocities
2736 // if scrolled all the way, which causes the vels to be zero, stop the animation loop
2737 if (!this.scrollTopVel && !this.scrollLeftVel) {
2738 this.endAutoScroll();
2743 // Kills any existing scrolling animation loop
2744 endAutoScroll: function() {
2745 if (this.scrollIntervalId) {
2746 clearInterval(this.scrollIntervalId);
2747 this.scrollIntervalId = null;
2749 this.handleScrollEnd();
2754 // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce)
2755 handleDebouncedScroll: function() {
2756 // recompute all coordinates, but *only* if this is *not* part of our scrolling animation
2757 if (!this.scrollIntervalId) {
2758 this.handleScrollEnd();
2763 // Called when scrolling has stopped, whether through auto scroll, or the user scrolling
2764 handleScrollEnd: function() {
2770 /* Tracks mouse movements over a component and raises events about which hit the mouse is over.
2771 ------------------------------------------------------------------------------------------------------------------------
2777 var HitDragListener = DragListener.extend({
2779 component: null, // converts coordinates to hits
2780 // methods: prepareHits, releaseHits, queryHit
2782 origHit: null, // the hit the mouse was over when listening started
2783 hit: null, // the hit the mouse is over
2784 coordAdjust: null, // delta that will be added to the mouse coordinates when computing collisions
2787 constructor: function(component, options) {
2788 DragListener.call(this, options); // call the super-constructor
2790 this.component = component;
2794 // Called when drag listening starts (but a real drag has not necessarily began).
2795 // ev might be undefined if dragging was started manually.
2796 handleInteractionStart: function(ev) {
2797 var subjectEl = this.subjectEl;
2802 this.computeCoords();
2805 origPoint = { left: getEvX(ev), top: getEvY(ev) };
2808 // constrain the point to bounds of the element being dragged
2810 subjectRect = getOuterRect(subjectEl); // used for centering as well
2811 point = constrainPoint(point, subjectRect);
2814 this.origHit = this.queryHit(point.left, point.top);
2816 // treat the center of the subject as the collision point?
2817 if (subjectEl && this.options.subjectCenter) {
2819 // only consider the area the subject overlaps the hit. best for large subjects.
2820 // TODO: skip this if hit didn't supply left/right/top/bottom
2822 subjectRect = intersectRects(this.origHit, subjectRect) ||
2823 subjectRect; // in case there is no intersection
2826 point = getRectCenter(subjectRect);
2829 this.coordAdjust = diffPoints(point, origPoint); // point - origPoint
2832 this.origHit = null;
2833 this.coordAdjust = null;
2836 // call the super-method. do it after origHit has been computed
2837 DragListener.prototype.handleInteractionStart.apply(this, arguments);
2841 // Recomputes the drag-critical positions of elements
2842 computeCoords: function() {
2843 this.component.prepareHits();
2844 this.computeScrollBounds(); // why is this here??????
2848 // Called when the actual drag has started
2849 handleDragStart: function(ev) {
2852 DragListener.prototype.handleDragStart.apply(this, arguments); // call the super-method
2854 // might be different from this.origHit if the min-distance is large
2855 hit = this.queryHit(getEvX(ev), getEvY(ev));
2857 // report the initial hit the mouse is over
2858 // especially important if no min-distance and drag starts immediately
2860 this.handleHitOver(hit);
2865 // Called when the drag moves
2866 handleDrag: function(dx, dy, ev) {
2869 DragListener.prototype.handleDrag.apply(this, arguments); // call the super-method
2871 hit = this.queryHit(getEvX(ev), getEvY(ev));
2873 if (!isHitsEqual(hit, this.hit)) { // a different hit than before?
2875 this.handleHitOut();
2878 this.handleHitOver(hit);
2884 // Called when dragging has been stopped
2885 handleDragEnd: function() {
2886 this.handleHitDone();
2887 DragListener.prototype.handleDragEnd.apply(this, arguments); // call the super-method
2891 // Called when a the mouse has just moved over a new hit
2892 handleHitOver: function(hit) {
2893 var isOrig = isHitsEqual(hit, this.origHit);
2897 this.trigger('hitOver', this.hit, isOrig, this.origHit);
2901 // Called when the mouse has just moved out of a hit
2902 handleHitOut: function() {
2904 this.trigger('hitOut', this.hit);
2905 this.handleHitDone();
2911 // Called after a hitOut. Also called before a dragStop
2912 handleHitDone: function() {
2914 this.trigger('hitDone', this.hit);
2919 // Called when the interaction ends, whether there was a real drag or not
2920 handleInteractionEnd: function() {
2921 DragListener.prototype.handleInteractionEnd.apply(this, arguments); // call the super-method
2923 this.origHit = null;
2926 this.component.releaseHits();
2930 // Called when scrolling has stopped, whether through auto scroll, or the user scrolling
2931 handleScrollEnd: function() {
2932 DragListener.prototype.handleScrollEnd.apply(this, arguments); // call the super-method
2934 this.computeCoords(); // hits' absolute positions will be in new places. recompute
2938 // Gets the hit underneath the coordinates for the given mouse event
2939 queryHit: function(left, top) {
2941 if (this.coordAdjust) {
2942 left += this.coordAdjust.left;
2943 top += this.coordAdjust.top;
2946 return this.component.queryHit(left, top);
2952 // Returns `true` if the hits are identically equal. `false` otherwise. Must be from the same component.
2953 // Two null values will be considered equal, as two "out of the component" states are the same.
2954 function isHitsEqual(hit0, hit1) {
2956 if (!hit0 && !hit1) {
2961 return hit0.component === hit1.component &&
2962 isHitPropsWithin(hit0, hit1) &&
2963 isHitPropsWithin(hit1, hit0); // ensures all props are identical
2970 // Returns true if all of subHit's non-standard properties are within superHit
2971 function isHitPropsWithin(subHit, superHit) {
2972 for (var propName in subHit) {
2973 if (!/^(component|left|right|top|bottom)$/.test(propName)) {
2974 if (subHit[propName] !== superHit[propName]) {
2984 /* Creates a clone of an element and lets it track the mouse as it moves
2985 ----------------------------------------------------------------------------------------------------------------------*/
2987 var MouseFollower = Class.extend(ListenerMixin, {
2991 sourceEl: null, // the element that will be cloned and made to look like it is dragging
2992 el: null, // the clone of `sourceEl` that will track the mouse
2993 parentEl: null, // the element that `el` (the clone) will be attached to
2995 // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl
2999 // the absolute coordinates of the initiating touch/mouse action
3003 // the number of pixels the mouse has moved from its initial position
3009 isAnimating: false, // doing the revert animation?
3011 constructor: function(sourceEl, options) {
3012 this.options = options = options || {};
3013 this.sourceEl = sourceEl;
3014 this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent
3018 // Causes the element to start following the mouse
3019 start: function(ev) {
3020 if (!this.isFollowing) {
3021 this.isFollowing = true;
3023 this.y0 = getEvY(ev);
3024 this.x0 = getEvX(ev);
3028 if (!this.isHidden) {
3029 this.updatePosition();
3032 if (getEvIsTouch(ev)) {
3033 this.listenTo($(document), 'touchmove', this.handleMove);
3036 this.listenTo($(document), 'mousemove', this.handleMove);
3042 // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position.
3043 // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately.
3044 stop: function(shouldRevert, callback) {
3046 var revertDuration = this.options.revertDuration;
3048 function complete() { // might be called by .animate(), which might change `this` context
3049 _this.isAnimating = false;
3050 _this.removeElement();
3052 _this.top0 = _this.left0 = null; // reset state for future updatePosition calls
3059 if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time
3060 this.isFollowing = false;
3062 this.stopListeningTo($(document));
3064 if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation?
3065 this.isAnimating = true;
3070 duration: revertDuration,
3081 // Gets the tracking element. Create it if necessary
3086 el = this.el = this.sourceEl.clone()
3087 .addClass(this.options.additionalClass || '')
3089 position: 'absolute',
3090 visibility: '', // in case original element was hidden (commonly through hideEvents())
3091 display: this.isHidden ? 'none' : '', // for when initially hidden
3093 right: 'auto', // erase and set width instead
3094 bottom: 'auto', // erase and set height instead
3095 width: this.sourceEl.width(), // explicit height in case there was a 'right' value
3096 height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value
3097 opacity: this.options.opacity || '',
3098 zIndex: this.options.zIndex
3101 // we don't want long taps or any mouse interaction causing selection/menus.
3102 // would use preventSelection(), but that prevents selectstart, causing problems.
3103 el.addClass('fc-unselectable');
3105 el.appendTo(this.parentEl);
3112 // Removes the tracking element if it has already been created
3113 removeElement: function() {
3121 // Update the CSS position of the tracking element
3122 updatePosition: function() {
3126 this.getEl(); // ensure this.el
3128 // make sure origin info was computed
3129 if (this.top0 === null) {
3130 sourceOffset = this.sourceEl.offset();
3131 origin = this.el.offsetParent().offset();
3132 this.top0 = sourceOffset.top - origin.top;
3133 this.left0 = sourceOffset.left - origin.left;
3137 top: this.top0 + this.topDelta,
3138 left: this.left0 + this.leftDelta
3143 // Gets called when the user moves the mouse
3144 handleMove: function(ev) {
3145 this.topDelta = getEvY(ev) - this.y0;
3146 this.leftDelta = getEvX(ev) - this.x0;
3148 if (!this.isHidden) {
3149 this.updatePosition();
3154 // Temporarily makes the tracking element invisible. Can be called before following starts
3156 if (!this.isHidden) {
3157 this.isHidden = true;
3165 // Show the tracking element after it has been temporarily hidden
3167 if (this.isHidden) {
3168 this.isHidden = false;
3169 this.updatePosition();
3170 this.getEl().show();
3178 /* An abstract class comprised of a "grid" of areas that each represent a specific datetime
3179 ----------------------------------------------------------------------------------------------------------------------*/
3181 var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, {
3183 // self-config, overridable by subclasses
3184 hasDayInteractions: true, // can user click/select ranges of time?
3186 view: null, // a View object
3187 isRTL: null, // shortcut to the view's isRTL option
3192 el: null, // the containing element
3193 elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name.
3195 // derived from options
3196 eventTimeFormat: null,
3197 displayEventTime: null,
3198 displayEventEnd: null,
3200 minResizeDuration: null, // TODO: hack. set by subclasses. minumum event resize duration
3202 // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity
3203 // of the date areas. if not defined, assumes to be day and time granularity.
3204 // TODO: port isTimeScale into same system?
3207 dayDragListener: null,
3208 segDragListener: null,
3209 segResizeListener: null,
3210 externalDragListener: null,
3213 constructor: function(view) {
3215 this.isRTL = view.opt('isRTL');
3216 this.elsByFill = {};
3218 this.dayDragListener = this.buildDayDragListener();
3219 this.initMouseIgnoring();
3224 ------------------------------------------------------------------------------------------------------------------*/
3227 // Generates the format string used for event time text, if not explicitly defined by 'timeFormat'
3228 computeEventTimeFormat: function() {
3229 return this.view.opt('smallTimeFormat');
3233 // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventTime'.
3234 // Only applies to non-all-day events.
3235 computeDisplayEventTime: function() {
3240 // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd'
3241 computeDisplayEventEnd: function() {
3247 ------------------------------------------------------------------------------------------------------------------*/
3250 // Tells the grid about what period of time to display.
3251 // Any date-related internal data should be generated.
3252 setRange: function(range) {
3253 this.start = range.start.clone();
3254 this.end = range.end.clone();
3256 this.rangeUpdated();
3257 this.processRangeOptions();
3261 // Called when internal variables that rely on the range should be updated
3262 rangeUpdated: function() {
3266 // Updates values that rely on options and also relate to range
3267 processRangeOptions: function() {
3268 var view = this.view;
3269 var displayEventTime;
3270 var displayEventEnd;
3272 this.eventTimeFormat =
3273 view.opt('eventTimeFormat') ||
3274 view.opt('timeFormat') || // deprecated
3275 this.computeEventTimeFormat();
3277 displayEventTime = view.opt('displayEventTime');
3278 if (displayEventTime == null) {
3279 displayEventTime = this.computeDisplayEventTime(); // might be based off of range
3282 displayEventEnd = view.opt('displayEventEnd');
3283 if (displayEventEnd == null) {
3284 displayEventEnd = this.computeDisplayEventEnd(); // might be based off of range
3287 this.displayEventTime = displayEventTime;
3288 this.displayEventEnd = displayEventEnd;
3292 // Converts a span (has unzoned start/end and any other grid-specific location information)
3293 // into an array of segments (pieces of events whose format is decided by the grid).
3294 spanToSegs: function(span) {
3295 // subclasses must implement
3299 // Diffs the two dates, returning a duration, based on granularity of the grid
3300 // TODO: port isTimeScale into this system?
3301 diffDates: function(a, b) {
3302 if (this.largeUnit) {
3303 return diffByUnit(a, b, this.largeUnit);
3306 return diffDayTime(a, b);
3312 ------------------------------------------------------------------------------------------------------------------*/
3315 // Called before one or more queryHit calls might happen. Should prepare any cached coordinates for queryHit
3316 prepareHits: function() {
3320 // Called when queryHit calls have subsided. Good place to clear any coordinate caches.
3321 releaseHits: function() {
3325 // Given coordinates from the topleft of the document, return data about the date-related area underneath.
3326 // Can return an object with arbitrary properties (although top/right/left/bottom are encouraged).
3327 // Must have a `grid` property, a reference to this current grid. TODO: avoid this
3328 // The returned object will be processed by getHitSpan and getHitEl.
3329 queryHit: function(leftOffset, topOffset) {
3333 // Given position-level information about a date-related area within the grid,
3334 // should return an object with at least a start/end date. Can provide other information as well.
3335 getHitSpan: function(hit) {
3339 // Given position-level information about a date-related area within the grid,
3340 // should return a jQuery element that best represents it. passed to dayClick callback.
3341 getHitEl: function(hit) {
3346 ------------------------------------------------------------------------------------------------------------------*/
3349 // Sets the container element that the grid should render inside of.
3350 // Does other DOM-related initializations.
3351 setElement: function(el) {
3354 if (this.hasDayInteractions) {
3355 preventSelection(el);
3357 this.bindDayHandler('touchstart', this.dayTouchStart);
3358 this.bindDayHandler('mousedown', this.dayMousedown);
3361 // attach event-element-related handlers. in Grid.events
3362 // same garbage collection note as above.
3363 this.bindSegHandlers();
3365 this.bindGlobalHandlers();
3369 bindDayHandler: function(name, handler) {
3372 // attach a handler to the grid's root element.
3373 // jQuery will take care of unregistering them when removeElement gets called.
3374 this.el.on(name, function(ev) {
3377 _this.segSelector + ',' + // directly on an event element
3378 _this.segSelector + ' *,' + // within an event element
3379 '.fc-more,' + // a "more.." link
3380 'a[data-goto]' // a clickable nav link
3383 return handler.call(_this, ev);
3389 // Removes the grid's container element from the DOM. Undoes any other DOM-related attachments.
3390 // DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View
3391 removeElement: function() {
3392 this.unbindGlobalHandlers();
3393 this.clearDragListeners();
3397 // NOTE: we don't null-out this.el for the same reasons we don't do it within View::removeElement
3401 // Renders the basic structure of grid view before any content is rendered
3402 renderSkeleton: function() {
3403 // subclasses should implement
3407 // Renders the grid's date-related content (like areas that represent days/times).
3408 // Assumes setRange has already been called and the skeleton has already been rendered.
3409 renderDates: function() {
3410 // subclasses should implement
3414 // Unrenders the grid's date-related content
3415 unrenderDates: function() {
3416 // subclasses should implement
3421 ------------------------------------------------------------------------------------------------------------------*/
3424 // Binds DOM handlers to elements that reside outside the grid, such as the document
3425 bindGlobalHandlers: function() {
3426 this.listenTo($(document), {
3427 dragstart: this.externalDragStart, // jqui
3428 sortstart: this.externalDragStart // jqui
3433 // Unbinds DOM handlers from elements that reside outside the grid
3434 unbindGlobalHandlers: function() {
3435 this.stopListeningTo($(document));
3439 // Process a mousedown on an element that represents a day. For day clicking and selecting.
3440 dayMousedown: function(ev) {
3441 if (!this.isIgnoringMouse) {
3442 this.dayDragListener.startInteraction(ev, {
3443 //distance: 5, // needs more work if we want dayClick to fire correctly
3449 dayTouchStart: function(ev) {
3450 var view = this.view;
3452 // HACK to prevent a user's clickaway for unselecting a range or an event
3453 // from causing a dayClick.
3454 if (view.isSelected || view.selectedEvent) {
3455 this.tempIgnoreMouse();
3458 this.dayDragListener.startInteraction(ev, {
3459 delay: this.view.opt('longPressDelay')
3464 // Creates a listener that tracks the user's drag across day elements.
3465 // For day clicking and selecting.
3466 buildDayDragListener: function() {
3468 var view = this.view;
3469 var isSelectable = view.opt('selectable');
3470 var dayClickHit; // null if invalid dayClick
3471 var selectionSpan; // null if invalid selection
3473 // this listener tracks a mousedown on a day element, and a subsequent drag.
3474 // if the drag ends on the same day, it is a 'dayClick'.
3475 // if 'selectable' is enabled, this listener also detects selections.
3476 var dragListener = new HitDragListener(this, {
3477 scroll: view.opt('dragScroll'),
3478 interactionStart: function() {
3479 dayClickHit = dragListener.origHit; // for dayClick, where no dragging happens
3480 selectionSpan = null;
3482 dragStart: function() {
3483 view.unselect(); // since we could be rendering a new selection, we want to clear any old one
3485 hitOver: function(hit, isOrig, origHit) {
3486 if (origHit) { // click needs to have started on a hit
3488 // if user dragged to another cell at any point, it can no longer be a dayClick
3494 selectionSpan = _this.computeSelection(
3495 _this.getHitSpan(origHit),
3496 _this.getHitSpan(hit)
3498 if (selectionSpan) {
3499 _this.renderSelection(selectionSpan);
3501 else if (selectionSpan === false) {
3507 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
3509 selectionSpan = null;
3510 _this.unrenderSelection();
3512 hitDone: function() { // called after a hitOut OR before a dragEnd
3515 interactionEnd: function(ev, isCancelled) {
3519 !_this.isIgnoringMouse // see hack in dayTouchStart
3521 view.triggerDayClick(
3522 _this.getHitSpan(dayClickHit),
3523 _this.getHitEl(dayClickHit),
3527 if (selectionSpan) {
3528 // the selection will already have been rendered. just report it
3529 view.reportSelection(selectionSpan, ev);
3535 return dragListener;
3539 // Kills all in-progress dragging.
3540 // Useful for when public API methods that result in re-rendering are invoked during a drag.
3541 // Also useful for when touch devices misbehave and don't fire their touchend.
3542 clearDragListeners: function() {
3543 this.dayDragListener.endInteraction();
3545 if (this.segDragListener) {
3546 this.segDragListener.endInteraction(); // will clear this.segDragListener
3548 if (this.segResizeListener) {
3549 this.segResizeListener.endInteraction(); // will clear this.segResizeListener
3551 if (this.externalDragListener) {
3552 this.externalDragListener.endInteraction(); // will clear this.externalDragListener
3558 ------------------------------------------------------------------------------------------------------------------*/
3559 // TODO: should probably move this to Grid.events, like we did event dragging / resizing
3562 // Renders a mock event at the given event location, which contains zoned start/end properties.
3563 // Returns all mock event elements.
3564 renderEventLocationHelper: function(eventLocation, sourceSeg) {
3565 var fakeEvent = this.fabricateHelperEvent(eventLocation, sourceSeg);
3567 return this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering
3571 // Builds a fake event given zoned event date properties and a segment is should be inspired from.
3572 // The range's end can be null, in which case the mock event that is rendered will have a null end time.
3573 // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging.
3574 fabricateHelperEvent: function(eventLocation, sourceSeg) {
3575 var fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible
3577 fakeEvent.start = eventLocation.start.clone();
3578 fakeEvent.end = eventLocation.end ? eventLocation.end.clone() : null;
3579 fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventDates
3580 this.view.calendar.normalizeEventDates(fakeEvent);
3582 // this extra className will be useful for differentiating real events from mock events in CSS
3583 fakeEvent.className = (fakeEvent.className || []).concat('fc-helper');
3585 // if something external is being dragged in, don't render a resizer
3587 fakeEvent.editable = false;
3594 // Renders a mock event. Given zoned event date properties.
3595 // Must return all mock event elements.
3596 renderHelper: function(eventLocation, sourceSeg) {
3597 // subclasses must implement
3601 // Unrenders a mock event
3602 unrenderHelper: function() {
3603 // subclasses must implement
3608 ------------------------------------------------------------------------------------------------------------------*/
3611 // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses.
3612 // Given a span (unzoned start/end and other misc data)
3613 renderSelection: function(span) {
3614 this.renderHighlight(span);
3618 // Unrenders any visual indications of a selection. Will unrender a highlight by default.
3619 unrenderSelection: function() {
3620 this.unrenderHighlight();
3624 // Given the first and last date-spans of a selection, returns another date-span object.
3625 // Subclasses can override and provide additional data in the span object. Will be passed to renderSelection().
3626 // Will return false if the selection is invalid and this should be indicated to the user.
3627 // Will return null/undefined if a selection invalid but no error should be reported.
3628 computeSelection: function(span0, span1) {
3629 var span = this.computeSelectionSpan(span0, span1);
3631 if (span && !this.view.calendar.isSelectionSpanAllowed(span)) {
3639 // Given two spans, must return the combination of the two.
3640 // TODO: do this separation of concerns (combining VS validation) for event dnd/resize too.
3641 computeSelectionSpan: function(span0, span1) {
3642 var dates = [ span0.start, span0.end, span1.start, span1.end ];
3644 dates.sort(compareNumbers); // sorts chronologically. works with Moments
3646 return { start: dates[0].clone(), end: dates[3].clone() };
3651 ------------------------------------------------------------------------------------------------------------------*/
3654 // Renders an emphasis on the given date range. Given a span (unzoned start/end and other misc data)
3655 renderHighlight: function(span) {
3656 this.renderFill('highlight', this.spanToSegs(span));
3660 // Unrenders the emphasis on a date range
3661 unrenderHighlight: function() {
3662 this.unrenderFill('highlight');
3666 // Generates an array of classNames for rendering the highlight. Used by the fill system.
3667 highlightSegClasses: function() {
3668 return [ 'fc-highlight' ];
3673 ------------------------------------------------------------------------------------------------------------------*/
3676 renderBusinessHours: function() {
3680 unrenderBusinessHours: function() {
3685 ------------------------------------------------------------------------------------------------------------------*/
3688 getNowIndicatorUnit: function() {
3692 renderNowIndicator: function(date) {
3696 unrenderNowIndicator: function() {
3700 /* Fill System (highlight, background events, business hours)
3701 --------------------------------------------------------------------------------------------------------------------
3702 TODO: remove this system. like we did in TimeGrid
3706 // Renders a set of rectangles over the given segments of time.
3707 // MUST RETURN a subset of segs, the segs that were actually rendered.
3708 // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement
3709 renderFill: function(type, segs) {
3710 // subclasses must implement
3714 // Unrenders a specific type of fill that is currently rendered on the grid
3715 unrenderFill: function(type) {
3716 var el = this.elsByFill[type];
3720 delete this.elsByFill[type];
3725 // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types.
3726 // Only returns segments that successfully rendered.
3727 // To be harnessed by renderFill (implemented by subclasses).
3728 // Analagous to renderFgSegEls.
3729 renderFillSegEls: function(type, segs) {
3731 var segElMethod = this[type + 'SegEl'];
3733 var renderedSegs = [];
3738 // build a large concatenation of segment HTML
3739 for (i = 0; i < segs.length; i++) {
3740 html += this.fillSegHtml(type, segs[i]);
3743 // Grab individual elements from the combined HTML string. Use each as the default rendering.
3744 // Then, compute the 'el' for each segment.
3745 $(html).each(function(i, node) {
3749 // allow custom filter methods per-type
3751 el = segElMethod.call(_this, seg, el);
3754 if (el) { // custom filters did not cancel the render
3755 el = $(el); // allow custom filter to return raw DOM node
3757 // correct element type? (would be bad if a non-TD were inserted into a table for example)
3758 if (el.is(_this.fillSegTag)) {
3760 renderedSegs.push(seg);
3766 return renderedSegs;
3770 fillSegTag: 'div', // subclasses can override
3773 // Builds the HTML needed for one fill segment. Generic enough to work with different types.
3774 fillSegHtml: function(type, seg) {
3776 // custom hooks per-type
3777 var classesMethod = this[type + 'SegClasses'];
3778 var cssMethod = this[type + 'SegCss'];
3780 var classes = classesMethod ? classesMethod.call(this, seg) : [];
3781 var css = cssToStr(cssMethod ? cssMethod.call(this, seg) : {});
3783 return '<' + this.fillSegTag +
3784 (classes.length ? ' class="' + classes.join(' ') + '"' : '') +
3785 (css ? ' style="' + css + '"' : '') +
3791 /* Generic rendering utilities for subclasses
3792 ------------------------------------------------------------------------------------------------------------------*/
3795 // Computes HTML classNames for a single-day element
3796 getDayClasses: function(date) {
3797 var view = this.view;
3798 var today = view.calendar.getNow();
3799 var classes = [ 'fc-' + dayIDs[date.day()] ];
3802 view.intervalDuration.as('months') == 1 &&
3803 date.month() != view.intervalStart.month()
3805 classes.push('fc-other-month');
3808 if (date.isSame(today, 'day')) {
3811 view.highlightStateClass
3814 else if (date < today) {
3815 classes.push('fc-past');
3818 classes.push('fc-future');
3828 /* Event-rendering and event-interaction methods for the abstract Grid class
3829 ----------------------------------------------------------------------------------------------------------------------*/
3833 // self-config, overridable by subclasses
3834 segSelector: '.fc-event-container > *', // what constitutes an event element?
3836 mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing
3837 isDraggingSeg: false, // is a segment being dragged? boolean
3838 isResizingSeg: false, // is a segment being resized? boolean
3839 isDraggingExternal: false, // jqui-dragging an external element? boolean
3840 segs: null, // the *event* segments currently rendered in the grid. TODO: rename to `eventSegs`
3843 // Renders the given events onto the grid
3844 renderEvents: function(events) {
3849 for (i = 0; i < events.length; i++) {
3850 (isBgEvent(events[i]) ? bgEvents : fgEvents).push(events[i]);
3853 this.segs = [].concat( // record all segs
3854 this.renderBgEvents(bgEvents),
3855 this.renderFgEvents(fgEvents)
3860 renderBgEvents: function(events) {
3861 var segs = this.eventsToSegs(events);
3863 // renderBgSegs might return a subset of segs, segs that were actually rendered
3864 return this.renderBgSegs(segs) || segs;
3868 renderFgEvents: function(events) {
3869 var segs = this.eventsToSegs(events);
3871 // renderFgSegs might return a subset of segs, segs that were actually rendered
3872 return this.renderFgSegs(segs) || segs;
3876 // Unrenders all events currently rendered on the grid
3877 unrenderEvents: function() {
3878 this.handleSegMouseout(); // trigger an eventMouseout if user's mouse is over an event
3879 this.clearDragListeners();
3881 this.unrenderFgSegs();
3882 this.unrenderBgSegs();
3888 // Retrieves all rendered segment objects currently rendered on the grid
3889 getEventSegs: function() {
3890 return this.segs || [];
3894 /* Foreground Segment Rendering
3895 ------------------------------------------------------------------------------------------------------------------*/
3898 // Renders foreground event segments onto the grid. May return a subset of segs that were rendered.
3899 renderFgSegs: function(segs) {
3900 // subclasses must implement
3904 // Unrenders all currently rendered foreground segments
3905 unrenderFgSegs: function() {
3906 // subclasses must implement
3910 // Renders and assigns an `el` property for each foreground event segment.
3911 // Only returns segments that successfully rendered.
3912 // A utility that subclasses may use.
3913 renderFgSegEls: function(segs, disableResizing) {
3914 var view = this.view;
3916 var renderedSegs = [];
3919 if (segs.length) { // don't build an empty html string
3921 // build a large concatenation of event segment HTML
3922 for (i = 0; i < segs.length; i++) {
3923 html += this.fgSegHtml(segs[i], disableResizing);
3926 // Grab individual elements from the combined HTML string. Use each as the default rendering.
3927 // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false.
3928 $(html).each(function(i, node) {
3930 var el = view.resolveEventEl(seg.event, $(node));
3933 el.data('fc-seg', seg); // used by handlers
3935 renderedSegs.push(seg);
3940 return renderedSegs;
3944 // Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls()
3945 fgSegHtml: function(seg, disableResizing) {
3946 // subclasses should implement
3950 /* Background Segment Rendering
3951 ------------------------------------------------------------------------------------------------------------------*/
3954 // Renders the given background event segments onto the grid.
3955 // Returns a subset of the segs that were actually rendered.
3956 renderBgSegs: function(segs) {
3957 return this.renderFill('bgEvent', segs);
3961 // Unrenders all the currently rendered background event segments
3962 unrenderBgSegs: function() {
3963 this.unrenderFill('bgEvent');
3967 // Renders a background event element, given the default rendering. Called by the fill system.
3968 bgEventSegEl: function(seg, el) {
3969 return this.view.resolveEventEl(seg.event, el); // will filter through eventRender
3973 // Generates an array of classNames to be used for the default rendering of a background event.
3974 // Called by fillSegHtml.
3975 bgEventSegClasses: function(seg) {
3976 var event = seg.event;
3977 var source = event.source || {};
3979 return [ 'fc-bgevent' ].concat(
3981 source.className || []
3986 // Generates a semicolon-separated CSS string to be used for the default rendering of a background event.
3987 // Called by fillSegHtml.
3988 bgEventSegCss: function(seg) {
3990 'background-color': this.getSegSkinCss(seg)['background-color']
3995 // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system.
3996 // Called by fillSegHtml.
3997 businessHoursSegClasses: function(seg) {
3998 return [ 'fc-nonbusiness', 'fc-bgevent' ];
4003 ------------------------------------------------------------------------------------------------------------------*/
4006 // Compute business hour segs for the grid's current date range.
4007 // Caller must ask if whole-day business hours are needed.
4008 buildBusinessHourSegs: function(wholeDay) {
4009 var events = this.view.calendar.getCurrentBusinessHourEvents(wholeDay);
4011 // HACK. Eventually refactor business hours "events" system.
4012 // If no events are given, but businessHours is activated, this means the entire visible range should be
4013 // marked as *not* business-hours, via inverse-background rendering.
4016 this.view.calendar.options.businessHours // don't access view option. doesn't update with dynamic options
4019 $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, {
4020 start: this.view.end, // guaranteed out-of-range
4021 end: this.view.end, // "
4027 return this.eventsToSegs(events);
4032 ------------------------------------------------------------------------------------------------------------------*/
4035 // Attaches event-element-related handlers for *all* rendered event segments of the view.
4036 bindSegHandlers: function() {
4037 this.bindSegHandlersToEl(this.el);
4041 // Attaches event-element-related handlers to an arbitrary container element. leverages bubbling.
4042 bindSegHandlersToEl: function(el) {
4043 this.bindSegHandlerToEl(el, 'touchstart', this.handleSegTouchStart);
4044 this.bindSegHandlerToEl(el, 'touchend', this.handleSegTouchEnd);
4045 this.bindSegHandlerToEl(el, 'mouseenter', this.handleSegMouseover);
4046 this.bindSegHandlerToEl(el, 'mouseleave', this.handleSegMouseout);
4047 this.bindSegHandlerToEl(el, 'mousedown', this.handleSegMousedown);
4048 this.bindSegHandlerToEl(el, 'click', this.handleSegClick);
4052 // Executes a handler for any a user-interaction on a segment.
4053 // Handler gets called with (seg, ev), and with the `this` context of the Grid
4054 bindSegHandlerToEl: function(el, name, handler) {
4057 el.on(name, this.segSelector, function(ev) {
4058 var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents
4060 // only call the handlers if there is not a drag/resize in progress
4061 if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) {
4062 return handler.call(_this, seg, ev); // context will be the Grid
4068 handleSegClick: function(seg, ev) {
4069 var res = this.view.trigger('eventClick', seg.el[0], seg.event, ev); // can return `false` to cancel
4070 if (res === false) {
4071 ev.preventDefault();
4076 // Updates internal state and triggers handlers for when an event element is moused over
4077 handleSegMouseover: function(seg, ev) {
4079 !this.isIgnoringMouse &&
4082 this.mousedOverSeg = seg;
4083 if (this.view.isEventResizable(seg.event)) {
4084 seg.el.addClass('fc-allow-mouse-resize');
4086 this.view.trigger('eventMouseover', seg.el[0], seg.event, ev);
4091 // Updates internal state and triggers handlers for when an event element is moused out.
4092 // Can be given no arguments, in which case it will mouseout the segment that was previously moused over.
4093 handleSegMouseout: function(seg, ev) {
4094 ev = ev || {}; // if given no args, make a mock mouse event
4096 if (this.mousedOverSeg) {
4097 seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment
4098 this.mousedOverSeg = null;
4099 if (this.view.isEventResizable(seg.event)) {
4100 seg.el.removeClass('fc-allow-mouse-resize');
4102 this.view.trigger('eventMouseout', seg.el[0], seg.event, ev);
4107 handleSegMousedown: function(seg, ev) {
4108 var isResizing = this.startSegResize(seg, ev, { distance: 5 });
4110 if (!isResizing && this.view.isEventDraggable(seg.event)) {
4111 this.buildSegDragListener(seg)
4112 .startInteraction(ev, {
4119 handleSegTouchStart: function(seg, ev) {
4120 var view = this.view;
4121 var event = seg.event;
4122 var isSelected = view.isEventSelected(event);
4123 var isDraggable = view.isEventDraggable(event);
4124 var isResizable = view.isEventResizable(event);
4125 var isResizing = false;
4128 if (isSelected && isResizable) {
4129 // only allow resizing of the event is selected
4130 isResizing = this.startSegResize(seg, ev);
4133 if (!isResizing && (isDraggable || isResizable)) { // allowed to be selected?
4135 dragListener = isDraggable ?
4136 this.buildSegDragListener(seg) :
4137 this.buildSegSelectListener(seg); // seg isn't draggable, but still needs to be selected
4139 dragListener.startInteraction(ev, { // won't start if already started
4140 delay: isSelected ? 0 : this.view.opt('longPressDelay') // do delay if not already selected
4144 // a long tap simulates a mouseover. ignore this bogus mouseover.
4145 this.tempIgnoreMouse();
4149 handleSegTouchEnd: function(seg, ev) {
4150 // touchstart+touchend = click, which simulates a mouseover.
4151 // ignore this bogus mouseover.
4152 this.tempIgnoreMouse();
4156 // returns boolean whether resizing actually started or not.
4157 // assumes the seg allows resizing.
4158 // `dragOptions` are optional.
4159 startSegResize: function(seg, ev, dragOptions) {
4160 if ($(ev.target).is('.fc-resizer')) {
4161 this.buildSegResizeListener(seg, $(ev.target).is('.fc-start-resizer'))
4162 .startInteraction(ev, dragOptions);
4171 ------------------------------------------------------------------------------------------------------------------*/
4174 // Builds a listener that will track user-dragging on an event segment.
4175 // Generic enough to work with any type of Grid.
4176 // Has side effect of setting/unsetting `segDragListener`
4177 buildSegDragListener: function(seg) {
4179 var view = this.view;
4180 var calendar = view.calendar;
4182 var event = seg.event;
4184 var mouseFollower; // A clone of the original element that will move with the mouse
4185 var dropLocation; // zoned event date properties
4187 if (this.segDragListener) {
4188 return this.segDragListener;
4191 // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents
4193 var dragListener = this.segDragListener = new HitDragListener(view, {
4194 scroll: view.opt('dragScroll'),
4196 subjectCenter: true,
4197 interactionStart: function(ev) {
4198 seg.component = _this; // for renderDrag
4200 mouseFollower = new MouseFollower(seg.el, {
4201 additionalClass: 'fc-dragging',
4203 opacity: dragListener.isTouch ? null : view.opt('dragOpacity'),
4204 revertDuration: view.opt('dragRevertDuration'),
4205 zIndex: 2 // one above the .fc-view
4207 mouseFollower.hide(); // don't show until we know this is a real drag
4208 mouseFollower.start(ev);
4210 dragStart: function(ev) {
4211 if (dragListener.isTouch && !view.isEventSelected(event)) {
4212 // if not previously selected, will fire after a delay. then, select the event
4213 view.selectEvent(event);
4216 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
4217 _this.segDragStart(seg, ev);
4218 view.hideEvent(event); // hide all event segments. our mouseFollower will take over
4220 hitOver: function(hit, isOrig, origHit) {
4223 // starting hit could be forced (DayGrid.limit)
4228 // since we are querying the parent view, might not belong to this grid
4229 dropLocation = _this.computeEventDrop(
4230 origHit.component.getHitSpan(origHit),
4231 hit.component.getHitSpan(hit),
4235 if (dropLocation && !calendar.isEventSpanAllowed(_this.eventToSpan(dropLocation), event)) {
4237 dropLocation = null;
4240 // if a valid drop location, have the subclass render a visual indication
4241 if (dropLocation && (dragHelperEls = view.renderDrag(dropLocation, seg))) {
4243 dragHelperEls.addClass('fc-dragging');
4244 if (!dragListener.isTouch) {
4245 _this.applyDragOpacity(dragHelperEls);
4248 mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own
4251 mouseFollower.show(); // otherwise, have the helper follow the mouse (no snapping)
4255 dropLocation = null; // needs to have moved hits to be a valid drop
4258 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
4259 view.unrenderDrag(); // unrender whatever was done in renderDrag
4260 mouseFollower.show(); // show in case we are moving out of all hits
4261 dropLocation = null;
4263 hitDone: function() { // Called after a hitOut OR before a dragEnd
4266 interactionEnd: function(ev) {
4267 delete seg.component; // prevent side effects
4269 // do revert animation if hasn't changed. calls a callback when finished (whether animation or not)
4270 mouseFollower.stop(!dropLocation, function() {
4272 view.unrenderDrag();
4273 view.showEvent(event);
4274 _this.segDragStop(seg, ev);
4277 view.reportEventDrop(event, dropLocation, this.largeUnit, el, ev);
4280 _this.segDragListener = null;
4284 return dragListener;
4288 // seg isn't draggable, but let's use a generic DragListener
4289 // simply for the delay, so it can be selected.
4290 // Has side effect of setting/unsetting `segDragListener`
4291 buildSegSelectListener: function(seg) {
4293 var view = this.view;
4294 var event = seg.event;
4296 if (this.segDragListener) {
4297 return this.segDragListener;
4300 var dragListener = this.segDragListener = new DragListener({
4301 dragStart: function(ev) {
4302 if (dragListener.isTouch && !view.isEventSelected(event)) {
4303 // if not previously selected, will fire after a delay. then, select the event
4304 view.selectEvent(event);
4307 interactionEnd: function(ev) {
4308 _this.segDragListener = null;
4312 return dragListener;
4316 // Called before event segment dragging starts
4317 segDragStart: function(seg, ev) {
4318 this.isDraggingSeg = true;
4319 this.view.trigger('eventDragStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
4323 // Called after event segment dragging stops
4324 segDragStop: function(seg, ev) {
4325 this.isDraggingSeg = false;
4326 this.view.trigger('eventDragStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
4330 // Given the spans an event drag began, and the span event was dropped, calculates the new zoned start/end/allDay
4331 // values for the event. Subclasses may override and set additional properties to be used by renderDrag.
4332 // A falsy returned value indicates an invalid drop.
4333 // DOES NOT consider overlap/constraint.
4334 computeEventDrop: function(startSpan, endSpan, event) {
4335 var calendar = this.view.calendar;
4336 var dragStart = startSpan.start;
4337 var dragEnd = endSpan.start;
4339 var dropLocation; // zoned event date properties
4341 if (dragStart.hasTime() === dragEnd.hasTime()) {
4342 delta = this.diffDates(dragEnd, dragStart);
4344 // if an all-day event was in a timed area and it was dragged to a different time,
4345 // guarantee an end and adjust start/end to have times
4346 if (event.allDay && durationHasTime(delta)) {
4348 start: event.start.clone(),
4349 end: calendar.getEventEnd(event), // will be an ambig day
4350 allDay: false // for normalizeEventTimes
4352 calendar.normalizeEventTimes(dropLocation);
4354 // othewise, work off existing values
4356 dropLocation = pluckEventDateProps(event);
4359 dropLocation.start.add(delta);
4360 if (dropLocation.end) {
4361 dropLocation.end.add(delta);
4365 // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared
4367 start: dragEnd.clone(),
4368 end: null, // end should be cleared
4369 allDay: !dragEnd.hasTime()
4373 return dropLocation;
4377 // Utility for apply dragOpacity to a jQuery set
4378 applyDragOpacity: function(els) {
4379 var opacity = this.view.opt('dragOpacity');
4381 if (opacity != null) {
4382 els.css('opacity', opacity);
4387 /* External Element Dragging
4388 ------------------------------------------------------------------------------------------------------------------*/
4391 // Called when a jQuery UI drag is initiated anywhere in the DOM
4392 externalDragStart: function(ev, ui) {
4393 var view = this.view;
4397 if (view.opt('droppable')) { // only listen if this setting is on
4398 el = $((ui ? ui.item : null) || ev.target);
4400 // Test that the dragged element passes the dropAccept selector or filter function.
4401 // FYI, the default is "*" (matches all)
4402 accept = view.opt('dropAccept');
4403 if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) {
4404 if (!this.isDraggingExternal) { // prevent double-listening if fired twice
4405 this.listenToExternalDrag(el, ev, ui);
4412 // Called when a jQuery UI drag starts and it needs to be monitored for dropping
4413 listenToExternalDrag: function(el, ev, ui) {
4415 var calendar = this.view.calendar;
4416 var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create
4417 var dropLocation; // a null value signals an unsuccessful drag
4419 // listener that tracks mouse movement over date-associated pixel regions
4420 var dragListener = _this.externalDragListener = new HitDragListener(this, {
4421 interactionStart: function() {
4422 _this.isDraggingExternal = true;
4424 hitOver: function(hit) {
4425 dropLocation = _this.computeExternalDrop(
4426 hit.component.getHitSpan(hit), // since we are querying the parent view, might not belong to this grid
4430 if ( // invalid hit?
4432 !calendar.isExternalSpanAllowed(_this.eventToSpan(dropLocation), dropLocation, meta.eventProps)
4435 dropLocation = null;
4439 _this.renderDrag(dropLocation); // called without a seg parameter
4442 hitOut: function() {
4443 dropLocation = null; // signal unsuccessful
4445 hitDone: function() { // Called after a hitOut OR before a dragEnd
4447 _this.unrenderDrag();
4449 interactionEnd: function(ev) {
4450 if (dropLocation) { // element was dropped on a valid hit
4451 _this.view.reportExternalDrop(meta, dropLocation, el, ev, ui);
4453 _this.isDraggingExternal = false;
4454 _this.externalDragListener = null;
4458 dragListener.startDrag(ev); // start listening immediately
4462 // Given a hit to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object),
4463 // returns the zoned start/end dates for the event that would result from the hypothetical drop. end might be null.
4464 // Returning a null value signals an invalid drop hit.
4465 // DOES NOT consider overlap/constraint.
4466 computeExternalDrop: function(span, meta) {
4467 var calendar = this.view.calendar;
4468 var dropLocation = {
4469 start: calendar.applyTimezone(span.start), // simulate a zoned event start date
4473 // if dropped on an all-day span, and element's metadata specified a time, set it
4474 if (meta.startTime && !dropLocation.start.hasTime()) {
4475 dropLocation.start.time(meta.startTime);
4478 if (meta.duration) {
4479 dropLocation.end = dropLocation.start.clone().add(meta.duration);
4482 return dropLocation;
4487 /* Drag Rendering (for both events and an external elements)
4488 ------------------------------------------------------------------------------------------------------------------*/
4491 // Renders a visual indication of an event or external element being dragged.
4492 // `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null.
4493 // `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null.
4494 // A truthy returned value indicates this method has rendered a helper element.
4495 // Must return elements used for any mock events.
4496 renderDrag: function(dropLocation, seg) {
4497 // subclasses must implement
4501 // Unrenders a visual indication of an event or external element being dragged
4502 unrenderDrag: function() {
4503 // subclasses must implement
4508 ------------------------------------------------------------------------------------------------------------------*/
4511 // Creates a listener that tracks the user as they resize an event segment.
4512 // Generic enough to work with any type of Grid.
4513 buildSegResizeListener: function(seg, isStart) {
4515 var view = this.view;
4516 var calendar = view.calendar;
4518 var event = seg.event;
4519 var eventEnd = calendar.getEventEnd(event);
4521 var resizeLocation; // zoned event date properties. falsy if invalid resize
4523 // Tracks mouse movement over the *grid's* coordinate map
4524 var dragListener = this.segResizeListener = new HitDragListener(this, {
4525 scroll: view.opt('dragScroll'),
4527 interactionStart: function() {
4530 dragStart: function(ev) {
4532 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
4533 _this.segResizeStart(seg, ev);
4535 hitOver: function(hit, isOrig, origHit) {
4536 var origHitSpan = _this.getHitSpan(origHit);
4537 var hitSpan = _this.getHitSpan(hit);
4539 resizeLocation = isStart ?
4540 _this.computeEventStartResize(origHitSpan, hitSpan, event) :
4541 _this.computeEventEndResize(origHitSpan, hitSpan, event);
4543 if (resizeLocation) {
4544 if (!calendar.isEventSpanAllowed(_this.eventToSpan(resizeLocation), event)) {
4546 resizeLocation = null;
4548 // no change? (FYI, event dates might have zones)
4550 resizeLocation.start.isSame(event.start.clone().stripZone()) &&
4551 resizeLocation.end.isSame(eventEnd.clone().stripZone())
4553 resizeLocation = null;
4557 if (resizeLocation) {
4558 view.hideEvent(event);
4559 _this.renderEventResize(resizeLocation, seg);
4562 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
4563 resizeLocation = null;
4565 hitDone: function() { // resets the rendering to show the original event
4566 _this.unrenderEventResize();
4567 view.showEvent(event);
4570 interactionEnd: function(ev) {
4572 _this.segResizeStop(seg, ev);
4574 if (resizeLocation) { // valid date to resize to?
4575 view.reportEventResize(event, resizeLocation, this.largeUnit, el, ev);
4577 _this.segResizeListener = null;
4581 return dragListener;
4585 // Called before event segment resizing starts
4586 segResizeStart: function(seg, ev) {
4587 this.isResizingSeg = true;
4588 this.view.trigger('eventResizeStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
4592 // Called after event segment resizing stops
4593 segResizeStop: function(seg, ev) {
4594 this.isResizingSeg = false;
4595 this.view.trigger('eventResizeStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
4599 // Returns new date-information for an event segment being resized from its start
4600 computeEventStartResize: function(startSpan, endSpan, event) {
4601 return this.computeEventResize('start', startSpan, endSpan, event);
4605 // Returns new date-information for an event segment being resized from its end
4606 computeEventEndResize: function(startSpan, endSpan, event) {
4607 return this.computeEventResize('end', startSpan, endSpan, event);
4611 // Returns new zoned date information for an event segment being resized from its start OR end
4612 // `type` is either 'start' or 'end'.
4613 // DOES NOT consider overlap/constraint.
4614 computeEventResize: function(type, startSpan, endSpan, event) {
4615 var calendar = this.view.calendar;
4616 var delta = this.diffDates(endSpan[type], startSpan[type]);
4617 var resizeLocation; // zoned event date properties
4618 var defaultDuration;
4620 // build original values to work from, guaranteeing a start and end
4622 start: event.start.clone(),
4623 end: calendar.getEventEnd(event),
4624 allDay: event.allDay
4627 // if an all-day event was in a timed area and was resized to a time, adjust start/end to have times
4628 if (resizeLocation.allDay && durationHasTime(delta)) {
4629 resizeLocation.allDay = false;
4630 calendar.normalizeEventTimes(resizeLocation);
4633 resizeLocation[type].add(delta); // apply delta to start or end
4635 // if the event was compressed too small, find a new reasonable duration for it
4636 if (!resizeLocation.start.isBefore(resizeLocation.end)) {
4639 this.minResizeDuration || // TODO: hack
4641 calendar.defaultAllDayEventDuration :
4642 calendar.defaultTimedEventDuration);
4644 if (type == 'start') { // resizing the start?
4645 resizeLocation.start = resizeLocation.end.clone().subtract(defaultDuration);
4647 else { // resizing the end?
4648 resizeLocation.end = resizeLocation.start.clone().add(defaultDuration);
4652 return resizeLocation;
4656 // Renders a visual indication of an event being resized.
4657 // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag.
4658 // Must return elements used for any mock events.
4659 renderEventResize: function(range, seg) {
4660 // subclasses must implement
4664 // Unrenders a visual indication of an event being resized.
4665 unrenderEventResize: function() {
4666 // subclasses must implement
4671 ------------------------------------------------------------------------------------------------------------------*/
4674 // Compute the text that should be displayed on an event's element.
4675 // `range` can be the Event object itself, or something range-like, with at least a `start`.
4676 // If event times are disabled, or the event has no time, will return a blank string.
4677 // If not specified, formatStr will default to the eventTimeFormat setting,
4678 // and displayEnd will default to the displayEventEnd setting.
4679 getEventTimeText: function(range, formatStr, displayEnd) {
4681 if (formatStr == null) {
4682 formatStr = this.eventTimeFormat;
4685 if (displayEnd == null) {
4686 displayEnd = this.displayEventEnd;
4689 if (this.displayEventTime && range.start.hasTime()) {
4690 if (displayEnd && range.end) {
4691 return this.view.formatRange(range, formatStr);
4694 return range.start.format(formatStr);
4702 // Generic utility for generating the HTML classNames for an event segment's element
4703 getSegClasses: function(seg, isDraggable, isResizable) {
4704 var view = this.view;
4707 seg.isStart ? 'fc-start' : 'fc-not-start',
4708 seg.isEnd ? 'fc-end' : 'fc-not-end'
4709 ].concat(this.getSegCustomClasses(seg));
4712 classes.push('fc-draggable');
4715 classes.push('fc-resizable');
4718 // event is currently selected? attach a className.
4719 if (view.isEventSelected(seg.event)) {
4720 classes.push('fc-selected');
4727 // List of classes that were defined by the caller of the API in some way
4728 getSegCustomClasses: function(seg) {
4729 var event = seg.event;
4732 event.className, // guaranteed to be an array
4733 event.source ? event.source.className : []
4738 // Utility for generating event skin-related CSS properties
4739 getSegSkinCss: function(seg) {
4741 'background-color': this.getSegBackgroundColor(seg),
4742 'border-color': this.getSegBorderColor(seg),
4743 color: this.getSegTextColor(seg)
4748 // Queries for caller-specified color, then falls back to default
4749 getSegBackgroundColor: function(seg) {
4750 return seg.event.backgroundColor ||
4752 this.getSegDefaultBackgroundColor(seg);
4756 getSegDefaultBackgroundColor: function(seg) {
4757 var source = seg.event.source || {};
4759 return source.backgroundColor ||
4761 this.view.opt('eventBackgroundColor') ||
4762 this.view.opt('eventColor');
4766 // Queries for caller-specified color, then falls back to default
4767 getSegBorderColor: function(seg) {
4768 return seg.event.borderColor ||
4770 this.getSegDefaultBorderColor(seg);
4774 getSegDefaultBorderColor: function(seg) {
4775 var source = seg.event.source || {};
4777 return source.borderColor ||
4779 this.view.opt('eventBorderColor') ||
4780 this.view.opt('eventColor');
4784 // Queries for caller-specified color, then falls back to default
4785 getSegTextColor: function(seg) {
4786 return seg.event.textColor ||
4787 this.getSegDefaultTextColor(seg);
4791 getSegDefaultTextColor: function(seg) {
4792 var source = seg.event.source || {};
4794 return source.textColor ||
4795 this.view.opt('eventTextColor');
4799 /* Converting events -> eventRange -> eventSpan -> eventSegs
4800 ------------------------------------------------------------------------------------------------------------------*/
4803 // Generates an array of segments for the given single event
4804 // Can accept an event "location" as well (which only has start/end and no allDay)
4805 eventToSegs: function(event) {
4806 return this.eventsToSegs([ event ]);
4810 eventToSpan: function(event) {
4811 return this.eventToSpans(event)[0];
4815 // Generates spans (always unzoned) for the given event.
4816 // Does not do any inverting for inverse-background events.
4817 // Can accept an event "location" as well (which only has start/end and no allDay)
4818 eventToSpans: function(event) {
4819 var range = this.eventToRange(event);
4820 return this.eventRangeToSpans(range, event);
4825 // Converts an array of event objects into an array of event segment objects.
4826 // A custom `segSliceFunc` may be given for arbitrarily slicing up events.
4827 // Doesn't guarantee an order for the resulting array.
4828 eventsToSegs: function(allEvents, segSliceFunc) {
4830 var eventsById = groupEventsById(allEvents);
4833 $.each(eventsById, function(id, events) {
4837 for (i = 0; i < events.length; i++) {
4838 ranges.push(_this.eventToRange(events[i]));
4841 // inverse-background events (utilize only the first event in calculations)
4842 if (isInverseBgEvent(events[0])) {
4843 ranges = _this.invertRanges(ranges);
4845 for (i = 0; i < ranges.length; i++) {
4846 segs.push.apply(segs, // append to
4847 _this.eventRangeToSegs(ranges[i], events[0], segSliceFunc));
4850 // normal event ranges
4852 for (i = 0; i < ranges.length; i++) {
4853 segs.push.apply(segs, // append to
4854 _this.eventRangeToSegs(ranges[i], events[i], segSliceFunc));
4863 // Generates the unzoned start/end dates an event appears to occupy
4864 // Can accept an event "location" as well (which only has start/end and no allDay)
4865 eventToRange: function(event) {
4866 var calendar = this.view.calendar;
4867 var start = event.start.clone().stripZone();
4871 // derive the end from the start and allDay. compute allDay if necessary
4872 calendar.getDefaultEventEnd(
4873 event.allDay != null ?
4875 !event.start.hasTime(),
4880 // hack: dynamic locale change forgets to upate stored event localed
4881 calendar.localizeMoment(start);
4882 calendar.localizeMoment(end);
4884 return { start: start, end: end };
4888 // Given an event's range (unzoned start/end), and the event itself,
4889 // slice into segments (using the segSliceFunc function if specified)
4890 eventRangeToSegs: function(range, event, segSliceFunc) {
4891 var spans = this.eventRangeToSpans(range, event);
4895 for (i = 0; i < spans.length; i++) {
4896 segs.push.apply(segs, // append to
4897 this.eventSpanToSegs(spans[i], event, segSliceFunc));
4904 // Given an event's unzoned date range, return an array of "span" objects.
4905 // Subclasses can override.
4906 eventRangeToSpans: function(range, event) {
4907 return [ $.extend({}, range) ]; // copy into a single-item array
4911 // Given an event's span (unzoned start/end and other misc data), and the event itself,
4912 // slices into segments and attaches event-derived properties to them.
4913 eventSpanToSegs: function(span, event, segSliceFunc) {
4914 var segs = segSliceFunc ? segSliceFunc(span) : this.spanToSegs(span);
4917 for (i = 0; i < segs.length; i++) {
4920 seg.eventStartMS = +span.start; // TODO: not the best name after making spans unzoned
4921 seg.eventDurationMS = span.end - span.start;
4928 // Produces a new array of range objects that will cover all the time NOT covered by the given ranges.
4929 // SIDE EFFECT: will mutate the given array and will use its date references.
4930 invertRanges: function(ranges) {
4931 var view = this.view;
4932 var viewStart = view.start.clone(); // need a copy
4933 var viewEnd = view.end.clone(); // need a copy
4934 var inverseRanges = [];
4935 var start = viewStart; // the end of the previous range. the start of the new range
4938 // ranges need to be in order. required for our date-walking algorithm
4939 ranges.sort(compareRanges);
4941 for (i = 0; i < ranges.length; i++) {
4944 // add the span of time before the event (if there is any)
4945 if (range.start > start) { // compare millisecond time (skip any ambig logic)
4946 inverseRanges.push({
4955 // add the span of time after the last event (if there is any)
4956 if (start < viewEnd) { // compare millisecond time (skip any ambig logic)
4957 inverseRanges.push({
4963 return inverseRanges;
4967 sortEventSegs: function(segs) {
4968 segs.sort(proxy(this, 'compareEventSegs'));
4972 // A cmp function for determining which segments should take visual priority
4973 compareEventSegs: function(seg1, seg2) {
4974 return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first
4975 seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first
4976 seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1)
4977 compareByFieldSpecs(seg1.event, seg2.event, this.view.eventOrderSpecs);
4984 ----------------------------------------------------------------------------------------------------------------------*/
4987 function pluckEventDateProps(event) {
4989 start: event.start.clone(),
4990 end: event.end ? event.end.clone() : null,
4991 allDay: event.allDay // keep it the same
4994 FC.pluckEventDateProps = pluckEventDateProps;
4997 function isBgEvent(event) { // returns true if background OR inverse-background
4998 var rendering = getEventRendering(event);
4999 return rendering === 'background' || rendering === 'inverse-background';
5001 FC.isBgEvent = isBgEvent; // export
5004 function isInverseBgEvent(event) {
5005 return getEventRendering(event) === 'inverse-background';
5009 function getEventRendering(event) {
5010 return firstDefined((event.source || {}).rendering, event.rendering);
5014 function groupEventsById(events) {
5015 var eventsById = {};
5018 for (i = 0; i < events.length; i++) {
5020 (eventsById[event._id] || (eventsById[event._id] = [])).push(event);
5027 // A cmp function for determining which non-inverted "ranges" (see above) happen earlier
5028 function compareRanges(range1, range2) {
5029 return range1.start - range2.start; // earlier ranges go first
5033 /* External-Dragging-Element Data
5034 ----------------------------------------------------------------------------------------------------------------------*/
5036 // Require all HTML5 data-* attributes used by FullCalendar to have this prefix.
5037 // A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event.
5038 FC.dataAttrPrefix = '';
5040 // Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure
5041 // to be used for Event Object creation.
5042 // A defined `.eventProps`, even when empty, indicates that an event should be created.
5043 function getDraggedElMeta(el) {
5044 var prefix = FC.dataAttrPrefix;
5045 var eventProps; // properties for creating the event, not related to date/time
5046 var startTime; // a Duration
5050 if (prefix) { prefix += '-'; }
5051 eventProps = el.data(prefix + 'event') || null;
5054 if (typeof eventProps === 'object') {
5055 eventProps = $.extend({}, eventProps); // make a copy
5057 else { // something like 1 or true. still signal event creation
5061 // pluck special-cased date/time properties
5062 startTime = eventProps.start;
5063 if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well
5064 duration = eventProps.duration;
5065 stick = eventProps.stick;
5066 delete eventProps.start;
5067 delete eventProps.time;
5068 delete eventProps.duration;
5069 delete eventProps.stick;
5072 // fallback to standalone attribute values for each of the date/time properties
5073 if (startTime == null) { startTime = el.data(prefix + 'start'); }
5074 if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well
5075 if (duration == null) { duration = el.data(prefix + 'duration'); }
5076 if (stick == null) { stick = el.data(prefix + 'stick'); }
5078 // massage into correct data types
5079 startTime = startTime != null ? moment.duration(startTime) : null;
5080 duration = duration != null ? moment.duration(duration) : null;
5081 stick = Boolean(stick);
5083 return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick };
5090 A set of rendering and date-related methods for a visual component comprised of one or more rows of day columns.
5091 Prerequisite: the object being mixed into needs to be a *Grid*
5093 var DayTableMixin = FC.DayTableMixin = {
5095 breakOnWeeks: false, // should create a new row for each week?
5096 dayDates: null, // whole-day dates for each column. left to right
5097 dayIndices: null, // for each day from start, the offset
5101 colHeadFormat: null,
5104 // Populates internal variables used for date calculation and rendering
5105 updateDayTable: function() {
5106 var view = this.view;
5107 var date = this.start.clone();
5109 var dayIndices = [];
5115 while (date.isBefore(this.end)) { // loop each day from start to end
5116 if (view.isHiddenDay(date)) {
5117 dayIndices.push(dayIndex + 0.5); // mark that it's between indices
5121 dayIndices.push(dayIndex);
5122 dayDates.push(date.clone());
5124 date.add(1, 'days');
5127 if (this.breakOnWeeks) {
5128 // count columns until the day-of-week repeats
5129 firstDay = dayDates[0].day();
5130 for (daysPerRow = 1; daysPerRow < dayDates.length; daysPerRow++) {
5131 if (dayDates[daysPerRow].day() == firstDay) {
5135 rowCnt = Math.ceil(dayDates.length / daysPerRow);
5139 daysPerRow = dayDates.length;
5142 this.dayDates = dayDates;
5143 this.dayIndices = dayIndices;
5144 this.daysPerRow = daysPerRow;
5145 this.rowCnt = rowCnt;
5147 this.updateDayTableCols();
5151 // Computes and assigned the colCnt property and updates any options that may be computed from it
5152 updateDayTableCols: function() {
5153 this.colCnt = this.computeColCnt();
5154 this.colHeadFormat = this.view.opt('columnFormat') || this.computeColHeadFormat();
5158 // Determines how many columns there should be in the table
5159 computeColCnt: function() {
5160 return this.daysPerRow;
5164 // Computes the ambiguously-timed moment for the given cell
5165 getCellDate: function(row, col) {
5166 return this.dayDates[
5167 this.getCellDayIndex(row, col)
5172 // Computes the ambiguously-timed date range for the given cell
5173 getCellRange: function(row, col) {
5174 var start = this.getCellDate(row, col);
5175 var end = start.clone().add(1, 'days');
5177 return { start: start, end: end };
5181 // Returns the number of day cells, chronologically, from the first of the grid (0-based)
5182 getCellDayIndex: function(row, col) {
5183 return row * this.daysPerRow + this.getColDayIndex(col);
5187 // Returns the numner of day cells, chronologically, from the first cell in *any given row*
5188 getColDayIndex: function(col) {
5190 return this.colCnt - 1 - col;
5198 // Given a date, returns its chronolocial cell-index from the first cell of the grid.
5199 // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets.
5200 // If before the first offset, returns a negative number.
5201 // If after the last offset, returns an offset past the last cell offset.
5202 // Only works for *start* dates of cells. Will not work for exclusive end dates for cells.
5203 getDateDayIndex: function(date) {
5204 var dayIndices = this.dayIndices;
5205 var dayOffset = date.diff(this.start, 'days');
5207 if (dayOffset < 0) {
5208 return dayIndices[0] - 1;
5210 else if (dayOffset >= dayIndices.length) {
5211 return dayIndices[dayIndices.length - 1] + 1;
5214 return dayIndices[dayOffset];
5220 ------------------------------------------------------------------------------------------------------------------*/
5223 // Computes a default column header formatting string if `colFormat` is not explicitly defined
5224 computeColHeadFormat: function() {
5225 // if more than one week row, or if there are a lot of columns with not much space,
5226 // put just the day numbers will be in each cell
5227 if (this.rowCnt > 1 || this.colCnt > 10) {
5228 return 'ddd'; // "Sat"
5230 // multiple days, so full single date string WON'T be in title text
5231 else if (this.colCnt > 1) {
5232 return this.view.opt('dayOfMonthFormat'); // "Sat 12/10"
5234 // single day, so full single date string will probably be in title text
5236 return 'dddd'; // "Saturday"
5242 ------------------------------------------------------------------------------------------------------------------*/
5245 // Slices up a date range into a segment for every week-row it intersects with
5246 sliceRangeByRow: function(range) {
5247 var daysPerRow = this.daysPerRow;
5248 var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold
5249 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index
5250 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index
5253 var rowFirst, rowLast; // inclusive day-index range for current row
5254 var segFirst, segLast; // inclusive day-index range for segment
5256 for (row = 0; row < this.rowCnt; row++) {
5257 rowFirst = row * daysPerRow;
5258 rowLast = rowFirst + daysPerRow - 1;
5260 // intersect segment's offset range with the row's
5261 segFirst = Math.max(rangeFirst, rowFirst);
5262 segLast = Math.min(rangeLast, rowLast);
5264 // deal with in-between indices
5265 segFirst = Math.ceil(segFirst); // in-between starts round to next cell
5266 segLast = Math.floor(segLast); // in-between ends round to prev cell
5268 if (segFirst <= segLast) { // was there any intersection with the current row?
5272 // normalize to start of row
5273 firstRowDayIndex: segFirst - rowFirst,
5274 lastRowDayIndex: segLast - rowFirst,
5276 // must be matching integers to be the segment's start/end
5277 isStart: segFirst === rangeFirst,
5278 isEnd: segLast === rangeLast
5287 // Slices up a date range into a segment for every day-cell it intersects with.
5288 // TODO: make more DRY with sliceRangeByRow somehow.
5289 sliceRangeByDay: function(range) {
5290 var daysPerRow = this.daysPerRow;
5291 var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold
5292 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index
5293 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index
5296 var rowFirst, rowLast; // inclusive day-index range for current row
5298 var segFirst, segLast; // inclusive day-index range for segment
5300 for (row = 0; row < this.rowCnt; row++) {
5301 rowFirst = row * daysPerRow;
5302 rowLast = rowFirst + daysPerRow - 1;
5304 for (i = rowFirst; i <= rowLast; i++) {
5306 // intersect segment's offset range with the row's
5307 segFirst = Math.max(rangeFirst, i);
5308 segLast = Math.min(rangeLast, i);
5310 // deal with in-between indices
5311 segFirst = Math.ceil(segFirst); // in-between starts round to next cell
5312 segLast = Math.floor(segLast); // in-between ends round to prev cell
5314 if (segFirst <= segLast) { // was there any intersection with the current row?
5318 // normalize to start of row
5319 firstRowDayIndex: segFirst - rowFirst,
5320 lastRowDayIndex: segLast - rowFirst,
5322 // must be matching integers to be the segment's start/end
5323 isStart: segFirst === rangeFirst,
5324 isEnd: segLast === rangeLast
5335 ------------------------------------------------------------------------------------------------------------------*/
5338 renderHeadHtml: function() {
5339 var view = this.view;
5342 '<div class="fc-row ' + view.widgetHeaderClass + '">' +
5345 this.renderHeadTrHtml() +
5352 renderHeadIntroHtml: function() {
5353 return this.renderIntroHtml(); // fall back to generic
5357 renderHeadTrHtml: function() {
5360 (this.isRTL ? '' : this.renderHeadIntroHtml()) +
5361 this.renderHeadDateCellsHtml() +
5362 (this.isRTL ? this.renderHeadIntroHtml() : '') +
5367 renderHeadDateCellsHtml: function() {
5371 for (col = 0; col < this.colCnt; col++) {
5372 date = this.getCellDate(0, col);
5373 htmls.push(this.renderHeadDateCellHtml(date));
5376 return htmls.join('');
5380 // TODO: when internalApiVersion, accept an object for HTML attributes
5381 // (colspan should be no different)
5382 renderHeadDateCellHtml: function(date, colspan, otherAttrs) {
5383 var view = this.view;
5386 '<th class="fc-day-header ' + view.widgetHeaderClass + ' fc-' + dayIDs[date.day()] + '"' +
5387 (this.rowCnt === 1 ?
5388 ' data-date="' + date.format('YYYY-MM-DD') + '"' :
5391 ' colspan="' + colspan + '"' :
5397 // don't make a link if the heading could represent multiple days, or if there's only one day (forceOff)
5398 view.buildGotoAnchorHtml(
5399 { date: date, forceOff: this.rowCnt > 1 || this.colCnt === 1 },
5400 htmlEscape(date.format(this.colHeadFormat)) // inner HTML
5406 /* Background Rendering
5407 ------------------------------------------------------------------------------------------------------------------*/
5410 renderBgTrHtml: function(row) {
5413 (this.isRTL ? '' : this.renderBgIntroHtml(row)) +
5414 this.renderBgCellsHtml(row) +
5415 (this.isRTL ? this.renderBgIntroHtml(row) : '') +
5420 renderBgIntroHtml: function(row) {
5421 return this.renderIntroHtml(); // fall back to generic
5425 renderBgCellsHtml: function(row) {
5429 for (col = 0; col < this.colCnt; col++) {
5430 date = this.getCellDate(row, col);
5431 htmls.push(this.renderBgCellHtml(date));
5434 return htmls.join('');
5438 renderBgCellHtml: function(date, otherAttrs) {
5439 var view = this.view;
5440 var classes = this.getDayClasses(date);
5442 classes.unshift('fc-day', view.widgetContentClass);
5444 return '<td class="' + classes.join(' ') + '"' +
5445 ' data-date="' + date.format('YYYY-MM-DD') + '"' + // if date has a time, won't format it
5454 ------------------------------------------------------------------------------------------------------------------*/
5457 // Generates the default HTML intro for any row. User classes should override
5458 renderIntroHtml: function() {
5462 // TODO: a generic method for dealing with <tr>, RTL, intro
5463 // when increment internalApiVersion
5464 // wrapTr (scheduler)
5468 ------------------------------------------------------------------------------------------------------------------*/
5471 // Applies the generic "intro" and "outro" HTML to the given cells.
5472 // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro.
5473 bookendCells: function(trEl) {
5474 var introHtml = this.renderIntroHtml();
5478 trEl.append(introHtml);
5481 trEl.prepend(introHtml);
5490 /* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week.
5491 ----------------------------------------------------------------------------------------------------------------------*/
5493 var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, {
5495 numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal
5496 bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid
5498 rowEls: null, // set of fake row elements
5499 cellEls: null, // set of whole-day elements comprising the row's background
5500 helperEls: null, // set of cell skeleton elements for rendering the mock event "helper"
5502 rowCoordCache: null,
5503 colCoordCache: null,
5506 // Renders the rows and columns into the component's `this.el`, which should already be assigned.
5507 // isRigid determins whether the individual rows should ignore the contents and be a constant height.
5508 // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient.
5509 renderDates: function(isRigid) {
5510 var view = this.view;
5511 var rowCnt = this.rowCnt;
5512 var colCnt = this.colCnt;
5517 for (row = 0; row < rowCnt; row++) {
5518 html += this.renderDayRowHtml(row, isRigid);
5522 this.rowEls = this.el.find('.fc-row');
5523 this.cellEls = this.el.find('.fc-day');
5525 this.rowCoordCache = new CoordCache({
5529 this.colCoordCache = new CoordCache({
5530 els: this.cellEls.slice(0, this.colCnt), // only the first row
5534 // trigger dayRender with each cell's element
5535 for (row = 0; row < rowCnt; row++) {
5536 for (col = 0; col < colCnt; col++) {
5540 this.getCellDate(row, col),
5541 this.getCellEl(row, col)
5548 unrenderDates: function() {
5549 this.removeSegPopover();
5553 renderBusinessHours: function() {
5554 var segs = this.buildBusinessHourSegs(true); // wholeDay=true
5555 this.renderFill('businessHours', segs, 'bgevent');
5559 unrenderBusinessHours: function() {
5560 this.unrenderFill('businessHours');
5564 // Generates the HTML for a single row, which is a div that wraps a table.
5565 // `row` is the row number.
5566 renderDayRowHtml: function(row, isRigid) {
5567 var view = this.view;
5568 var classes = [ 'fc-row', 'fc-week', view.widgetContentClass ];
5571 classes.push('fc-rigid');
5575 '<div class="' + classes.join(' ') + '">' +
5576 '<div class="fc-bg">' +
5578 this.renderBgTrHtml(row) +
5581 '<div class="fc-content-skeleton">' +
5583 (this.numbersVisible ?
5585 this.renderNumberTrHtml(row) +
5595 /* Grid Number Rendering
5596 ------------------------------------------------------------------------------------------------------------------*/
5599 renderNumberTrHtml: function(row) {
5602 (this.isRTL ? '' : this.renderNumberIntroHtml(row)) +
5603 this.renderNumberCellsHtml(row) +
5604 (this.isRTL ? this.renderNumberIntroHtml(row) : '') +
5609 renderNumberIntroHtml: function(row) {
5610 return this.renderIntroHtml();
5614 renderNumberCellsHtml: function(row) {
5618 for (col = 0; col < this.colCnt; col++) {
5619 date = this.getCellDate(row, col);
5620 htmls.push(this.renderNumberCellHtml(date));
5623 return htmls.join('');
5627 // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton.
5628 // The number row will only exist if either day numbers or week numbers are turned on.
5629 renderNumberCellHtml: function(date) {
5632 var weekCalcFirstDoW;
5634 if (!this.view.dayNumbersVisible && !this.view.cellWeekNumbersVisible) {
5635 // no numbers in day cell (week number must be along the side)
5636 return '<td/>'; // will create an empty space above events :(
5639 classes = this.getDayClasses(date);
5640 classes.unshift('fc-day-top');
5642 if (this.view.cellWeekNumbersVisible) {
5643 // To determine the day of week number change under ISO, we cannot
5644 // rely on moment.js methods such as firstDayOfWeek() or weekday(),
5645 // because they rely on the locale's dow (possibly overridden by
5646 // our firstDay option), which may not be Monday. We cannot change
5647 // dow, because that would affect the calendar start day as well.
5648 if (date._locale._fullCalendar_weekCalc === 'ISO') {
5649 weekCalcFirstDoW = 1; // Monday by ISO 8601 definition
5652 weekCalcFirstDoW = date._locale.firstDayOfWeek();
5656 html += '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '">';
5658 if (this.view.cellWeekNumbersVisible && (date.day() == weekCalcFirstDoW)) {
5659 html += this.view.buildGotoAnchorHtml(
5660 { date: date, type: 'week' },
5661 { 'class': 'fc-week-number' },
5662 date.format('w') // inner HTML
5666 if (this.view.dayNumbersVisible) {
5667 html += this.view.buildGotoAnchorHtml(
5669 { 'class': 'fc-day-number' },
5670 date.date() // inner HTML
5681 ------------------------------------------------------------------------------------------------------------------*/
5684 // Computes a default event time formatting string if `timeFormat` is not explicitly defined
5685 computeEventTimeFormat: function() {
5686 return this.view.opt('extraSmallTimeFormat'); // like "6p" or "6:30p"
5690 // Computes a default `displayEventEnd` value if one is not expliclty defined
5691 computeDisplayEventEnd: function() {
5692 return this.colCnt == 1; // we'll likely have space if there's only one day
5697 ------------------------------------------------------------------------------------------------------------------*/
5700 rangeUpdated: function() {
5701 this.updateDayTable();
5705 // Slices up the given span (unzoned start/end with other misc data) into an array of segments
5706 spanToSegs: function(span) {
5707 var segs = this.sliceRangeByRow(span);
5710 for (i = 0; i < segs.length; i++) {
5713 seg.leftCol = this.daysPerRow - 1 - seg.lastRowDayIndex;
5714 seg.rightCol = this.daysPerRow - 1 - seg.firstRowDayIndex;
5717 seg.leftCol = seg.firstRowDayIndex;
5718 seg.rightCol = seg.lastRowDayIndex;
5727 ------------------------------------------------------------------------------------------------------------------*/
5730 prepareHits: function() {
5731 this.colCoordCache.build();
5732 this.rowCoordCache.build();
5733 this.rowCoordCache.bottoms[this.rowCnt - 1] += this.bottomCoordPadding; // hack
5737 releaseHits: function() {
5738 this.colCoordCache.clear();
5739 this.rowCoordCache.clear();
5743 queryHit: function(leftOffset, topOffset) {
5744 if (this.colCoordCache.isLeftInBounds(leftOffset) && this.rowCoordCache.isTopInBounds(topOffset)) {
5745 var col = this.colCoordCache.getHorizontalIndex(leftOffset);
5746 var row = this.rowCoordCache.getVerticalIndex(topOffset);
5748 if (row != null && col != null) {
5749 return this.getCellHit(row, col);
5755 getHitSpan: function(hit) {
5756 return this.getCellRange(hit.row, hit.col);
5760 getHitEl: function(hit) {
5761 return this.getCellEl(hit.row, hit.col);
5766 ------------------------------------------------------------------------------------------------------------------*/
5767 // FYI: the first column is the leftmost column, regardless of date
5770 getCellHit: function(row, col) {
5774 component: this, // needed unfortunately :(
5775 left: this.colCoordCache.getLeftOffset(col),
5776 right: this.colCoordCache.getRightOffset(col),
5777 top: this.rowCoordCache.getTopOffset(row),
5778 bottom: this.rowCoordCache.getBottomOffset(row)
5783 getCellEl: function(row, col) {
5784 return this.cellEls.eq(row * this.colCnt + col);
5788 /* Event Drag Visualization
5789 ------------------------------------------------------------------------------------------------------------------*/
5790 // TODO: move to DayGrid.event, similar to what we did with Grid's drag methods
5793 // Renders a visual indication of an event or external element being dragged.
5794 // `eventLocation` has zoned start and end (optional)
5795 renderDrag: function(eventLocation, seg) {
5797 // always render a highlight underneath
5798 this.renderHighlight(this.eventToSpan(eventLocation));
5800 // if a segment from the same calendar but another component is being dragged, render a helper event
5801 if (seg && seg.component !== this) {
5802 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
5807 // Unrenders any visual indication of a hovering event
5808 unrenderDrag: function() {
5809 this.unrenderHighlight();
5810 this.unrenderHelper();
5814 /* Event Resize Visualization
5815 ------------------------------------------------------------------------------------------------------------------*/
5818 // Renders a visual indication of an event being resized
5819 renderEventResize: function(eventLocation, seg) {
5820 this.renderHighlight(this.eventToSpan(eventLocation));
5821 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
5825 // Unrenders a visual indication of an event being resized
5826 unrenderEventResize: function() {
5827 this.unrenderHighlight();
5828 this.unrenderHelper();
5833 ------------------------------------------------------------------------------------------------------------------*/
5836 // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null.
5837 renderHelper: function(event, sourceSeg) {
5838 var helperNodes = [];
5839 var segs = this.eventToSegs(event);
5842 segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered
5843 rowStructs = this.renderSegRows(segs);
5845 // inject each new event skeleton into each associated row
5846 this.rowEls.each(function(row, rowNode) {
5847 var rowEl = $(rowNode); // the .fc-row
5848 var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned
5851 // If there is an original segment, match the top position. Otherwise, put it at the row's top level
5852 if (sourceSeg && sourceSeg.row === row) {
5853 skeletonTop = sourceSeg.el.position().top;
5856 skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top;
5859 skeletonEl.css('top', skeletonTop)
5861 .append(rowStructs[row].tbodyEl);
5863 rowEl.append(skeletonEl);
5864 helperNodes.push(skeletonEl[0]);
5867 return ( // must return the elements rendered
5868 this.helperEls = $(helperNodes) // array -> jQuery set
5873 // Unrenders any visual indication of a mock helper event
5874 unrenderHelper: function() {
5875 if (this.helperEls) {
5876 this.helperEls.remove();
5877 this.helperEls = null;
5882 /* Fill System (highlight, background events, business hours)
5883 ------------------------------------------------------------------------------------------------------------------*/
5886 fillSegTag: 'td', // override the default tag name
5889 // Renders a set of rectangles over the given segments of days.
5890 // Only returns segments that successfully rendered.
5891 renderFill: function(type, segs, className) {
5896 segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs
5898 for (i = 0; i < segs.length; i++) {
5900 skeletonEl = this.renderFillRow(type, seg, className);
5901 this.rowEls.eq(seg.row).append(skeletonEl);
5902 nodes.push(skeletonEl[0]);
5905 this.elsByFill[type] = $(nodes);
5911 // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered.
5912 renderFillRow: function(type, seg, className) {
5913 var colCnt = this.colCnt;
5914 var startCol = seg.leftCol;
5915 var endCol = seg.rightCol + 1;
5919 className = className || type.toLowerCase();
5922 '<div class="fc-' + className + '-skeleton">' +
5923 '<table><tr/></table>' +
5926 trEl = skeletonEl.find('tr');
5929 trEl.append('<td colspan="' + startCol + '"/>');
5933 seg.el.attr('colspan', endCol - startCol)
5936 if (endCol < colCnt) {
5937 trEl.append('<td colspan="' + (colCnt - endCol) + '"/>');
5940 this.bookendCells(trEl);
5949 /* Event-rendering methods for the DayGrid class
5950 ----------------------------------------------------------------------------------------------------------------------*/
5954 rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering
5957 // Unrenders all events currently rendered on the grid
5958 unrenderEvents: function() {
5959 this.removeSegPopover(); // removes the "more.." events popover
5960 Grid.prototype.unrenderEvents.apply(this, arguments); // calls the super-method
5964 // Retrieves all rendered segment objects currently rendered on the grid
5965 getEventSegs: function() {
5966 return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method
5967 .concat(this.popoverSegs || []); // append the segments from the "more..." popover
5971 // Renders the given background event segments onto the grid
5972 renderBgSegs: function(segs) {
5974 // don't render timed background events
5975 var allDaySegs = $.grep(segs, function(seg) {
5976 return seg.event.allDay;
5979 return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method
5983 // Renders the given foreground event segments onto the grid
5984 renderFgSegs: function(segs) {
5987 // render an `.el` on each seg
5988 // returns a subset of the segs. segs that were actually rendered
5989 segs = this.renderFgSegEls(segs);
5991 rowStructs = this.rowStructs = this.renderSegRows(segs);
5993 // append to each row's content skeleton
5994 this.rowEls.each(function(i, rowNode) {
5995 $(rowNode).find('.fc-content-skeleton > table').append(
5996 rowStructs[i].tbodyEl
6000 return segs; // return only the segs that were actually rendered
6004 // Unrenders all currently rendered foreground event segments
6005 unrenderFgSegs: function() {
6006 var rowStructs = this.rowStructs || [];
6009 while ((rowStruct = rowStructs.pop())) {
6010 rowStruct.tbodyEl.remove();
6013 this.rowStructs = null;
6017 // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton.
6018 // Returns an array of rowStruct objects (see the bottom of `renderSegRow`).
6019 // PRECONDITION: each segment shoud already have a rendered and assigned `.el`
6020 renderSegRows: function(segs) {
6021 var rowStructs = [];
6025 segRows = this.groupSegRows(segs); // group into nested arrays
6027 // iterate each row of segment groupings
6028 for (row = 0; row < segRows.length; row++) {
6030 this.renderSegRow(row, segRows[row])
6038 // Builds the HTML to be used for the default element for an individual segment
6039 fgSegHtml: function(seg, disableResizing) {
6040 var view = this.view;
6041 var event = seg.event;
6042 var isDraggable = view.isEventDraggable(event);
6043 var isResizableFromStart = !disableResizing && event.allDay &&
6044 seg.isStart && view.isEventResizableFromStart(event);
6045 var isResizableFromEnd = !disableResizing && event.allDay &&
6046 seg.isEnd && view.isEventResizableFromEnd(event);
6047 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd);
6048 var skinCss = cssToStr(this.getSegSkinCss(seg));
6053 classes.unshift('fc-day-grid-event', 'fc-h-event');
6055 // Only display a timed events time if it is the starting segment
6057 timeText = this.getEventTimeText(event);
6059 timeHtml = '<span class="fc-time">' + htmlEscape(timeText) + '</span>';
6064 '<span class="fc-title">' +
6065 (htmlEscape(event.title || '') || ' ') + // we always want one line of height
6068 return '<a class="' + classes.join(' ') + '"' +
6070 ' href="' + htmlEscape(event.url) + '"' :
6074 ' style="' + skinCss + '"' :
6078 '<div class="fc-content">' +
6080 titleHtml + ' ' + timeHtml : // put a natural space in between
6081 timeHtml + ' ' + titleHtml //
6084 (isResizableFromStart ?
6085 '<div class="fc-resizer fc-start-resizer" />' :
6088 (isResizableFromEnd ?
6089 '<div class="fc-resizer fc-end-resizer" />' :
6096 // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains
6097 // the segments. Returns object with a bunch of internal data about how the render was calculated.
6098 // NOTE: modifies rowSegs
6099 renderSegRow: function(row, rowSegs) {
6100 var colCnt = this.colCnt;
6101 var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels
6102 var levelCnt = Math.max(1, segLevels.length); // ensure at least one level
6103 var tbody = $('<tbody/>');
6104 var segMatrix = []; // lookup for which segments are rendered into which level+col cells
6105 var cellMatrix = []; // lookup for all <td> elements of the level+col matrix
6106 var loneCellMatrix = []; // lookup for <td> elements that only take up a single column
6113 // populates empty cells from the current column (`col`) to `endCol`
6114 function emptyCellsUntil(endCol) {
6115 while (col < endCol) {
6116 // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell
6117 td = (loneCellMatrix[i - 1] || [])[col];
6121 parseInt(td.attr('rowspan') || 1, 10) + 1
6128 cellMatrix[i][col] = td;
6129 loneCellMatrix[i][col] = td;
6134 for (i = 0; i < levelCnt; i++) { // iterate through all levels
6135 levelSegs = segLevels[i];
6140 cellMatrix.push([]);
6141 loneCellMatrix.push([]);
6143 // levelCnt might be 1 even though there are no actual levels. protect against this.
6144 // this single empty row is useful for styling.
6146 for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level
6149 emptyCellsUntil(seg.leftCol);
6151 // create a container that occupies or more columns. append the event element.
6152 td = $('<td class="fc-event-container"/>').append(seg.el);
6153 if (seg.leftCol != seg.rightCol) {
6154 td.attr('colspan', seg.rightCol - seg.leftCol + 1);
6156 else { // a single-column segment
6157 loneCellMatrix[i][col] = td;
6160 while (col <= seg.rightCol) {
6161 cellMatrix[i][col] = td;
6162 segMatrix[i][col] = seg;
6170 emptyCellsUntil(colCnt); // finish off the row
6171 this.bookendCells(tr);
6175 return { // a "rowStruct"
6176 row: row, // the row number
6178 cellMatrix: cellMatrix,
6179 segMatrix: segMatrix,
6180 segLevels: segLevels,
6186 // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels.
6187 // NOTE: modifies segs
6188 buildSegLevels: function(segs) {
6193 // Give preference to elements with certain criteria, so they have
6194 // a chance to be closer to the top.
6195 this.sortEventSegs(segs);
6197 for (i = 0; i < segs.length; i++) {
6200 // loop through levels, starting with the topmost, until the segment doesn't collide with other segments
6201 for (j = 0; j < levels.length; j++) {
6202 if (!isDaySegCollision(seg, levels[j])) {
6206 // `j` now holds the desired subrow index
6209 // create new level array if needed and append segment
6210 (levels[j] || (levels[j] = [])).push(seg);
6213 // order segments left-to-right. very important if calendar is RTL
6214 for (j = 0; j < levels.length; j++) {
6215 levels[j].sort(compareDaySegCols);
6222 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row
6223 groupSegRows: function(segs) {
6227 for (i = 0; i < this.rowCnt; i++) {
6231 for (i = 0; i < segs.length; i++) {
6232 segRows[segs[i].row].push(segs[i]);
6241 // Computes whether two segments' columns collide. They are assumed to be in the same row.
6242 function isDaySegCollision(seg, otherSegs) {
6245 for (i = 0; i < otherSegs.length; i++) {
6246 otherSeg = otherSegs[i];
6249 otherSeg.leftCol <= seg.rightCol &&
6250 otherSeg.rightCol >= seg.leftCol
6260 // A cmp function for determining the leftmost event
6261 function compareDaySegCols(a, b) {
6262 return a.leftCol - b.leftCol;
6267 /* Methods relate to limiting the number events for a given day on a DayGrid
6268 ----------------------------------------------------------------------------------------------------------------------*/
6269 // NOTE: all the segs being passed around in here are foreground segs
6273 segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible
6274 popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible
6277 removeSegPopover: function() {
6278 if (this.segPopover) {
6279 this.segPopover.hide(); // in handler, will call segPopover's removeElement
6284 // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid.
6285 // `levelLimit` can be false (don't limit), a number, or true (should be computed).
6286 limitRows: function(levelLimit) {
6287 var rowStructs = this.rowStructs || [];
6291 for (row = 0; row < rowStructs.length; row++) {
6292 this.unlimitRow(row);
6295 rowLevelLimit = false;
6297 else if (typeof levelLimit === 'number') {
6298 rowLevelLimit = levelLimit;
6301 rowLevelLimit = this.computeRowLevelLimit(row);
6304 if (rowLevelLimit !== false) {
6305 this.limitRow(row, rowLevelLimit);
6311 // Computes the number of levels a row will accomodate without going outside its bounds.
6312 // Assumes the row is "rigid" (maintains a constant height regardless of what is inside).
6313 // `row` is the row number.
6314 computeRowLevelLimit: function(row) {
6315 var rowEl = this.rowEls.eq(row); // the containing "fake" row div
6316 var rowHeight = rowEl.height(); // TODO: cache somehow?
6317 var trEls = this.rowStructs[row].tbodyEl.children();
6321 function iterInnerHeights(i, childNode) {
6322 trHeight = Math.max(trHeight, $(childNode).outerHeight());
6325 // Reveal one level <tr> at a time and stop when we find one out of bounds
6326 for (i = 0; i < trEls.length; i++) {
6327 trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal)
6329 // with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell,
6330 // so instead, find the tallest inner content element.
6332 trEl.find('> td > :first-child').each(iterInnerHeights);
6334 if (trEl.position().top + trHeight > rowHeight) {
6339 return false; // should not limit at all
6343 // Limits the given grid row to the maximum number of levels and injects "more" links if necessary.
6344 // `row` is the row number.
6345 // `levelLimit` is a number for the maximum (inclusive) number of levels allowed.
6346 limitRow: function(row, levelLimit) {
6348 var rowStruct = this.rowStructs[row];
6349 var moreNodes = []; // array of "more" <a> links and <td> DOM nodes
6350 var col = 0; // col #, left-to-right (not chronologically)
6351 var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right
6352 var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row
6353 var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes
6355 var segsBelow; // array of segment objects below `seg` in the current `col`
6356 var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies
6357 var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column)
6359 var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell
6361 var moreTd, moreWrap, moreLink;
6363 // Iterates through empty level cells and places "more" links inside if need be
6364 function emptyCellsUntil(endCol) { // goes from current `col` to `endCol`
6365 while (col < endCol) {
6366 segsBelow = _this.getCellSegs(row, col, levelLimit);
6367 if (segsBelow.length) {
6368 td = cellMatrix[levelLimit - 1][col];
6369 moreLink = _this.renderMoreLink(row, col, segsBelow);
6370 moreWrap = $('<div/>').append(moreLink);
6371 td.append(moreWrap);
6372 moreNodes.push(moreWrap[0]);
6378 if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit?
6379 levelSegs = rowStruct.segLevels[levelLimit - 1];
6380 cellMatrix = rowStruct.cellMatrix;
6382 limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit
6383 .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array
6385 // iterate though segments in the last allowable level
6386 for (i = 0; i < levelSegs.length; i++) {
6388 emptyCellsUntil(seg.leftCol); // process empty cells before the segment
6390 // determine *all* segments below `seg` that occupy the same columns
6393 while (col <= seg.rightCol) {
6394 segsBelow = this.getCellSegs(row, col, levelLimit);
6395 colSegsBelow.push(segsBelow);
6396 totalSegsBelow += segsBelow.length;
6400 if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links?
6401 td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell
6402 rowspan = td.attr('rowspan') || 1;
6405 // make a replacement <td> for each column the segment occupies. will be one for each colspan
6406 for (j = 0; j < colSegsBelow.length; j++) {
6407 moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan);
6408 segsBelow = colSegsBelow[j];
6409 moreLink = this.renderMoreLink(
6412 [ seg ].concat(segsBelow) // count seg as hidden too
6414 moreWrap = $('<div/>').append(moreLink);
6415 moreTd.append(moreWrap);
6416 segMoreNodes.push(moreTd[0]);
6417 moreNodes.push(moreTd[0]);
6420 td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements
6421 limitedNodes.push(td[0]);
6425 emptyCellsUntil(this.colCnt); // finish off the level
6426 rowStruct.moreEls = $(moreNodes); // for easy undoing later
6427 rowStruct.limitedEls = $(limitedNodes); // for easy undoing later
6432 // Reveals all levels and removes all "more"-related elements for a grid's row.
6433 // `row` is a row number.
6434 unlimitRow: function(row) {
6435 var rowStruct = this.rowStructs[row];
6437 if (rowStruct.moreEls) {
6438 rowStruct.moreEls.remove();
6439 rowStruct.moreEls = null;
6442 if (rowStruct.limitedEls) {
6443 rowStruct.limitedEls.removeClass('fc-limited');
6444 rowStruct.limitedEls = null;
6449 // Renders an <a> element that represents hidden event element for a cell.
6450 // Responsible for attaching click handler as well.
6451 renderMoreLink: function(row, col, hiddenSegs) {
6453 var view = this.view;
6455 return $('<a class="fc-more"/>')
6457 this.getMoreLinkText(hiddenSegs.length)
6459 .on('click', function(ev) {
6460 var clickOption = view.opt('eventLimitClick');
6461 var date = _this.getCellDate(row, col);
6462 var moreEl = $(this);
6463 var dayEl = _this.getCellEl(row, col);
6464 var allSegs = _this.getCellSegs(row, col);
6466 // rescope the segments to be within the cell's date
6467 var reslicedAllSegs = _this.resliceDaySegs(allSegs, date);
6468 var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date);
6470 if (typeof clickOption === 'function') {
6471 // the returned value can be an atomic option
6472 clickOption = view.trigger('eventLimitClick', null, {
6476 segs: reslicedAllSegs,
6477 hiddenSegs: reslicedHiddenSegs
6481 if (clickOption === 'popover') {
6482 _this.showSegPopover(row, col, moreEl, reslicedAllSegs);
6484 else if (typeof clickOption === 'string') { // a view name
6485 view.calendar.zoomTo(date, clickOption);
6491 // Reveals the popover that displays all events within a cell
6492 showSegPopover: function(row, col, moreLink, segs) {
6494 var view = this.view;
6495 var moreWrap = moreLink.parent(); // the <div> wrapper around the <a>
6496 var topEl; // the element we want to match the top coordinate of
6499 if (this.rowCnt == 1) {
6500 topEl = view.el; // will cause the popover to cover any sort of header
6503 topEl = this.rowEls.eq(row); // will align with top of row
6507 className: 'fc-more-popover',
6508 content: this.renderSegPopoverContent(row, col, segs),
6509 parentEl: this.view.el, // attach to root of view. guarantees outside of scrollbars.
6510 top: topEl.offset().top,
6511 autoHide: true, // when the user clicks elsewhere, hide the popover
6512 viewportConstrain: view.opt('popoverViewportConstrain'),
6514 // kill everything when the popover is hidden
6515 _this.segPopover.removeElement();
6516 _this.segPopover = null;
6517 _this.popoverSegs = null;
6521 // Determine horizontal coordinate.
6522 // We use the moreWrap instead of the <td> to avoid border confusion.
6524 options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border
6527 options.left = moreWrap.offset().left - 1; // -1 to be over cell border
6530 this.segPopover = new Popover(options);
6531 this.segPopover.show();
6533 // the popover doesn't live within the grid's container element, and thus won't get the event
6534 // delegated-handlers for free. attach event-related handlers to the popover.
6535 this.bindSegHandlersToEl(this.segPopover.el);
6539 // Builds the inner DOM contents of the segment popover
6540 renderSegPopoverContent: function(row, col, segs) {
6541 var view = this.view;
6542 var isTheme = view.opt('theme');
6543 var title = this.getCellDate(row, col).format(view.opt('dayPopoverFormat'));
6545 '<div class="fc-header ' + view.widgetHeaderClass + '">' +
6546 '<span class="fc-close ' +
6547 (isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') +
6549 '<span class="fc-title">' +
6552 '<div class="fc-clear"/>' +
6554 '<div class="fc-body ' + view.widgetContentClass + '">' +
6555 '<div class="fc-event-container"></div>' +
6558 var segContainer = content.find('.fc-event-container');
6561 // render each seg's `el` and only return the visible segs
6562 segs = this.renderFgSegEls(segs, true); // disableResizing=true
6563 this.popoverSegs = segs;
6565 for (i = 0; i < segs.length; i++) {
6567 // because segments in the popover are not part of a grid coordinate system, provide a hint to any
6568 // grids that want to do drag-n-drop about which cell it came from
6570 segs[i].hit = this.getCellHit(row, col);
6573 segContainer.append(segs[i].el);
6580 // Given the events within an array of segment objects, reslice them to be in a single day
6581 resliceDaySegs: function(segs, dayDate) {
6583 // build an array of the original events
6584 var events = $.map(segs, function(seg) {
6588 var dayStart = dayDate.clone();
6589 var dayEnd = dayStart.clone().add(1, 'days');
6590 var dayRange = { start: dayStart, end: dayEnd };
6592 // slice the events with a custom slicing function
6593 segs = this.eventsToSegs(
6596 var seg = intersectRanges(range, dayRange); // undefind if no intersection
6597 return seg ? [ seg ] : []; // must return an array of segments
6601 // force an order because eventsToSegs doesn't guarantee one
6602 this.sortEventSegs(segs);
6608 // Generates the text that should be inside a "more" link, given the number of events it represents
6609 getMoreLinkText: function(num) {
6610 var opt = this.view.opt('eventLimitText');
6612 if (typeof opt === 'function') {
6616 return '+' + num + ' ' + opt;
6621 // Returns segments within a given cell.
6622 // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs.
6623 getCellSegs: function(row, col, startLevel) {
6624 var segMatrix = this.rowStructs[row].segMatrix;
6625 var level = startLevel || 0;
6629 while (level < segMatrix.length) {
6630 seg = segMatrix[level][col];
6644 /* A component that renders one or more columns of vertical time slots
6645 ----------------------------------------------------------------------------------------------------------------------*/
6646 // We mixin DayTable, even though there is only a single row of days
6648 var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
6650 slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines
6651 snapDuration: null, // granularity of time for dragging and selecting
6653 minTime: null, // Duration object that denotes the first visible time of any given day
6654 maxTime: null, // Duration object that denotes the exclusive visible end time of any given day
6655 labelFormat: null, // formatting string for times running along vertical axis
6656 labelInterval: null, // duration of how often a label should be displayed for a slot
6658 colEls: null, // cells elements in the day-row background
6659 slatContainerEl: null, // div that wraps all the slat rows
6660 slatEls: null, // elements running horizontally across all columns
6661 nowIndicatorEls: null,
6663 colCoordCache: null,
6664 slatCoordCache: null,
6667 constructor: function() {
6668 Grid.apply(this, arguments); // call the super-constructor
6670 this.processOptions();
6674 // Renders the time grid into `this.el`, which should already be assigned.
6675 // Relies on the view's colCnt. In the future, this component should probably be self-sufficient.
6676 renderDates: function() {
6677 this.el.html(this.renderHtml());
6678 this.colEls = this.el.find('.fc-day');
6679 this.slatContainerEl = this.el.find('.fc-slats');
6680 this.slatEls = this.slatContainerEl.find('tr');
6682 this.colCoordCache = new CoordCache({
6686 this.slatCoordCache = new CoordCache({
6691 this.renderContentSkeleton();
6695 // Renders the basic HTML skeleton for the grid
6696 renderHtml: function() {
6698 '<div class="fc-bg">' +
6700 this.renderBgTrHtml(0) + // row=0
6703 '<div class="fc-slats">' +
6705 this.renderSlatRowHtml() +
6711 // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL.
6712 renderSlatRowHtml: function() {
6713 var view = this.view;
6714 var isRTL = this.isRTL;
6716 var slotTime = moment.duration(+this.minTime); // wish there was .clone() for durations
6717 var slotDate; // will be on the view's first day, but we only care about its time
6721 // Calculate the time for each slot
6722 while (slotTime < this.maxTime) {
6723 slotDate = this.start.clone().time(slotTime);
6724 isLabeled = isInt(divideDurationByDuration(slotTime, this.labelInterval));
6727 '<td class="fc-axis fc-time ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' +
6729 '<span>' + // for matchCellWidths
6730 htmlEscape(slotDate.format(this.labelFormat)) +
6737 '<tr data-time="' + slotDate.format('HH:mm:ss') + '"' +
6738 (isLabeled ? '' : ' class="fc-minor"') +
6740 (!isRTL ? axisHtml : '') +
6741 '<td class="' + view.widgetContentClass + '"/>' +
6742 (isRTL ? axisHtml : '') +
6745 slotTime.add(this.slotDuration);
6753 ------------------------------------------------------------------------------------------------------------------*/
6756 // Parses various options into properties of this object
6757 processOptions: function() {
6758 var view = this.view;
6759 var slotDuration = view.opt('slotDuration');
6760 var snapDuration = view.opt('snapDuration');
6763 slotDuration = moment.duration(slotDuration);
6764 snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration;
6766 this.slotDuration = slotDuration;
6767 this.snapDuration = snapDuration;
6768 this.snapsPerSlot = slotDuration / snapDuration; // TODO: ensure an integer multiple?
6770 this.minResizeDuration = snapDuration; // hack
6772 this.minTime = moment.duration(view.opt('minTime'));
6773 this.maxTime = moment.duration(view.opt('maxTime'));
6775 // might be an array value (for TimelineView).
6776 // if so, getting the most granular entry (the last one probably).
6777 input = view.opt('slotLabelFormat');
6778 if ($.isArray(input)) {
6779 input = input[input.length - 1];
6784 view.opt('smallTimeFormat'); // the computed default
6786 input = view.opt('slotLabelInterval');
6787 this.labelInterval = input ?
6788 moment.duration(input) :
6789 this.computeLabelInterval(slotDuration);
6793 // Computes an automatic value for slotLabelInterval
6794 computeLabelInterval: function(slotDuration) {
6799 // find the smallest stock label interval that results in more than one slots-per-label
6800 for (i = AGENDA_STOCK_SUB_DURATIONS.length - 1; i >= 0; i--) {
6801 labelInterval = moment.duration(AGENDA_STOCK_SUB_DURATIONS[i]);
6802 slotsPerLabel = divideDurationByDuration(labelInterval, slotDuration);
6803 if (isInt(slotsPerLabel) && slotsPerLabel > 1) {
6804 return labelInterval;
6808 return moment.duration(slotDuration); // fall back. clone
6812 // Computes a default event time formatting string if `timeFormat` is not explicitly defined
6813 computeEventTimeFormat: function() {
6814 return this.view.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM)
6818 // Computes a default `displayEventEnd` value if one is not expliclty defined
6819 computeDisplayEventEnd: function() {
6825 ------------------------------------------------------------------------------------------------------------------*/
6828 prepareHits: function() {
6829 this.colCoordCache.build();
6830 this.slatCoordCache.build();
6834 releaseHits: function() {
6835 this.colCoordCache.clear();
6836 // NOTE: don't clear slatCoordCache because we rely on it for computeTimeTop
6840 queryHit: function(leftOffset, topOffset) {
6841 var snapsPerSlot = this.snapsPerSlot;
6842 var colCoordCache = this.colCoordCache;
6843 var slatCoordCache = this.slatCoordCache;
6845 if (colCoordCache.isLeftInBounds(leftOffset) && slatCoordCache.isTopInBounds(topOffset)) {
6846 var colIndex = colCoordCache.getHorizontalIndex(leftOffset);
6847 var slatIndex = slatCoordCache.getVerticalIndex(topOffset);
6849 if (colIndex != null && slatIndex != null) {
6850 var slatTop = slatCoordCache.getTopOffset(slatIndex);
6851 var slatHeight = slatCoordCache.getHeight(slatIndex);
6852 var partial = (topOffset - slatTop) / slatHeight; // floating point number between 0 and 1
6853 var localSnapIndex = Math.floor(partial * snapsPerSlot); // the snap # relative to start of slat
6854 var snapIndex = slatIndex * snapsPerSlot + localSnapIndex;
6855 var snapTop = slatTop + (localSnapIndex / snapsPerSlot) * slatHeight;
6856 var snapBottom = slatTop + ((localSnapIndex + 1) / snapsPerSlot) * slatHeight;
6861 component: this, // needed unfortunately :(
6862 left: colCoordCache.getLeftOffset(colIndex),
6863 right: colCoordCache.getRightOffset(colIndex),
6872 getHitSpan: function(hit) {
6873 var start = this.getCellDate(0, hit.col); // row=0
6874 var time = this.computeSnapTime(hit.snap); // pass in the snap-index
6878 end = start.clone().add(this.snapDuration);
6880 return { start: start, end: end };
6884 getHitEl: function(hit) {
6885 return this.colEls.eq(hit.col);
6890 ------------------------------------------------------------------------------------------------------------------*/
6893 rangeUpdated: function() {
6894 this.updateDayTable();
6898 // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day
6899 computeSnapTime: function(snapIndex) {
6900 return moment.duration(this.minTime + this.snapDuration * snapIndex);
6904 // Slices up the given span (unzoned start/end with other misc data) into an array of segments
6905 spanToSegs: function(span) {
6906 var segs = this.sliceRangeByTimes(span);
6909 for (i = 0; i < segs.length; i++) {
6911 segs[i].col = this.daysPerRow - 1 - segs[i].dayIndex;
6914 segs[i].col = segs[i].dayIndex;
6922 sliceRangeByTimes: function(range) {
6929 for (dayIndex = 0; dayIndex < this.daysPerRow; dayIndex++) {
6930 dayDate = this.dayDates[dayIndex].clone(); // TODO: better API for this?
6932 start: dayDate.clone().time(this.minTime),
6933 end: dayDate.clone().time(this.maxTime)
6935 seg = intersectRanges(range, dayRange); // both will be ambig timezone
6937 seg.dayIndex = dayIndex;
6947 ------------------------------------------------------------------------------------------------------------------*/
6950 updateSize: function(isResize) { // NOT a standard Grid method
6951 this.slatCoordCache.build();
6954 this.updateSegVerticals(
6955 [].concat(this.fgSegs || [], this.bgSegs || [], this.businessSegs || [])
6961 getTotalSlatHeight: function() {
6962 return this.slatContainerEl.outerHeight();
6966 // Computes the top coordinate, relative to the bounds of the grid, of the given date.
6967 // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight.
6968 computeDateTop: function(date, startOfDayDate) {
6969 return this.computeTimeTop(
6971 date - startOfDayDate.clone().stripTime()
6977 // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration).
6978 computeTimeTop: function(time) {
6979 var len = this.slatEls.length;
6980 var slatCoverage = (time - this.minTime) / this.slotDuration; // floating-point value of # of slots covered
6984 // compute a floating-point number for how many slats should be progressed through.
6985 // from 0 to number of slats (inclusive)
6986 // constrained because minTime/maxTime might be customized.
6987 slatCoverage = Math.max(0, slatCoverage);
6988 slatCoverage = Math.min(len, slatCoverage);
6990 // an integer index of the furthest whole slat
6991 // from 0 to number slats (*exclusive*, so len-1)
6992 slatIndex = Math.floor(slatCoverage);
6993 slatIndex = Math.min(slatIndex, len - 1);
6995 // how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition.
6996 // could be 1.0 if slatCoverage is covering *all* the slots
6997 slatRemainder = slatCoverage - slatIndex;
6999 return this.slatCoordCache.getTopPosition(slatIndex) +
7000 this.slatCoordCache.getHeight(slatIndex) * slatRemainder;
7005 /* Event Drag Visualization
7006 ------------------------------------------------------------------------------------------------------------------*/
7009 // Renders a visual indication of an event being dragged over the specified date(s).
7010 // A returned value of `true` signals that a mock "helper" event has been rendered.
7011 renderDrag: function(eventLocation, seg) {
7013 if (seg) { // if there is event information for this drag, render a helper event
7015 // returns mock event elements
7016 // signal that a helper has been rendered
7017 return this.renderEventLocationHelper(eventLocation, seg);
7020 // otherwise, just render a highlight
7021 this.renderHighlight(this.eventToSpan(eventLocation));
7026 // Unrenders any visual indication of an event being dragged
7027 unrenderDrag: function() {
7028 this.unrenderHelper();
7029 this.unrenderHighlight();
7033 /* Event Resize Visualization
7034 ------------------------------------------------------------------------------------------------------------------*/
7037 // Renders a visual indication of an event being resized
7038 renderEventResize: function(eventLocation, seg) {
7039 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
7043 // Unrenders any visual indication of an event being resized
7044 unrenderEventResize: function() {
7045 this.unrenderHelper();
7050 ------------------------------------------------------------------------------------------------------------------*/
7053 // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag)
7054 renderHelper: function(event, sourceSeg) {
7055 return this.renderHelperSegs(this.eventToSegs(event), sourceSeg); // returns mock event elements
7059 // Unrenders any mock helper event
7060 unrenderHelper: function() {
7061 this.unrenderHelperSegs();
7066 ------------------------------------------------------------------------------------------------------------------*/
7069 renderBusinessHours: function() {
7070 this.renderBusinessSegs(
7071 this.buildBusinessHourSegs()
7076 unrenderBusinessHours: function() {
7077 this.unrenderBusinessSegs();
7082 ------------------------------------------------------------------------------------------------------------------*/
7085 getNowIndicatorUnit: function() {
7086 return 'minute'; // will refresh on the minute
7090 renderNowIndicator: function(date) {
7091 // seg system might be overkill, but it handles scenario where line needs to be rendered
7092 // more than once because of columns with the same date (resources columns for example)
7093 var segs = this.spanToSegs({ start: date, end: date });
7094 var top = this.computeDateTop(date, date);
7098 // render lines within the columns
7099 for (i = 0; i < segs.length; i++) {
7100 nodes.push($('<div class="fc-now-indicator fc-now-indicator-line"></div>')
7102 .appendTo(this.colContainerEls.eq(segs[i].col))[0]);
7105 // render an arrow over the axis
7106 if (segs.length > 0) { // is the current time in view?
7107 nodes.push($('<div class="fc-now-indicator fc-now-indicator-arrow"></div>')
7109 .appendTo(this.el.find('.fc-content-skeleton'))[0]);
7112 this.nowIndicatorEls = $(nodes);
7116 unrenderNowIndicator: function() {
7117 if (this.nowIndicatorEls) {
7118 this.nowIndicatorEls.remove();
7119 this.nowIndicatorEls = null;
7125 ------------------------------------------------------------------------------------------------------------------*/
7128 // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight.
7129 renderSelection: function(span) {
7130 if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered
7132 // normally acceps an eventLocation, span has a start/end, which is good enough
7133 this.renderEventLocationHelper(span);
7136 this.renderHighlight(span);
7141 // Unrenders any visual indication of a selection
7142 unrenderSelection: function() {
7143 this.unrenderHelper();
7144 this.unrenderHighlight();
7149 ------------------------------------------------------------------------------------------------------------------*/
7152 renderHighlight: function(span) {
7153 this.renderHighlightSegs(this.spanToSegs(span));
7157 unrenderHighlight: function() {
7158 this.unrenderHighlightSegs();
7165 /* Methods for rendering SEGMENTS, pieces of content that live on the view
7166 ( this file is no longer just for events )
7167 ----------------------------------------------------------------------------------------------------------------------*/
7171 colContainerEls: null, // containers for each column
7173 // inner-containers for each column where different types of segs live
7174 fgContainerEls: null,
7175 bgContainerEls: null,
7176 helperContainerEls: null,
7177 highlightContainerEls: null,
7178 businessContainerEls: null,
7180 // arrays of different types of displayed segments
7184 highlightSegs: null,
7188 // Renders the DOM that the view's content will live in
7189 renderContentSkeleton: function() {
7194 for (i = 0; i < this.colCnt; i++) {
7197 '<div class="fc-content-col">' +
7198 '<div class="fc-event-container fc-helper-container"></div>' +
7199 '<div class="fc-event-container"></div>' +
7200 '<div class="fc-highlight-container"></div>' +
7201 '<div class="fc-bgevent-container"></div>' +
7202 '<div class="fc-business-container"></div>' +
7208 '<div class="fc-content-skeleton">' +
7210 '<tr>' + cellHtml + '</tr>' +
7215 this.colContainerEls = skeletonEl.find('.fc-content-col');
7216 this.helperContainerEls = skeletonEl.find('.fc-helper-container');
7217 this.fgContainerEls = skeletonEl.find('.fc-event-container:not(.fc-helper-container)');
7218 this.bgContainerEls = skeletonEl.find('.fc-bgevent-container');
7219 this.highlightContainerEls = skeletonEl.find('.fc-highlight-container');
7220 this.businessContainerEls = skeletonEl.find('.fc-business-container');
7222 this.bookendCells(skeletonEl.find('tr')); // TODO: do this on string level
7223 this.el.append(skeletonEl);
7227 /* Foreground Events
7228 ------------------------------------------------------------------------------------------------------------------*/
7231 renderFgSegs: function(segs) {
7232 segs = this.renderFgSegsIntoContainers(segs, this.fgContainerEls);
7234 return segs; // needed for Grid::renderEvents
7238 unrenderFgSegs: function() {
7239 this.unrenderNamedSegs('fgSegs');
7243 /* Foreground Helper Events
7244 ------------------------------------------------------------------------------------------------------------------*/
7247 renderHelperSegs: function(segs, sourceSeg) {
7252 segs = this.renderFgSegsIntoContainers(segs, this.helperContainerEls);
7254 // Try to make the segment that is in the same row as sourceSeg look the same
7255 for (i = 0; i < segs.length; i++) {
7257 if (sourceSeg && sourceSeg.col === seg.col) {
7258 sourceEl = sourceSeg.el;
7260 left: sourceEl.css('left'),
7261 right: sourceEl.css('right'),
7262 'margin-left': sourceEl.css('margin-left'),
7263 'margin-right': sourceEl.css('margin-right')
7266 helperEls.push(seg.el[0]);
7269 this.helperSegs = segs;
7271 return $(helperEls); // must return rendered helpers
7275 unrenderHelperSegs: function() {
7276 this.unrenderNamedSegs('helperSegs');
7280 /* Background Events
7281 ------------------------------------------------------------------------------------------------------------------*/
7284 renderBgSegs: function(segs) {
7285 segs = this.renderFillSegEls('bgEvent', segs); // TODO: old fill system
7286 this.updateSegVerticals(segs);
7287 this.attachSegsByCol(this.groupSegsByCol(segs), this.bgContainerEls);
7289 return segs; // needed for Grid::renderEvents
7293 unrenderBgSegs: function() {
7294 this.unrenderNamedSegs('bgSegs');
7299 ------------------------------------------------------------------------------------------------------------------*/
7302 renderHighlightSegs: function(segs) {
7303 segs = this.renderFillSegEls('highlight', segs); // TODO: old fill system
7304 this.updateSegVerticals(segs);
7305 this.attachSegsByCol(this.groupSegsByCol(segs), this.highlightContainerEls);
7306 this.highlightSegs = segs;
7310 unrenderHighlightSegs: function() {
7311 this.unrenderNamedSegs('highlightSegs');
7316 ------------------------------------------------------------------------------------------------------------------*/
7319 renderBusinessSegs: function(segs) {
7320 segs = this.renderFillSegEls('businessHours', segs); // TODO: old fill system
7321 this.updateSegVerticals(segs);
7322 this.attachSegsByCol(this.groupSegsByCol(segs), this.businessContainerEls);
7323 this.businessSegs = segs;
7327 unrenderBusinessSegs: function() {
7328 this.unrenderNamedSegs('businessSegs');
7332 /* Seg Rendering Utils
7333 ------------------------------------------------------------------------------------------------------------------*/
7336 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col
7337 groupSegsByCol: function(segs) {
7341 for (i = 0; i < this.colCnt; i++) {
7345 for (i = 0; i < segs.length; i++) {
7346 segsByCol[segs[i].col].push(segs[i]);
7353 // Given segments grouped by column, insert the segments' elements into a parallel array of container
7354 // elements, each living within a column.
7355 attachSegsByCol: function(segsByCol, containerEls) {
7360 for (col = 0; col < this.colCnt; col++) { // iterate each column grouping
7361 segs = segsByCol[col];
7363 for (i = 0; i < segs.length; i++) {
7364 containerEls.eq(col).append(segs[i].el);
7370 // Given the name of a property of `this` object, assumed to be an array of segments,
7371 // loops through each segment and removes from DOM. Will null-out the property afterwards.
7372 unrenderNamedSegs: function(propName) {
7373 var segs = this[propName];
7377 for (i = 0; i < segs.length; i++) {
7378 segs[i].el.remove();
7380 this[propName] = null;
7386 /* Foreground Event Rendering Utils
7387 ------------------------------------------------------------------------------------------------------------------*/
7390 // Given an array of foreground segments, render a DOM element for each, computes position,
7391 // and attaches to the column inner-container elements.
7392 renderFgSegsIntoContainers: function(segs, containerEls) {
7396 segs = this.renderFgSegEls(segs); // will call fgSegHtml
7397 segsByCol = this.groupSegsByCol(segs);
7399 for (col = 0; col < this.colCnt; col++) {
7400 this.updateFgSegCoords(segsByCol[col]);
7403 this.attachSegsByCol(segsByCol, containerEls);
7409 // Renders the HTML for a single event segment's default rendering
7410 fgSegHtml: function(seg, disableResizing) {
7411 var view = this.view;
7412 var event = seg.event;
7413 var isDraggable = view.isEventDraggable(event);
7414 var isResizableFromStart = !disableResizing && seg.isStart && view.isEventResizableFromStart(event);
7415 var isResizableFromEnd = !disableResizing && seg.isEnd && view.isEventResizableFromEnd(event);
7416 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd);
7417 var skinCss = cssToStr(this.getSegSkinCss(seg));
7419 var fullTimeText; // more verbose time text. for the print stylesheet
7420 var startTimeText; // just the start time text
7422 classes.unshift('fc-time-grid-event', 'fc-v-event');
7424 if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day...
7425 // Don't display time text on segments that run entirely through a day.
7426 // That would appear as midnight-midnight and would look dumb.
7427 // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am)
7428 if (seg.isStart || seg.isEnd) {
7429 timeText = this.getEventTimeText(seg);
7430 fullTimeText = this.getEventTimeText(seg, 'LT');
7431 startTimeText = this.getEventTimeText(seg, null, false); // displayEnd=false
7434 // Display the normal time text for the *event's* times
7435 timeText = this.getEventTimeText(event);
7436 fullTimeText = this.getEventTimeText(event, 'LT');
7437 startTimeText = this.getEventTimeText(event, null, false); // displayEnd=false
7440 return '<a class="' + classes.join(' ') + '"' +
7442 ' href="' + htmlEscape(event.url) + '"' :
7446 ' style="' + skinCss + '"' :
7450 '<div class="fc-content">' +
7452 '<div class="fc-time"' +
7453 ' data-start="' + htmlEscape(startTimeText) + '"' +
7454 ' data-full="' + htmlEscape(fullTimeText) + '"' +
7456 '<span>' + htmlEscape(timeText) + '</span>' +
7461 '<div class="fc-title">' +
7462 htmlEscape(event.title) +
7467 '<div class="fc-bg"/>' +
7468 /* TODO: write CSS for this
7469 (isResizableFromStart ?
7470 '<div class="fc-resizer fc-start-resizer" />' :
7474 (isResizableFromEnd ?
7475 '<div class="fc-resizer fc-end-resizer" />' :
7482 /* Seg Position Utils
7483 ------------------------------------------------------------------------------------------------------------------*/
7486 // Refreshes the CSS top/bottom coordinates for each segment element.
7487 // Works when called after initial render, after a window resize/zoom for example.
7488 updateSegVerticals: function(segs) {
7489 this.computeSegVerticals(segs);
7490 this.assignSegVerticals(segs);
7494 // For each segment in an array, computes and assigns its top and bottom properties
7495 computeSegVerticals: function(segs) {
7498 for (i = 0; i < segs.length; i++) {
7500 seg.top = this.computeDateTop(seg.start, seg.start);
7501 seg.bottom = this.computeDateTop(seg.end, seg.start);
7506 // Given segments that already have their top/bottom properties computed, applies those values to
7507 // the segments' elements.
7508 assignSegVerticals: function(segs) {
7511 for (i = 0; i < segs.length; i++) {
7513 seg.el.css(this.generateSegVerticalCss(seg));
7518 // Generates an object with CSS properties for the top/bottom coordinates of a segment element
7519 generateSegVerticalCss: function(seg) {
7522 bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container
7527 /* Foreground Event Positioning Utils
7528 ------------------------------------------------------------------------------------------------------------------*/
7531 // Given segments that are assumed to all live in the *same column*,
7532 // compute their verical/horizontal coordinates and assign to their elements.
7533 updateFgSegCoords: function(segs) {
7534 this.computeSegVerticals(segs); // horizontals relies on this
7535 this.computeFgSegHorizontals(segs); // compute horizontal coordinates, z-index's, and reorder the array
7536 this.assignSegVerticals(segs);
7537 this.assignFgSegHorizontals(segs);
7541 // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each.
7542 // NOTE: Also reorders the given array by date!
7543 computeFgSegHorizontals: function(segs) {
7548 this.sortEventSegs(segs); // order by certain criteria
7549 levels = buildSlotSegLevels(segs);
7550 computeForwardSlotSegs(levels);
7552 if ((level0 = levels[0])) {
7554 for (i = 0; i < level0.length; i++) {
7555 computeSlotSegPressures(level0[i]);
7558 for (i = 0; i < level0.length; i++) {
7559 this.computeFgSegForwardBack(level0[i], 0, 0);
7565 // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range
7566 // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and
7567 // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left.
7569 // The segment might be part of a "series", which means consecutive segments with the same pressure
7570 // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of
7571 // segments behind this one in the current series, and `seriesBackwardCoord` is the starting
7572 // coordinate of the first segment in the series.
7573 computeFgSegForwardBack: function(seg, seriesBackwardPressure, seriesBackwardCoord) {
7574 var forwardSegs = seg.forwardSegs;
7577 if (seg.forwardCoord === undefined) { // not already computed
7579 if (!forwardSegs.length) {
7581 // if there are no forward segments, this segment should butt up against the edge
7582 seg.forwardCoord = 1;
7586 // sort highest pressure first
7587 this.sortForwardSegs(forwardSegs);
7589 // this segment's forwardCoord will be calculated from the backwardCoord of the
7590 // highest-pressure forward segment.
7591 this.computeFgSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord);
7592 seg.forwardCoord = forwardSegs[0].backwardCoord;
7595 // calculate the backwardCoord from the forwardCoord. consider the series
7596 seg.backwardCoord = seg.forwardCoord -
7597 (seg.forwardCoord - seriesBackwardCoord) / // available width for series
7598 (seriesBackwardPressure + 1); // # of segments in the series
7600 // use this segment's coordinates to computed the coordinates of the less-pressurized
7602 for (i=0; i<forwardSegs.length; i++) {
7603 this.computeFgSegForwardBack(forwardSegs[i], 0, seg.forwardCoord);
7609 sortForwardSegs: function(forwardSegs) {
7610 forwardSegs.sort(proxy(this, 'compareForwardSegs'));
7614 // A cmp function for determining which forward segment to rely on more when computing coordinates.
7615 compareForwardSegs: function(seg1, seg2) {
7616 // put higher-pressure first
7617 return seg2.forwardPressure - seg1.forwardPressure ||
7618 // put segments that are closer to initial edge first (and favor ones with no coords yet)
7619 (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) ||
7620 // do normal sorting...
7621 this.compareEventSegs(seg1, seg2);
7625 // Given foreground event segments that have already had their position coordinates computed,
7626 // assigns position-related CSS values to their elements.
7627 assignFgSegHorizontals: function(segs) {
7630 for (i = 0; i < segs.length; i++) {
7632 seg.el.css(this.generateFgSegHorizontalCss(seg));
7634 // if the height is short, add a className for alternate styling
7635 if (seg.bottom - seg.top < 30) {
7636 seg.el.addClass('fc-short');
7642 // Generates an object with CSS properties/values that should be applied to an event segment element.
7643 // Contains important positioning-related properties that should be applied to any event element, customized or not.
7644 generateFgSegHorizontalCss: function(seg) {
7645 var shouldOverlap = this.view.opt('slotEventOverlap');
7646 var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point
7647 var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point
7648 var props = this.generateSegVerticalCss(seg); // get top/bottom first
7649 var left; // amount of space from left edge, a fraction of the total width
7650 var right; // amount of space from right edge, a fraction of the total width
7652 if (shouldOverlap) {
7653 // double the width, but don't go beyond the maximum forward coordinate (1.0)
7654 forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2);
7658 left = 1 - forwardCoord;
7659 right = backwardCoord;
7662 left = backwardCoord;
7663 right = 1 - forwardCoord;
7666 props.zIndex = seg.level + 1; // convert from 0-base to 1-based
7667 props.left = left * 100 + '%';
7668 props.right = right * 100 + '%';
7670 if (shouldOverlap && seg.forwardPressure) {
7671 // add padding to the edge so that forward stacked events don't cover the resizer's icon
7672 props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width
7681 // Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is
7682 // left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date.
7683 function buildSlotSegLevels(segs) {
7688 for (i=0; i<segs.length; i++) {
7691 // go through all the levels and stop on the first level where there are no collisions
7692 for (j=0; j<levels.length; j++) {
7693 if (!computeSlotSegCollisions(seg, levels[j]).length) {
7700 (levels[j] || (levels[j] = [])).push(seg);
7707 // For every segment, figure out the other segments that are in subsequent
7708 // levels that also occupy the same vertical space. Accumulate in seg.forwardSegs
7709 function computeForwardSlotSegs(levels) {
7714 for (i=0; i<levels.length; i++) {
7717 for (j=0; j<level.length; j++) {
7720 seg.forwardSegs = [];
7721 for (k=i+1; k<levels.length; k++) {
7722 computeSlotSegCollisions(seg, levels[k], seg.forwardSegs);
7729 // Figure out which path forward (via seg.forwardSegs) results in the longest path until
7730 // the furthest edge is reached. The number of segments in this path will be seg.forwardPressure
7731 function computeSlotSegPressures(seg) {
7732 var forwardSegs = seg.forwardSegs;
7733 var forwardPressure = 0;
7736 if (seg.forwardPressure === undefined) { // not already computed
7738 for (i=0; i<forwardSegs.length; i++) {
7739 forwardSeg = forwardSegs[i];
7741 // figure out the child's maximum forward path
7742 computeSlotSegPressures(forwardSeg);
7744 // either use the existing maximum, or use the child's forward pressure
7745 // plus one (for the forwardSeg itself)
7746 forwardPressure = Math.max(
7748 1 + forwardSeg.forwardPressure
7752 seg.forwardPressure = forwardPressure;
7757 // Find all the segments in `otherSegs` that vertically collide with `seg`.
7758 // Append into an optionally-supplied `results` array and return.
7759 function computeSlotSegCollisions(seg, otherSegs, results) {
7760 results = results || [];
7762 for (var i=0; i<otherSegs.length; i++) {
7763 if (isSlotSegCollision(seg, otherSegs[i])) {
7764 results.push(otherSegs[i]);
7772 // Do these segments occupy the same vertical space?
7773 function isSlotSegCollision(seg1, seg2) {
7774 return seg1.bottom > seg2.top && seg1.top < seg2.bottom;
7779 /* An abstract class from which other views inherit from
7780 ----------------------------------------------------------------------------------------------------------------------*/
7782 var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, {
7784 type: null, // subclass' view name (string)
7785 name: null, // deprecated. use `type` instead
7786 title: null, // the text that will be displayed in the header's title
7788 calendar: null, // owner Calendar object
7789 options: null, // hash containing all options. already merged with view-specific-options
7790 el: null, // the view's containing element. set by Calendar
7792 displaying: null, // a promise representing the state of rendering. null if no render requested
7793 isSkeletonRendered: false,
7794 isEventsRendered: false,
7796 // range the view is actually displaying (moments)
7798 end: null, // exclusive
7800 // range the view is formally responsible for (moments)
7801 // may be different from start/end. for example, a month view might have 1st-31st, excluding padded dates
7802 intervalStart: null,
7803 intervalEnd: null, // exclusive
7804 intervalDuration: null,
7805 intervalUnit: null, // name of largest unit being displayed, like "month" or "week"
7808 isSelected: false, // boolean whether a range of time is user-selected or not
7809 selectedEvent: null,
7811 eventOrderSpecs: null, // criteria for ordering events when they have same date/time
7813 // classNames styled by jqui themes
7814 widgetHeaderClass: null,
7815 widgetContentClass: null,
7816 highlightStateClass: null,
7818 // for date utils, computed from options
7819 nextDayThreshold: null,
7820 isHiddenDayHash: null,
7823 isNowIndicatorRendered: null,
7824 initialNowDate: null, // result first getNow call
7825 initialNowQueriedMs: null, // ms time the getNow was called
7826 nowIndicatorTimeoutID: null, // for refresh timing of now indicator
7827 nowIndicatorIntervalID: null, // "
7830 constructor: function(calendar, type, options, intervalDuration) {
7832 this.calendar = calendar;
7833 this.type = this.name = type; // .name is deprecated
7834 this.options = options;
7835 this.intervalDuration = intervalDuration || moment.duration(1, 'day');
7837 this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold'));
7838 this.initThemingProps();
7839 this.initHiddenDays();
7840 this.isRTL = this.opt('isRTL');
7842 this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder'));
7848 // A good place for subclasses to initialize member variables
7849 initialize: function() {
7850 // subclasses can implement
7854 // Retrieves an option with the given name
7855 opt: function(name) {
7856 return this.options[name];
7860 // Triggers handlers that are view-related. Modifies args before passing to calendar.
7861 trigger: function(name, thisObj) { // arguments beyond thisObj are passed along
7862 var calendar = this.calendar;
7864 return calendar.trigger.apply(
7866 [name, thisObj || this].concat(
7867 Array.prototype.slice.call(arguments, 2), // arguments beyond thisObj
7868 [ this ] // always make the last argument a reference to the view. TODO: deprecate
7875 ------------------------------------------------------------------------------------------------------------------*/
7878 // Updates all internal dates to center around the given current unzoned date.
7879 setDate: function(date) {
7880 this.setRange(this.computeRange(date));
7884 // Updates all internal dates for displaying the given unzoned range.
7885 setRange: function(range) {
7886 $.extend(this, range); // assigns every property to this object's member variables
7891 // Given a single current unzoned date, produce information about what range to display.
7892 // Subclasses can override. Must return all properties.
7893 computeRange: function(date) {
7894 var intervalUnit = computeIntervalUnit(this.intervalDuration);
7895 var intervalStart = date.clone().startOf(intervalUnit);
7896 var intervalEnd = intervalStart.clone().add(this.intervalDuration);
7899 // normalize the range's time-ambiguity
7900 if (/year|month|week|day/.test(intervalUnit)) { // whole-days?
7901 intervalStart.stripTime();
7902 intervalEnd.stripTime();
7904 else { // needs to have a time?
7905 if (!intervalStart.hasTime()) {
7906 intervalStart = this.calendar.time(0); // give 00:00 time
7908 if (!intervalEnd.hasTime()) {
7909 intervalEnd = this.calendar.time(0); // give 00:00 time
7913 start = intervalStart.clone();
7914 start = this.skipHiddenDays(start);
7915 end = intervalEnd.clone();
7916 end = this.skipHiddenDays(end, -1, true); // exclusively move backwards
7919 intervalUnit: intervalUnit,
7920 intervalStart: intervalStart,
7921 intervalEnd: intervalEnd,
7928 // Computes the new date when the user hits the prev button, given the current date
7929 computePrevDate: function(date) {
7930 return this.massageCurrentDate(
7931 date.clone().startOf(this.intervalUnit).subtract(this.intervalDuration), -1
7936 // Computes the new date when the user hits the next button, given the current date
7937 computeNextDate: function(date) {
7938 return this.massageCurrentDate(
7939 date.clone().startOf(this.intervalUnit).add(this.intervalDuration)
7944 // Given an arbitrarily calculated current date of the calendar, returns a date that is ensured to be completely
7945 // visible. `direction` is optional and indicates which direction the current date was being
7946 // incremented or decremented (1 or -1).
7947 massageCurrentDate: function(date, direction) {
7948 if (this.intervalDuration.as('days') <= 1) { // if the view displays a single day or smaller
7949 if (this.isHiddenDay(date)) {
7950 date = this.skipHiddenDays(date, direction);
7951 date.startOf('day');
7959 /* Title and Date Formatting
7960 ------------------------------------------------------------------------------------------------------------------*/
7963 // Sets the view's title property to the most updated computed value
7964 updateTitle: function() {
7965 this.title = this.computeTitle();
7969 // Computes what the title at the top of the calendar should be for this view
7970 computeTitle: function() {
7971 return this.formatRange(
7973 // in case intervalStart/End has a time, make sure timezone is correct
7974 start: this.calendar.applyTimezone(this.intervalStart),
7975 end: this.calendar.applyTimezone(this.intervalEnd)
7977 this.opt('titleFormat') || this.computeTitleFormat(),
7978 this.opt('titleRangeSeparator')
7983 // Generates the format string that should be used to generate the title for the current date range.
7984 // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`.
7985 computeTitleFormat: function() {
7986 if (this.intervalUnit == 'year') {
7989 else if (this.intervalUnit == 'month') {
7990 return this.opt('monthYearFormat'); // like "September 2014"
7992 else if (this.intervalDuration.as('days') > 1) {
7993 return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014"
7996 return 'LL'; // one day. longer, like "September 9 2014"
8001 // Utility for formatting a range. Accepts a range object, formatting string, and optional separator.
8002 // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account.
8003 // The timezones of the dates within `range` will be respected.
8004 formatRange: function(range, formatStr, separator) {
8005 var end = range.end;
8007 if (!end.hasTime()) { // all-day?
8008 end = end.clone().subtract(1); // convert to inclusive. last ms of previous day
8011 return formatRange(range.start, end, formatStr, separator, this.opt('isRTL'));
8015 getAllDayHtml: function() {
8016 return this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'));
8021 ------------------------------------------------------------------------------------------------------------------*/
8024 // Generates HTML for an anchor to another view into the calendar.
8025 // Will either generate an <a> tag or a non-clickable <span> tag, depending on enabled settings.
8026 // `gotoOptions` can either be a moment input, or an object with the form:
8027 // { date, type, forceOff }
8028 // `type` is a view-type like "day" or "week". default value is "day".
8029 // `attrs` and `innerHtml` are use to generate the rest of the HTML tag.
8030 buildGotoAnchorHtml: function(gotoOptions, attrs, innerHtml) {
8031 var date, type, forceOff;
8034 if ($.isPlainObject(gotoOptions)) {
8035 date = gotoOptions.date;
8036 type = gotoOptions.type;
8037 forceOff = gotoOptions.forceOff;
8040 date = gotoOptions; // a single moment input
8042 date = FC.moment(date); // if a string, parse it
8044 finalOptions = { // for serialization into the link
8045 date: date.format('YYYY-MM-DD'),
8049 if (typeof attrs === 'string') {
8054 attrs = attrs ? ' ' + attrsToStr(attrs) : ''; // will have a leading space
8055 innerHtml = innerHtml || '';
8057 if (!forceOff && this.opt('navLinks')) {
8058 return '<a' + attrs +
8059 ' data-goto="' + htmlEscape(JSON.stringify(finalOptions)) + '">' +
8064 return '<span' + attrs + '>' +
8072 ------------------------------------------------------------------------------------------------------------------*/
8075 // Sets the container element that the view should render inside of.
8076 // Does other DOM-related initializations.
8077 setElement: function(el) {
8079 this.bindGlobalHandlers();
8083 // Removes the view's container element from the DOM, clearing any content beforehand.
8084 // Undoes any other DOM-related attachments.
8085 removeElement: function() {
8086 this.clear(); // clears all content
8088 // clean up the skeleton
8089 if (this.isSkeletonRendered) {
8090 this.unrenderSkeleton();
8091 this.isSkeletonRendered = false;
8094 this.unbindGlobalHandlers();
8098 // NOTE: don't null-out this.el in case the View was destroyed within an API callback.
8099 // We don't null-out the View's other jQuery element references upon destroy,
8100 // so we shouldn't kill this.el either.
8104 // Does everything necessary to display the view centered around the given unzoned date.
8105 // Does every type of rendering EXCEPT rendering events.
8106 // Is asychronous and returns a promise.
8107 display: function(date, explicitScrollState) {
8109 var prevScrollState = null;
8111 if (explicitScrollState != null && this.displaying) { // don't need prevScrollState if explicitScrollState
8112 prevScrollState = this.queryScroll();
8115 this.calendar.freezeContentHeight();
8117 return syncThen(this.clear(), function() { // clear the content first
8120 syncThen(_this.displayView(date), function() { // displayView might return a promise
8122 // caller of display() wants a specific scroll state?
8123 if (explicitScrollState != null) {
8124 // we make an assumption that this is NOT the initial render,
8125 // and thus don't need forceScroll (is inconveniently asynchronous)
8126 _this.setScroll(explicitScrollState);
8129 _this.forceScroll(_this.computeInitialScroll(prevScrollState));
8132 _this.calendar.unfreezeContentHeight();
8133 _this.triggerRender();
8140 // Does everything necessary to clear the content of the view.
8141 // Clears dates and events. Does not clear the skeleton.
8142 // Is asychronous and returns a promise.
8145 var displaying = this.displaying;
8147 if (displaying) { // previously displayed, or in the process of being displayed?
8148 return syncThen(displaying, function() { // wait for the display to finish
8149 _this.displaying = null;
8150 _this.clearEvents();
8151 return _this.clearView(); // might return a promise. chain it
8155 return $.when(); // an immediately-resolved promise
8160 // Displays the view's non-event content, such as date-related content or anything required by events.
8161 // Renders the view's non-content skeleton if necessary.
8162 // Can be asynchronous and return a promise.
8163 displayView: function(date) {
8164 if (!this.isSkeletonRendered) {
8165 this.renderSkeleton();
8166 this.isSkeletonRendered = true;
8172 this.render(); // TODO: deprecate
8176 this.renderBusinessHours(); // might need coordinates, so should go after updateSize()
8177 this.startNowIndicator();
8181 // Unrenders the view content that was rendered in displayView.
8182 // Can be asynchronous and return a promise.
8183 clearView: function() {
8185 this.stopNowIndicator();
8186 this.triggerUnrender();
8187 this.unrenderBusinessHours();
8188 this.unrenderDates();
8190 this.destroy(); // TODO: deprecate
8195 // Renders the basic structure of the view before any content is rendered
8196 renderSkeleton: function() {
8197 // subclasses should implement
8201 // Unrenders the basic structure of the view
8202 unrenderSkeleton: function() {
8203 // subclasses should implement
8207 // Renders the view's date-related content.
8208 // Assumes setRange has already been called and the skeleton has already been rendered.
8209 renderDates: function() {
8210 // subclasses should implement
8214 // Unrenders the view's date-related content
8215 unrenderDates: function() {
8216 // subclasses should override
8220 // Signals that the view's content has been rendered
8221 triggerRender: function() {
8222 this.trigger('viewRender', this, this, this.el);
8226 // Signals that the view's content is about to be unrendered
8227 triggerUnrender: function() {
8228 this.trigger('viewDestroy', this, this, this.el);
8232 // Binds DOM handlers to elements that reside outside the view container, such as the document
8233 bindGlobalHandlers: function() {
8234 this.listenTo($(document), 'mousedown', this.handleDocumentMousedown);
8235 this.listenTo($(document), 'touchstart', this.processUnselect);
8239 // Unbinds DOM handlers from elements that reside outside the view container
8240 unbindGlobalHandlers: function() {
8241 this.stopListeningTo($(document));
8245 // Initializes internal variables related to theming
8246 initThemingProps: function() {
8247 var tm = this.opt('theme') ? 'ui' : 'fc';
8249 this.widgetHeaderClass = tm + '-widget-header';
8250 this.widgetContentClass = tm + '-widget-content';
8251 this.highlightStateClass = tm + '-state-highlight';
8256 ------------------------------------------------------------------------------------------------------------------*/
8259 // Renders business-hours onto the view. Assumes updateSize has already been called.
8260 renderBusinessHours: function() {
8261 // subclasses should implement
8265 // Unrenders previously-rendered business-hours
8266 unrenderBusinessHours: function() {
8267 // subclasses should implement
8272 ------------------------------------------------------------------------------------------------------------------*/
8275 // Immediately render the current time indicator and begins re-rendering it at an interval,
8276 // which is defined by this.getNowIndicatorUnit().
8277 // TODO: somehow do this for the current whole day's background too
8278 startNowIndicator: function() {
8282 var delay; // ms wait value
8284 if (this.opt('nowIndicator')) {
8285 unit = this.getNowIndicatorUnit();
8287 update = proxy(this, 'updateNowIndicator'); // bind to `this`
8289 this.initialNowDate = this.calendar.getNow();
8290 this.initialNowQueriedMs = +new Date();
8291 this.renderNowIndicator(this.initialNowDate);
8292 this.isNowIndicatorRendered = true;
8294 // wait until the beginning of the next interval
8295 delay = this.initialNowDate.clone().startOf(unit).add(1, unit) - this.initialNowDate;
8296 this.nowIndicatorTimeoutID = setTimeout(function() {
8297 _this.nowIndicatorTimeoutID = null;
8299 delay = +moment.duration(1, unit);
8300 delay = Math.max(100, delay); // prevent too frequent
8301 _this.nowIndicatorIntervalID = setInterval(update, delay); // update every interval
8308 // rerenders the now indicator, computing the new current time from the amount of time that has passed
8309 // since the initial getNow call.
8310 updateNowIndicator: function() {
8311 if (this.isNowIndicatorRendered) {
8312 this.unrenderNowIndicator();
8313 this.renderNowIndicator(
8314 this.initialNowDate.clone().add(new Date() - this.initialNowQueriedMs) // add ms
8320 // Immediately unrenders the view's current time indicator and stops any re-rendering timers.
8321 // Won't cause side effects if indicator isn't rendered.
8322 stopNowIndicator: function() {
8323 if (this.isNowIndicatorRendered) {
8325 if (this.nowIndicatorTimeoutID) {
8326 clearTimeout(this.nowIndicatorTimeoutID);
8327 this.nowIndicatorTimeoutID = null;
8329 if (this.nowIndicatorIntervalID) {
8330 clearTimeout(this.nowIndicatorIntervalID);
8331 this.nowIndicatorIntervalID = null;
8334 this.unrenderNowIndicator();
8335 this.isNowIndicatorRendered = false;
8340 // Returns a string unit, like 'second' or 'minute' that defined how often the current time indicator
8341 // should be refreshed. If something falsy is returned, no time indicator is rendered at all.
8342 getNowIndicatorUnit: function() {
8343 // subclasses should implement
8347 // Renders a current time indicator at the given datetime
8348 renderNowIndicator: function(date) {
8349 // subclasses should implement
8353 // Undoes the rendering actions from renderNowIndicator
8354 unrenderNowIndicator: function() {
8355 // subclasses should implement
8360 ------------------------------------------------------------------------------------------------------------------*/
8363 // Refreshes anything dependant upon sizing of the container element of the grid
8364 updateSize: function(isResize) {
8368 scrollState = this.queryScroll();
8371 this.updateHeight(isResize);
8372 this.updateWidth(isResize);
8373 this.updateNowIndicator();
8376 this.setScroll(scrollState);
8381 // Refreshes the horizontal dimensions of the calendar
8382 updateWidth: function(isResize) {
8383 // subclasses should implement
8387 // Refreshes the vertical dimensions of the calendar
8388 updateHeight: function(isResize) {
8389 var calendar = this.calendar; // we poll the calendar for height information
8392 calendar.getSuggestedViewHeight(),
8393 calendar.isHeightAuto()
8398 // Updates the vertical dimensions of the calendar to the specified height.
8399 // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height.
8400 setHeight: function(height, isAuto) {
8401 // subclasses should implement
8406 ------------------------------------------------------------------------------------------------------------------*/
8409 // Computes the initial pre-configured scroll state prior to allowing the user to change it.
8410 // Given the scroll state from the previous rendering. If first time rendering, given null.
8411 computeInitialScroll: function(previousScrollState) {
8416 // Retrieves the view's current natural scroll state. Can return an arbitrary format.
8417 queryScroll: function() {
8418 // subclasses must implement
8422 // Sets the view's scroll state. Will accept the same format computeInitialScroll and queryScroll produce.
8423 setScroll: function(scrollState) {
8424 // subclasses must implement
8428 // Sets the scroll state, making sure to overcome any predefined scroll value the browser has in mind
8429 forceScroll: function(scrollState) {
8432 this.setScroll(scrollState);
8433 setTimeout(function() {
8434 _this.setScroll(scrollState);
8439 /* Event Elements / Segments
8440 ------------------------------------------------------------------------------------------------------------------*/
8443 // Does everything necessary to display the given events onto the current view
8444 displayEvents: function(events) {
8445 var scrollState = this.queryScroll();
8448 this.renderEvents(events);
8449 this.isEventsRendered = true;
8450 this.setScroll(scrollState);
8451 this.triggerEventRender();
8455 // Does everything necessary to clear the view's currently-rendered events
8456 clearEvents: function() {
8459 if (this.isEventsRendered) {
8461 // TODO: optimize: if we know this is part of a displayEvents call, don't queryScroll/setScroll
8462 scrollState = this.queryScroll();
8464 this.triggerEventUnrender();
8465 if (this.destroyEvents) {
8466 this.destroyEvents(); // TODO: deprecate
8468 this.unrenderEvents();
8469 this.setScroll(scrollState);
8470 this.isEventsRendered = false;
8475 // Renders the events onto the view.
8476 renderEvents: function(events) {
8477 // subclasses should implement
8481 // Removes event elements from the view.
8482 unrenderEvents: function() {
8483 // subclasses should implement
8487 // Signals that all events have been rendered
8488 triggerEventRender: function() {
8489 this.renderedEventSegEach(function(seg) {
8490 this.trigger('eventAfterRender', seg.event, seg.event, seg.el);
8492 this.trigger('eventAfterAllRender');
8496 // Signals that all event elements are about to be removed
8497 triggerEventUnrender: function() {
8498 this.renderedEventSegEach(function(seg) {
8499 this.trigger('eventDestroy', seg.event, seg.event, seg.el);
8504 // Given an event and the default element used for rendering, returns the element that should actually be used.
8505 // Basically runs events and elements through the eventRender hook.
8506 resolveEventEl: function(event, el) {
8507 var custom = this.trigger('eventRender', event, event, el);
8509 if (custom === false) { // means don't render at all
8512 else if (custom && custom !== true) {
8520 // Hides all rendered event segments linked to the given event
8521 showEvent: function(event) {
8522 this.renderedEventSegEach(function(seg) {
8523 seg.el.css('visibility', '');
8528 // Shows all rendered event segments linked to the given event
8529 hideEvent: function(event) {
8530 this.renderedEventSegEach(function(seg) {
8531 seg.el.css('visibility', 'hidden');
8536 // Iterates through event segments that have been rendered (have an el). Goes through all by default.
8537 // If the optional `event` argument is specified, only iterates through segments linked to that event.
8538 // The `this` value of the callback function will be the view.
8539 renderedEventSegEach: function(func, event) {
8540 var segs = this.getEventSegs();
8543 for (i = 0; i < segs.length; i++) {
8544 if (!event || segs[i].event._id === event._id) {
8546 func.call(this, segs[i]);
8553 // Retrieves all the rendered segment objects for the view
8554 getEventSegs: function() {
8555 // subclasses must implement
8560 /* Event Drag-n-Drop
8561 ------------------------------------------------------------------------------------------------------------------*/
8564 // Computes if the given event is allowed to be dragged by the user
8565 isEventDraggable: function(event) {
8566 return this.isEventStartEditable(event);
8570 isEventStartEditable: function(event) {
8571 return firstDefined(
8572 event.startEditable,
8573 (event.source || {}).startEditable,
8574 this.opt('eventStartEditable'),
8575 this.isEventGenerallyEditable(event)
8580 isEventGenerallyEditable: function(event) {
8581 return firstDefined(
8583 (event.source || {}).editable,
8584 this.opt('editable')
8589 // Must be called when an event in the view is dropped onto new location.
8590 // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event.
8591 reportEventDrop: function(event, dropLocation, largeUnit, el, ev) {
8592 var calendar = this.calendar;
8593 var mutateResult = calendar.mutateEvent(event, dropLocation, largeUnit);
8594 var undoFunc = function() {
8595 mutateResult.undo();
8596 calendar.reportEventChange();
8599 this.triggerEventDrop(event, mutateResult.dateDelta, undoFunc, el, ev);
8600 calendar.reportEventChange(); // will rerender events
8604 // Triggers event-drop handlers that have subscribed via the API
8605 triggerEventDrop: function(event, dateDelta, undoFunc, el, ev) {
8606 this.trigger('eventDrop', el[0], event, dateDelta, undoFunc, ev, {}); // {} = jqui dummy
8610 /* External Element Drag-n-Drop
8611 ------------------------------------------------------------------------------------------------------------------*/
8614 // Must be called when an external element, via jQuery UI, has been dropped onto the calendar.
8615 // `meta` is the parsed data that has been embedded into the dragging event.
8616 // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event.
8617 reportExternalDrop: function(meta, dropLocation, el, ev, ui) {
8618 var eventProps = meta.eventProps;
8622 // Try to build an event object and render it. TODO: decouple the two
8624 eventInput = $.extend({}, eventProps, dropLocation);
8625 event = this.calendar.renderEvent(eventInput, meta.stick)[0]; // renderEvent returns an array
8628 this.triggerExternalDrop(event, dropLocation, el, ev, ui);
8632 // Triggers external-drop handlers that have subscribed via the API
8633 triggerExternalDrop: function(event, dropLocation, el, ev, ui) {
8635 // trigger 'drop' regardless of whether element represents an event
8636 this.trigger('drop', el[0], dropLocation.start, ev, ui);
8639 this.trigger('eventReceive', null, event); // signal an external event landed
8644 /* Drag-n-Drop Rendering (for both events and external elements)
8645 ------------------------------------------------------------------------------------------------------------------*/
8648 // Renders a visual indication of a event or external-element drag over the given drop zone.
8649 // If an external-element, seg will be `null`.
8650 // Must return elements used for any mock events.
8651 renderDrag: function(dropLocation, seg) {
8652 // subclasses must implement
8656 // Unrenders a visual indication of an event or external-element being dragged.
8657 unrenderDrag: function() {
8658 // subclasses must implement
8663 ------------------------------------------------------------------------------------------------------------------*/
8666 // Computes if the given event is allowed to be resized from its starting edge
8667 isEventResizableFromStart: function(event) {
8668 return this.opt('eventResizableFromStart') && this.isEventResizable(event);
8672 // Computes if the given event is allowed to be resized from its ending edge
8673 isEventResizableFromEnd: function(event) {
8674 return this.isEventResizable(event);
8678 // Computes if the given event is allowed to be resized by the user at all
8679 isEventResizable: function(event) {
8680 var source = event.source || {};
8682 return firstDefined(
8683 event.durationEditable,
8684 source.durationEditable,
8685 this.opt('eventDurationEditable'),
8688 this.opt('editable')
8693 // Must be called when an event in the view has been resized to a new length
8694 reportEventResize: function(event, resizeLocation, largeUnit, el, ev) {
8695 var calendar = this.calendar;
8696 var mutateResult = calendar.mutateEvent(event, resizeLocation, largeUnit);
8697 var undoFunc = function() {
8698 mutateResult.undo();
8699 calendar.reportEventChange();
8702 this.triggerEventResize(event, mutateResult.durationDelta, undoFunc, el, ev);
8703 calendar.reportEventChange(); // will rerender events
8707 // Triggers event-resize handlers that have subscribed via the API
8708 triggerEventResize: function(event, durationDelta, undoFunc, el, ev) {
8709 this.trigger('eventResize', el[0], event, durationDelta, undoFunc, ev, {}); // {} = jqui dummy
8713 /* Selection (time range)
8714 ------------------------------------------------------------------------------------------------------------------*/
8717 // Selects a date span on the view. `start` and `end` are both Moments.
8718 // `ev` is the native mouse event that begin the interaction.
8719 select: function(span, ev) {
8721 this.renderSelection(span);
8722 this.reportSelection(span, ev);
8726 // Renders a visual indication of the selection
8727 renderSelection: function(span) {
8728 // subclasses should implement
8732 // Called when a new selection is made. Updates internal state and triggers handlers.
8733 reportSelection: function(span, ev) {
8734 this.isSelected = true;
8735 this.triggerSelect(span, ev);
8739 // Triggers handlers to 'select'
8740 triggerSelect: function(span, ev) {
8744 this.calendar.applyTimezone(span.start), // convert to calendar's tz for external API
8745 this.calendar.applyTimezone(span.end), // "
8751 // Undoes a selection. updates in the internal state and triggers handlers.
8752 // `ev` is the native mouse event that began the interaction.
8753 unselect: function(ev) {
8754 if (this.isSelected) {
8755 this.isSelected = false;
8756 if (this.destroySelection) {
8757 this.destroySelection(); // TODO: deprecate
8759 this.unrenderSelection();
8760 this.trigger('unselect', null, ev);
8765 // Unrenders a visual indication of selection
8766 unrenderSelection: function() {
8767 // subclasses should implement
8772 ------------------------------------------------------------------------------------------------------------------*/
8775 selectEvent: function(event) {
8776 if (!this.selectedEvent || this.selectedEvent !== event) {
8777 this.unselectEvent();
8778 this.renderedEventSegEach(function(seg) {
8779 seg.el.addClass('fc-selected');
8781 this.selectedEvent = event;
8786 unselectEvent: function() {
8787 if (this.selectedEvent) {
8788 this.renderedEventSegEach(function(seg) {
8789 seg.el.removeClass('fc-selected');
8790 }, this.selectedEvent);
8791 this.selectedEvent = null;
8796 isEventSelected: function(event) {
8797 // event references might change on refetchEvents(), while selectedEvent doesn't,
8799 return this.selectedEvent && this.selectedEvent._id === event._id;
8803 /* Mouse / Touch Unselecting (time range & event unselection)
8804 ------------------------------------------------------------------------------------------------------------------*/
8805 // TODO: move consistently to down/start or up/end?
8806 // TODO: don't kill previous selection if touch scrolling
8809 handleDocumentMousedown: function(ev) {
8810 if (isPrimaryMouseButton(ev)) {
8811 this.processUnselect(ev);
8816 processUnselect: function(ev) {
8817 this.processRangeUnselect(ev);
8818 this.processEventUnselect(ev);
8822 processRangeUnselect: function(ev) {
8825 // is there a time-range selection?
8826 if (this.isSelected && this.opt('unselectAuto')) {
8827 // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element
8828 ignore = this.opt('unselectCancel');
8829 if (!ignore || !$(ev.target).closest(ignore).length) {
8836 processEventUnselect: function(ev) {
8837 if (this.selectedEvent) {
8838 if (!$(ev.target).closest('.fc-selected').length) {
8839 this.unselectEvent();
8846 ------------------------------------------------------------------------------------------------------------------*/
8849 // Triggers handlers to 'dayClick'
8850 // Span has start/end of the clicked area. Only the start is useful.
8851 triggerDayClick: function(span, dayEl, ev) {
8855 this.calendar.applyTimezone(span.start), // convert to calendar's timezone for external API
8862 ------------------------------------------------------------------------------------------------------------------*/
8865 // Initializes internal variables related to calculating hidden days-of-week
8866 initHiddenDays: function() {
8867 var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden
8868 var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool)
8872 if (this.opt('weekends') === false) {
8873 hiddenDays.push(0, 6); // 0=sunday, 6=saturday
8876 for (i = 0; i < 7; i++) {
8878 !(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1)
8885 throw 'invalid hiddenDays'; // all days were hidden? bad.
8888 this.isHiddenDayHash = isHiddenDayHash;
8892 // Is the current day hidden?
8893 // `day` is a day-of-week index (0-6), or a Moment
8894 isHiddenDay: function(day) {
8895 if (moment.isMoment(day)) {
8898 return this.isHiddenDayHash[day];
8902 // Incrementing the current day until it is no longer a hidden day, returning a copy.
8903 // If the initial value of `date` is not a hidden day, don't do anything.
8904 // Pass `isExclusive` as `true` if you are dealing with an end date.
8905 // `inc` defaults to `1` (increment one day forward each time)
8906 skipHiddenDays: function(date, inc, isExclusive) {
8907 var out = date.clone();
8910 this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7]
8912 out.add(inc, 'days');
8918 // Returns the date range of the full days the given range visually appears to occupy.
8919 // Returns a new range object.
8920 computeDayRange: function(range) {
8921 var startDay = range.start.clone().stripTime(); // the beginning of the day the range starts
8922 var end = range.end;
8927 endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends
8928 endTimeMS = +end.time(); // # of milliseconds into `endDay`
8930 // If the end time is actually inclusively part of the next day and is equal to or
8931 // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`.
8932 // Otherwise, leaving it as inclusive will cause it to exclude `endDay`.
8933 if (endTimeMS && endTimeMS >= this.nextDayThreshold) {
8934 endDay.add(1, 'days');
8938 // If no end was specified, or if it is within `startDay` but not past nextDayThreshold,
8939 // assign the default duration of one day.
8940 if (!end || endDay <= startDay) {
8941 endDay = startDay.clone().add(1, 'days');
8944 return { start: startDay, end: endDay };
8948 // Does the given event visually appear to occupy more than one day?
8949 isMultiDayEvent: function(event) {
8950 var range = this.computeDayRange(event); // event is range-ish
8952 return range.end.diff(range.start, 'days') > 1;
8960 Embodies a div that has potential scrollbars
8962 var Scroller = FC.Scroller = Class.extend({
8964 el: null, // the guaranteed outer element
8965 scrollEl: null, // the element with the scrollbars
8970 constructor: function(options) {
8971 options = options || {};
8972 this.overflowX = options.overflowX || options.overflow || 'auto';
8973 this.overflowY = options.overflowY || options.overflow || 'auto';
8977 render: function() {
8978 this.el = this.renderEl();
8979 this.applyOverflow();
8983 renderEl: function() {
8984 return (this.scrollEl = $('<div class="fc-scroller"></div>'));
8988 // sets to natural height, unlocks overflow
8990 this.setHeight('auto');
8991 this.applyOverflow();
8995 destroy: function() {
9001 // -----------------------------------------------------------------------------------------------------------------
9004 applyOverflow: function() {
9006 'overflow-x': this.overflowX,
9007 'overflow-y': this.overflowY
9012 // Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'.
9013 // Useful for preserving scrollbar widths regardless of future resizes.
9014 // Can pass in scrollbarWidths for optimization.
9015 lockOverflow: function(scrollbarWidths) {
9016 var overflowX = this.overflowX;
9017 var overflowY = this.overflowY;
9019 scrollbarWidths = scrollbarWidths || this.getScrollbarWidths();
9021 if (overflowX === 'auto') {
9023 scrollbarWidths.top || scrollbarWidths.bottom || // horizontal scrollbars?
9024 // OR scrolling pane with massless scrollbars?
9025 this.scrollEl[0].scrollWidth - 1 > this.scrollEl[0].clientWidth
9026 // subtract 1 because of IE off-by-one issue
9027 ) ? 'scroll' : 'hidden';
9030 if (overflowY === 'auto') {
9032 scrollbarWidths.left || scrollbarWidths.right || // vertical scrollbars?
9033 // OR scrolling pane with massless scrollbars?
9034 this.scrollEl[0].scrollHeight - 1 > this.scrollEl[0].clientHeight
9035 // subtract 1 because of IE off-by-one issue
9036 ) ? 'scroll' : 'hidden';
9039 this.scrollEl.css({ 'overflow-x': overflowX, 'overflow-y': overflowY });
9043 // Getters / Setters
9044 // -----------------------------------------------------------------------------------------------------------------
9047 setHeight: function(height) {
9048 this.scrollEl.height(height);
9052 getScrollTop: function() {
9053 return this.scrollEl.scrollTop();
9057 setScrollTop: function(top) {
9058 this.scrollEl.scrollTop(top);
9062 getClientWidth: function() {
9063 return this.scrollEl[0].clientWidth;
9067 getClientHeight: function() {
9068 return this.scrollEl[0].clientHeight;
9072 getScrollbarWidths: function() {
9073 return getScrollbarWidths(this.scrollEl);
9080 var Calendar = FC.Calendar = Class.extend({
9082 dirDefaults: null, // option defaults related to LTR or RTL
9083 localeDefaults: null, // option defaults related to current locale
9084 overrides: null, // option overrides given to the fullCalendar constructor
9085 dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides.
9086 options: null, // all defaults combined with overrides
9087 viewSpecCache: null, // cache of view definitions
9088 view: null, // current View object
9090 loadingLevel: 0, // number of simultaneous loading tasks
9093 // a lot of this class' OOP logic is scoped within this constructor function,
9094 // but in the future, write individual methods on the prototype.
9095 constructor: Calendar_constructor,
9098 // Subclasses can override this for initialization logic after the constructor has been called
9099 initialize: function() {
9103 // Computes the flattened options hash for the calendar and assigns to `this.options`.
9104 // Assumes this.overrides and this.dynamicOverrides have already been initialized.
9105 populateOptionsHash: function() {
9106 var locale, localeDefaults;
9107 var isRTL, dirDefaults;
9109 locale = firstDefined( // explicit locale option given?
9110 this.dynamicOverrides.locale,
9111 this.overrides.locale
9113 localeDefaults = localeOptionHash[locale];
9114 if (!localeDefaults) { // explicit locale option not given or invalid?
9115 locale = Calendar.defaults.locale;
9116 localeDefaults = localeOptionHash[locale] || {};
9119 isRTL = firstDefined( // based on options computed so far, is direction RTL?
9120 this.dynamicOverrides.isRTL,
9121 this.overrides.isRTL,
9122 localeDefaults.isRTL,
9123 Calendar.defaults.isRTL
9125 dirDefaults = isRTL ? Calendar.rtlDefaults : {};
9127 this.dirDefaults = dirDefaults;
9128 this.localeDefaults = localeDefaults;
9129 this.options = mergeOptions([ // merge defaults and overrides. lowest to highest precedence
9130 Calendar.defaults, // global defaults
9134 this.dynamicOverrides
9136 populateInstanceComputableOptions(this.options); // fill in gaps with computed options
9140 // Gets information about how to create a view. Will use a cache.
9141 getViewSpec: function(viewType) {
9142 var cache = this.viewSpecCache;
9144 return cache[viewType] || (cache[viewType] = this.buildViewSpec(viewType));
9148 // Given a duration singular unit, like "week" or "day", finds a matching view spec.
9149 // Preference is given to views that have corresponding buttons.
9150 getUnitViewSpec: function(unit) {
9155 if ($.inArray(unit, intervalUnits) != -1) {
9157 // put views that have buttons first. there will be duplicates, but oh well
9158 viewTypes = this.header.getViewsWithButtons();
9159 $.each(FC.views, function(viewType) { // all views
9160 viewTypes.push(viewType);
9163 for (i = 0; i < viewTypes.length; i++) {
9164 spec = this.getViewSpec(viewTypes[i]);
9166 if (spec.singleUnit == unit) {
9175 // Builds an object with information on how to create a given view
9176 buildViewSpec: function(requestedViewType) {
9177 var viewOverrides = this.overrides.views || {};
9178 var specChain = []; // for the view. lowest to highest priority
9179 var defaultsChain = []; // for the view. lowest to highest priority
9180 var overridesChain = []; // for the view. lowest to highest priority
9181 var viewType = requestedViewType;
9182 var spec; // for the view
9183 var overrides; // for the view
9187 // iterate from the specific view definition to a more general one until we hit an actual View class
9189 spec = fcViews[viewType];
9190 overrides = viewOverrides[viewType];
9191 viewType = null; // clear. might repopulate for another iteration
9193 if (typeof spec === 'function') { // TODO: deprecate
9194 spec = { 'class': spec };
9198 specChain.unshift(spec);
9199 defaultsChain.unshift(spec.defaults || {});
9200 duration = duration || spec.duration;
9201 viewType = viewType || spec.type;
9205 overridesChain.unshift(overrides); // view-specific option hashes have options at zero-level
9206 duration = duration || overrides.duration;
9207 viewType = viewType || overrides.type;
9211 spec = mergeProps(specChain);
9212 spec.type = requestedViewType;
9213 if (!spec['class']) {
9218 duration = moment.duration(duration);
9219 if (duration.valueOf()) { // valid?
9220 spec.duration = duration;
9221 unit = computeIntervalUnit(duration);
9223 // view is a single-unit duration, like "week" or "day"
9224 // incorporate options for this. lowest priority
9225 if (duration.as(unit) === 1) {
9226 spec.singleUnit = unit;
9227 overridesChain.unshift(viewOverrides[unit] || {});
9232 spec.defaults = mergeOptions(defaultsChain);
9233 spec.overrides = mergeOptions(overridesChain);
9235 this.buildViewSpecOptions(spec);
9236 this.buildViewSpecButtonText(spec, requestedViewType);
9242 // Builds and assigns a view spec's options object from its already-assigned defaults and overrides
9243 buildViewSpecOptions: function(spec) {
9244 spec.options = mergeOptions([ // lowest to highest priority
9245 Calendar.defaults, // global defaults
9246 spec.defaults, // view's defaults (from ViewSubclass.defaults)
9248 this.localeDefaults, // locale and dir take precedence over view's defaults!
9249 this.overrides, // calendar's overrides (options given to constructor)
9250 spec.overrides, // view's overrides (view-specific options)
9251 this.dynamicOverrides // dynamically set via setter. highest precedence
9253 populateInstanceComputableOptions(spec.options);
9257 // Computes and assigns a view spec's buttonText-related options
9258 buildViewSpecButtonText: function(spec, requestedViewType) {
9260 // given an options object with a possible `buttonText` hash, lookup the buttonText for the
9261 // requested view, falling back to a generic unit entry like "week" or "day"
9262 function queryButtonText(options) {
9263 var buttonText = options.buttonText || {};
9264 return buttonText[requestedViewType] ||
9265 // view can decide to look up a certain key
9266 (spec.buttonTextKey ? buttonText[spec.buttonTextKey] : null) ||
9267 // a key like "month"
9268 (spec.singleUnit ? buttonText[spec.singleUnit] : null);
9271 // highest to lowest priority
9272 spec.buttonTextOverride =
9273 queryButtonText(this.dynamicOverrides) ||
9274 queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence
9275 spec.overrides.buttonText; // `buttonText` for view-specific options is a string
9277 // highest to lowest priority. mirrors buildViewSpecOptions
9278 spec.buttonTextDefault =
9279 queryButtonText(this.localeDefaults) ||
9280 queryButtonText(this.dirDefaults) ||
9281 spec.defaults.buttonText || // a single string. from ViewSubclass.defaults
9282 queryButtonText(Calendar.defaults) ||
9283 (spec.duration ? this.humanizeDuration(spec.duration) : null) || // like "3 days"
9284 requestedViewType; // fall back to given view name
9288 // Given a view name for a custom view or a standard view, creates a ready-to-go View object
9289 instantiateView: function(viewType) {
9290 var spec = this.getViewSpec(viewType);
9292 return new spec['class'](this, viewType, spec.options, spec.duration);
9296 // Returns a boolean about whether the view is okay to instantiate at some point
9297 isValidViewType: function(viewType) {
9298 return Boolean(this.getViewSpec(viewType));
9302 // Should be called when any type of async data fetching begins
9303 pushLoading: function() {
9304 if (!(this.loadingLevel++)) {
9305 this.trigger('loading', null, true, this.view);
9310 // Should be called when any type of async data fetching completes
9311 popLoading: function() {
9312 if (!(--this.loadingLevel)) {
9313 this.trigger('loading', null, false, this.view);
9318 // Given arguments to the select method in the API, returns a span (unzoned start/end and other info)
9319 buildSelectSpan: function(zonedStartInput, zonedEndInput) {
9320 var start = this.moment(zonedStartInput).stripZone();
9323 if (zonedEndInput) {
9324 end = this.moment(zonedEndInput).stripZone();
9326 else if (start.hasTime()) {
9327 end = start.clone().add(this.defaultTimedEventDuration);
9330 end = start.clone().add(this.defaultAllDayEventDuration);
9333 return { start: start, end: end };
9339 Calendar.mixin(EmitterMixin);
9342 function Calendar_constructor(element, overrides) {
9347 // -----------------------------------------------------------------------------------
9350 t.destroy = destroy;
9351 t.refetchEvents = refetchEvents;
9352 t.refetchEventSources = refetchEventSources;
9353 t.reportEvents = reportEvents;
9354 t.reportEventChange = reportEventChange;
9355 t.rerenderEvents = renderEvents; // `renderEvents` serves as a rerender. an API method
9356 t.changeView = renderView; // `renderView` will switch to another view
9358 t.unselect = unselect;
9361 t.prevYear = prevYear;
9362 t.nextYear = nextYear;
9364 t.gotoDate = gotoDate;
9365 t.incrementDate = incrementDate;
9367 t.getDate = getDate;
9368 t.getCalendar = getCalendar;
9369 t.getView = getView;
9370 t.option = option; // getter/setter method
9371 t.trigger = trigger;
9375 // -----------------------------------------------------------------------------------
9377 t.dynamicOverrides = {};
9378 t.viewSpecCache = {};
9379 t.optionHandlers = {}; // for Calendar.options.js
9380 t.overrides = $.extend({}, overrides); // make a copy
9382 t.populateOptionsHash(); // sets this.options
9386 // Locale-data Internals
9387 // -----------------------------------------------------------------------------------
9388 // Apply overrides to the current locale's data
9392 // Called immediately, and when any of the options change.
9393 // Happens before any internal objects rebuild or rerender, because this is very core.
9395 'locale', 'monthNames', 'monthNamesShort', 'dayNames', 'dayNamesShort', 'firstDay', 'weekNumberCalculation'
9396 ], function(locale, monthNames, monthNamesShort, dayNames, dayNamesShort, firstDay, weekNumberCalculation) {
9399 if (weekNumberCalculation === 'iso') {
9400 weekNumberCalculation = 'ISO'; // normalize
9403 localeData = createObject( // make a cheap copy
9404 getMomentLocaleData(locale) // will fall back to en
9408 localeData._months = monthNames;
9410 if (monthNamesShort) {
9411 localeData._monthsShort = monthNamesShort;
9414 localeData._weekdays = dayNames;
9416 if (dayNamesShort) {
9417 localeData._weekdaysShort = dayNamesShort;
9420 if (firstDay == null && weekNumberCalculation === 'ISO') {
9423 if (firstDay != null) {
9424 var _week = createObject(localeData._week); // _week: { dow: # }
9425 _week.dow = firstDay;
9426 localeData._week = _week;
9429 if ( // whitelist certain kinds of input
9430 weekNumberCalculation === 'ISO' ||
9431 weekNumberCalculation === 'local' ||
9432 typeof weekNumberCalculation === 'function'
9434 localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it
9437 // If the internal current date object already exists, move to new locale.
9438 // We do NOT need to do this technique for event dates, because this happens when converting to "segments".
9440 localizeMoment(date); // sets to localeData
9445 // Calendar-specific Date Utilities
9446 // -----------------------------------------------------------------------------------
9449 t.defaultAllDayEventDuration = moment.duration(t.options.defaultAllDayEventDuration);
9450 t.defaultTimedEventDuration = moment.duration(t.options.defaultTimedEventDuration);
9453 // Builds a moment using the settings of the current calendar: timezone and locale.
9454 // Accepts anything the vanilla moment() constructor accepts.
9455 t.moment = function() {
9458 if (t.options.timezone === 'local') {
9459 mom = FC.moment.apply(null, arguments);
9461 // Force the moment to be local, because FC.moment doesn't guarantee it.
9462 if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone
9466 else if (t.options.timezone === 'UTC') {
9467 mom = FC.moment.utc.apply(null, arguments); // process as UTC
9470 mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone
9473 localizeMoment(mom);
9479 // Updates the given moment's locale settings to the current calendar locale settings.
9480 function localizeMoment(mom) {
9481 mom._locale = localeData;
9483 t.localizeMoment = localizeMoment;
9486 // Returns a boolean about whether or not the calendar knows how to calculate
9487 // the timezone offset of arbitrary dates in the current timezone.
9488 t.getIsAmbigTimezone = function() {
9489 return t.options.timezone !== 'local' && t.options.timezone !== 'UTC';
9493 // Returns a copy of the given date in the current timezone. Has no effect on dates without times.
9494 t.applyTimezone = function(date) {
9495 if (!date.hasTime()) {
9496 return date.clone();
9499 var zonedDate = t.moment(date.toArray());
9500 var timeAdjust = date.time() - zonedDate.time();
9501 var adjustedZonedDate;
9503 // Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396)
9504 if (timeAdjust) { // is the time result different than expected?
9505 adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds
9506 if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now?
9507 zonedDate = adjustedZonedDate;
9515 // Returns a moment for the current date, as defined by the client's computer or from the `now` option.
9516 // Will return an moment with an ambiguous timezone.
9517 t.getNow = function() {
9518 var now = t.options.now;
9519 if (typeof now === 'function') {
9522 return t.moment(now).stripZone();
9526 // Get an event's normalized end date. If not present, calculate it from the defaults.
9527 t.getEventEnd = function(event) {
9529 return event.end.clone();
9532 return t.getDefaultEventEnd(event.allDay, event.start);
9537 // Given an event's allDay status and start date, return what its fallback end date should be.
9538 // TODO: rename to computeDefaultEventEnd
9539 t.getDefaultEventEnd = function(allDay, zonedStart) {
9540 var end = zonedStart.clone();
9543 end.stripTime().add(t.defaultAllDayEventDuration);
9546 end.add(t.defaultTimedEventDuration);
9549 if (t.getIsAmbigTimezone()) {
9550 end.stripZone(); // we don't know what the tzo should be
9557 // Produces a human-readable string for the given duration.
9558 // Side-effect: changes the locale of the given duration.
9559 t.humanizeDuration = function(duration) {
9560 return duration.locale(t.options.locale).humanize();
9566 // -----------------------------------------------------------------------------------
9569 EventManager.call(t);
9570 var isFetchNeeded = t.isFetchNeeded;
9571 var fetchEvents = t.fetchEvents;
9572 var fetchEventSources = t.fetchEventSources;
9577 // -----------------------------------------------------------------------------------
9580 var _element = element[0];
9583 var tm; // for making theme classes
9584 var currentView; // NOTE: keep this in sync with this.view
9585 var viewsByType = {}; // holds all instantiated view instances, current or not
9586 var suggestedViewHeight;
9587 var windowResizeProxy; // wraps the windowResize function
9588 var ignoreWindowResize = 0;
9590 var date; // unzoned
9595 // -----------------------------------------------------------------------------------
9598 // compute the initial ambig-timezone date
9599 if (t.options.defaultDate != null) {
9600 date = t.moment(t.options.defaultDate).stripZone();
9603 date = t.getNow(); // getNow already returns unzoned
9611 else if (elementVisible()) {
9612 // mainly for the public API
9619 function initialRender() {
9620 element.addClass('fc');
9622 // event delegation for nav links
9623 element.on('click.fc', 'a[data-goto]', function(ev) {
9624 var anchorEl = $(this);
9625 var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON
9626 var date = t.moment(gotoOptions.date);
9627 var viewType = gotoOptions.type;
9629 // property like "navLinkDayClick". might be a string or a function
9630 var customAction = currentView.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click');
9632 if (typeof customAction === 'function') {
9633 customAction(date, ev);
9636 if (typeof customAction === 'string') {
9637 viewType = customAction;
9639 zoomTo(date, viewType);
9643 // called immediately, and upon option change
9644 t.bindOption('theme', function(theme) {
9645 tm = theme ? 'ui' : 'fc'; // affects a larger scope
9646 element.toggleClass('ui-widget', theme);
9647 element.toggleClass('fc-unthemed', !theme);
9650 // called immediately, and upon option change.
9651 // HACK: locale often affects isRTL, so we explicitly listen to that too.
9652 t.bindOptions([ 'isRTL', 'locale' ], function(isRTL) {
9653 element.toggleClass('fc-ltr', !isRTL);
9654 element.toggleClass('fc-rtl', isRTL);
9657 content = $("<div class='fc-view-container'/>").prependTo(element);
9659 header = t.header = new Header(t);
9662 renderView(t.options.defaultView);
9664 if (t.options.handleWindowResize) {
9665 windowResizeProxy = debounce(windowResize, t.options.windowResizeDelay); // prevents rapid calls
9666 $(window).resize(windowResizeProxy);
9671 // can be called repeatedly and Header will rerender
9672 function renderHeader() {
9675 element.prepend(header.el);
9680 function destroy() {
9683 currentView.removeElement();
9685 // NOTE: don't null-out currentView/t.view in case API methods are called after destroy.
9686 // It is still the "current" view, just not rendered.
9689 header.removeElement();
9691 element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget');
9693 element.off('.fc'); // unbind nav link handlers
9695 if (windowResizeProxy) {
9696 $(window).unbind('resize', windowResizeProxy);
9701 function elementVisible() {
9702 return element.is(':visible');
9708 // -----------------------------------------------------------------------------------
9711 // Renders a view because of a date change, view-type change, or for the first time.
9712 // If not given a viewType, keep the current view but render different dates.
9713 // Accepts an optional scroll state to restore to.
9714 function renderView(viewType, explicitScrollState) {
9715 ignoreWindowResize++;
9717 // if viewType is changing, remove the old view's rendering
9718 if (currentView && viewType && currentView.type !== viewType) {
9719 freezeContentHeight(); // prevent a scroll jump when view element is removed
9723 // if viewType changed, or the view was never created, create a fresh view
9724 if (!currentView && viewType) {
9725 currentView = t.view =
9726 viewsByType[viewType] ||
9727 (viewsByType[viewType] = t.instantiateView(viewType));
9729 currentView.setElement(
9730 $("<div class='fc-view fc-" + viewType + "-view' />").appendTo(content)
9732 header.activateButton(viewType);
9737 // in case the view should render a period of time that is completely hidden
9738 date = currentView.massageCurrentDate(date);
9740 // render or rerender the view
9742 !currentView.displaying ||
9743 !( // NOT within interval range signals an implicit date window change
9744 date >= currentView.intervalStart &&
9745 date < currentView.intervalEnd
9748 if (elementVisible()) {
9750 currentView.display(date, explicitScrollState); // will call freezeContentHeight
9751 unfreezeContentHeight(); // immediately unfreeze regardless of whether display is async
9753 // need to do this after View::render, so dates are calculated
9754 updateHeaderTitle();
9755 updateTodayButton();
9757 getAndRenderEvents();
9762 unfreezeContentHeight(); // undo any lone freezeContentHeight calls
9763 ignoreWindowResize--;
9767 // Unrenders the current view and reflects this change in the Header.
9768 // Unregsiters the `currentView`, but does not remove from viewByType hash.
9769 function clearView() {
9770 header.deactivateButton(currentView.type);
9771 currentView.removeElement();
9772 currentView = t.view = null;
9776 // Destroys the view, including the view object. Then, re-instantiates it and renders it.
9777 // Maintains the same scroll state.
9778 // TODO: maintain any other user-manipulated state.
9779 function reinitView() {
9780 ignoreWindowResize++;
9781 freezeContentHeight();
9783 var viewType = currentView.type;
9784 var scrollState = currentView.queryScroll();
9786 renderView(viewType, scrollState);
9788 unfreezeContentHeight();
9789 ignoreWindowResize--;
9795 // -----------------------------------------------------------------------------------
9798 t.getSuggestedViewHeight = function() {
9799 if (suggestedViewHeight === undefined) {
9802 return suggestedViewHeight;
9806 t.isHeightAuto = function() {
9807 return t.options.contentHeight === 'auto' || t.options.height === 'auto';
9811 function updateSize(shouldRecalc) {
9812 if (elementVisible()) {
9818 ignoreWindowResize++;
9819 currentView.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto()
9820 ignoreWindowResize--;
9822 return true; // signal success
9827 function calcSize() {
9828 if (elementVisible()) {
9834 function _calcSize() { // assumes elementVisible
9835 var contentHeightInput = t.options.contentHeight;
9836 var heightInput = t.options.height;
9838 if (typeof contentHeightInput === 'number') { // exists and not 'auto'
9839 suggestedViewHeight = contentHeightInput;
9841 else if (typeof contentHeightInput === 'function') { // exists and is a function
9842 suggestedViewHeight = contentHeightInput();
9844 else if (typeof heightInput === 'number') { // exists and not 'auto'
9845 suggestedViewHeight = heightInput - queryHeaderHeight();
9847 else if (typeof heightInput === 'function') { // exists and is a function
9848 suggestedViewHeight = heightInput() - queryHeaderHeight();
9850 else if (heightInput === 'parent') { // set to height of parent element
9851 suggestedViewHeight = element.parent().height() - queryHeaderHeight();
9854 suggestedViewHeight = Math.round(content.width() / Math.max(t.options.aspectRatio, .5));
9859 function queryHeaderHeight() {
9860 return header.el ? header.el.outerHeight(true) : 0; // includes margin
9864 function windowResize(ev) {
9866 !ignoreWindowResize &&
9867 ev.target === window && // so we don't process jqui "resize" events that have bubbled up
9868 currentView.start // view has already been rendered
9870 if (updateSize(true)) {
9871 currentView.trigger('windowResize', _element);
9878 /* Event Fetching/Rendering
9879 -----------------------------------------------------------------------------*/
9880 // TODO: going forward, most of this stuff should be directly handled by the view
9883 function refetchEvents() { // can be called as an API method
9884 fetchAndRenderEvents();
9888 // TODO: move this into EventManager?
9889 function refetchEventSources(matchInputs) {
9890 fetchEventSources(t.getEventSourcesByMatchArray(matchInputs));
9894 function renderEvents() { // destroys old events if previously rendered
9895 if (elementVisible()) {
9896 freezeContentHeight();
9897 currentView.displayEvents(events);
9898 unfreezeContentHeight();
9903 function getAndRenderEvents() {
9904 if (!t.options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) {
9905 fetchAndRenderEvents();
9913 function fetchAndRenderEvents() {
9914 fetchEvents(currentView.start, currentView.end);
9915 // ... will call reportEvents
9916 // ... which will call renderEvents
9920 // called when event data arrives
9921 function reportEvents(_events) {
9927 // called when a single event's data has been changed
9928 function reportEventChange() {
9935 -----------------------------------------------------------------------------*/
9938 function updateHeaderTitle() {
9939 header.updateTitle(currentView.title);
9943 function updateTodayButton() {
9944 var now = t.getNow();
9946 if (now >= currentView.intervalStart && now < currentView.intervalEnd) {
9947 header.disableButton('today');
9950 header.enableButton('today');
9957 -----------------------------------------------------------------------------*/
9960 // this public method receives start/end dates in any format, with any timezone
9961 function select(zonedStartInput, zonedEndInput) {
9963 t.buildSelectSpan.apply(t, arguments)
9968 function unselect() { // safe to be called before renderView
9970 currentView.unselect();
9977 -----------------------------------------------------------------------------*/
9981 date = currentView.computePrevDate(date);
9987 date = currentView.computeNextDate(date);
9992 function prevYear() {
9993 date.add(-1, 'years');
9998 function nextYear() {
9999 date.add(1, 'years');
10010 function gotoDate(zonedDateInput) {
10011 date = t.moment(zonedDateInput).stripZone();
10016 function incrementDate(delta) {
10017 date.add(moment.duration(delta));
10022 // Forces navigation to a view for the given date.
10023 // `viewType` can be a specific view name or a generic one like "week" or "day".
10024 function zoomTo(newDate, viewType) {
10027 viewType = viewType || 'day'; // day is default zoom
10028 spec = t.getViewSpec(viewType) || t.getUnitViewSpec(viewType);
10030 date = newDate.clone();
10031 renderView(spec ? spec.type : null);
10035 // for external API
10036 function getDate() {
10037 return t.applyTimezone(date); // infuse the calendar's timezone
10042 /* Height "Freezing"
10043 -----------------------------------------------------------------------------*/
10044 // TODO: move this into the view
10046 t.freezeContentHeight = freezeContentHeight;
10047 t.unfreezeContentHeight = unfreezeContentHeight;
10050 function freezeContentHeight() {
10053 height: content.height(),
10059 function unfreezeContentHeight() {
10070 -----------------------------------------------------------------------------*/
10073 function getCalendar() {
10078 function getView() {
10079 return currentView;
10083 function option(name, value) {
10086 if (typeof name === 'string') {
10087 if (value === undefined) { // getter
10088 return t.options[name];
10090 else { // setter for individual option
10091 newOptionHash = {};
10092 newOptionHash[name] = value;
10093 setOptions(newOptionHash);
10096 else if (typeof name === 'object') { // compound setter with object input
10102 function setOptions(newOptionHash) {
10106 for (optionName in newOptionHash) {
10107 t.dynamicOverrides[optionName] = newOptionHash[optionName];
10110 t.viewSpecCache = {}; // the dynamic override invalidates the options in this cache, so just clear it
10111 t.populateOptionsHash(); // this.options needs to be recomputed after the dynamic override
10113 // trigger handlers after this.options has been updated
10114 for (optionName in newOptionHash) {
10115 t.triggerOptionHandlers(optionName); // recall bindOption/bindOptions
10119 // special-case handling of single option change.
10120 // if only one option change, `optionName` will be its name.
10121 if (optionCnt === 1) {
10122 if (optionName === 'height' || optionName === 'contentHeight' || optionName === 'aspectRatio') {
10123 updateSize(true); // true = allow recalculation of height
10126 else if (optionName === 'defaultDate') {
10127 return; // can't change date this way. use gotoDate instead
10129 else if (optionName === 'businessHours') {
10131 currentView.unrenderBusinessHours();
10132 currentView.renderBusinessHours();
10136 else if (optionName === 'timezone') {
10137 t.rezoneArrayEventSources();
10143 // catch-all. rerender the header and rebuild/rerender the current view
10145 viewsByType = {}; // even non-current views will be affected by this option change. do before rerender
10150 function trigger(name, thisObj) { // overrides the Emitter's trigger method :(
10151 var args = Array.prototype.slice.call(arguments, 2);
10153 thisObj = thisObj || _element;
10154 this.triggerWith(name, thisObj, args); // Emitter's method
10156 if (t.options[name]) {
10157 return t.options[name].apply(thisObj, args);
10166 Options binding/triggering system.
10170 // A map of option names to arrays of handler objects. Initialized to {} in Calendar.
10171 // Format for a handler object:
10173 // func // callback function to be called upon change
10174 // names // option names whose values should be given to func
10176 optionHandlers: null,
10178 // Calls handlerFunc immediately, and when the given option has changed.
10179 // handlerFunc will be given the option value.
10180 bindOption: function(optionName, handlerFunc) {
10181 this.bindOptions([ optionName ], handlerFunc);
10184 // Calls handlerFunc immediately, and when any of the given options change.
10185 // handlerFunc will be given each option value as ordered function arguments.
10186 bindOptions: function(optionNames, handlerFunc) {
10187 var handlerObj = { func: handlerFunc, names: optionNames };
10190 for (i = 0; i < optionNames.length; i++) {
10191 this.registerOptionHandlerObj(optionNames[i], handlerObj);
10194 this.triggerOptionHandlerObj(handlerObj);
10197 // Puts the given handler object into the internal hash
10198 registerOptionHandlerObj: function(optionName, handlerObj) {
10199 (this.optionHandlers[optionName] || (this.optionHandlers[optionName] = []))
10203 // Reports that the given option has changed, and calls all appropriate handlers.
10204 triggerOptionHandlers: function(optionName) {
10205 var handlerObjs = this.optionHandlers[optionName] || [];
10208 for (i = 0; i < handlerObjs.length; i++) {
10209 this.triggerOptionHandlerObj(handlerObjs[i]);
10213 // Calls the callback for a specific handler object, passing in the appropriate arguments.
10214 triggerOptionHandlerObj: function(handlerObj) {
10215 var optionNames = handlerObj.names;
10216 var optionValues = [];
10219 for (i = 0; i < optionNames.length; i++) {
10220 optionValues.push(this.options[optionNames[i]]);
10223 handlerObj.func.apply(this, optionValues); // maintain the Calendar's `this` context
10230 Calendar.defaults = {
10232 titleRangeSeparator: ' \u2013 ', // en dash
10233 monthYearFormat: 'MMMM YYYY', // required for en. other locales rely on datepicker computable option
10235 defaultTimedEventDuration: '02:00:00',
10236 defaultAllDayEventDuration: { days: 1 },
10237 forceEventDuration: false,
10238 nextDayThreshold: '09:00:00', // 9am
10241 defaultView: 'month',
10246 right: 'today prev,next'
10249 weekNumbers: false,
10251 weekNumberTitle: 'W',
10252 weekNumberCalculation: 'local',
10256 //nowIndicator: false,
10258 scrollTime: '06:00:00',
10261 lazyFetching: true,
10262 startParam: 'start',
10264 timezoneParam: 'timezone',
10268 //allDayDefault: undefined,
10275 prevYear: "prev year",
10276 nextYear: "next year",
10277 year: 'year', // TODO: locale files need to specify this
10285 prev: 'left-single-arrow',
10286 next: 'right-single-arrow',
10287 prevYear: 'left-double-arrow',
10288 nextYear: 'right-double-arrow'
10291 allDayText: 'all-day',
10293 // jquery-ui theming
10295 themeButtonIcons: {
10296 prev: 'circle-triangle-w',
10297 next: 'circle-triangle-e',
10298 prevYear: 'seek-prev',
10299 nextYear: 'seek-next'
10302 //eventResizableFromStart: false,
10304 dragRevertDuration: 500,
10307 //selectable: false,
10308 unselectAuto: true,
10312 eventOrder: 'title',
10315 eventLimitText: 'more',
10316 eventLimitClick: 'popover',
10317 dayPopoverFormat: 'LL',
10319 handleWindowResize: true,
10320 windowResizeDelay: 100, // milliseconds before an updateSize happens
10322 longPressDelay: 1000
10327 Calendar.englishDefaults = { // used by locale.js
10328 dayPopoverFormat: 'dddd, MMMM D'
10332 Calendar.rtlDefaults = { // right-to-left defaults
10333 header: { // TODO: smarter solution (first/center/last ?)
10334 left: 'next,prev today',
10339 prev: 'right-single-arrow',
10340 next: 'left-single-arrow',
10341 prevYear: 'right-double-arrow',
10342 nextYear: 'left-double-arrow'
10344 themeButtonIcons: {
10345 prev: 'circle-triangle-e',
10346 next: 'circle-triangle-w',
10347 nextYear: 'seek-prev',
10348 prevYear: 'seek-next'
10354 var localeOptionHash = FC.locales = {}; // initialize and expose
10357 // TODO: document the structure and ordering of a FullCalendar locale file
10360 // Initialize jQuery UI datepicker translations while using some of the translations
10361 // Will set this as the default locales for datepicker.
10362 FC.datepickerLocale = function(localeCode, dpLocaleCode, dpOptions) {
10364 // get the FullCalendar internal option hash for this locale. create if necessary
10365 var fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {});
10367 // transfer some simple options from datepicker to fc
10368 fcOptions.isRTL = dpOptions.isRTL;
10369 fcOptions.weekNumberTitle = dpOptions.weekHeader;
10371 // compute some more complex options from datepicker
10372 $.each(dpComputableOptions, function(name, func) {
10373 fcOptions[name] = func(dpOptions);
10376 // is jQuery UI Datepicker is on the page?
10377 if ($.datepicker) {
10379 // Register the locale data.
10380 // FullCalendar and MomentJS use locale codes like "pt-br" but Datepicker
10381 // does it like "pt-BR" or if it doesn't have the locale, maybe just "pt".
10382 // Make an alias so the locale can be referenced either way.
10383 $.datepicker.regional[dpLocaleCode] =
10384 $.datepicker.regional[localeCode] = // alias
10387 // Alias 'en' to the default locale data. Do this every time.
10388 $.datepicker.regional.en = $.datepicker.regional[''];
10390 // Set as Datepicker's global defaults.
10391 $.datepicker.setDefaults(dpOptions);
10396 // Sets FullCalendar-specific translations. Will set the locales as the global default.
10397 FC.locale = function(localeCode, newFcOptions) {
10401 // get the FullCalendar internal option hash for this locale. create if necessary
10402 fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {});
10404 // provided new options for this locales? merge them in
10405 if (newFcOptions) {
10406 fcOptions = localeOptionHash[localeCode] = mergeOptions([ fcOptions, newFcOptions ]);
10409 // compute locale options that weren't defined.
10410 // always do this. newFcOptions can be undefined when initializing from i18n file,
10411 // so no way to tell if this is an initialization or a default-setting.
10412 momOptions = getMomentLocaleData(localeCode); // will fall back to en
10413 $.each(momComputableOptions, function(name, func) {
10414 if (fcOptions[name] == null) {
10415 fcOptions[name] = func(momOptions, fcOptions);
10419 // set it as the default locale for FullCalendar
10420 Calendar.defaults.locale = localeCode;
10424 // NOTE: can't guarantee any of these computations will run because not every locale has datepicker
10425 // configs, so make sure there are English fallbacks for these in the defaults file.
10426 var dpComputableOptions = {
10428 buttonText: function(dpOptions) {
10430 // the translations sometimes wrongly contain HTML entities
10431 prev: stripHtmlEntities(dpOptions.prevText),
10432 next: stripHtmlEntities(dpOptions.nextText),
10433 today: stripHtmlEntities(dpOptions.currentText)
10437 // Produces format strings like "MMMM YYYY" -> "September 2014"
10438 monthYearFormat: function(dpOptions) {
10439 return dpOptions.showMonthAfterYear ?
10440 'YYYY[' + dpOptions.yearSuffix + '] MMMM' :
10441 'MMMM YYYY[' + dpOptions.yearSuffix + ']';
10446 var momComputableOptions = {
10448 // Produces format strings like "ddd M/D" -> "Fri 9/15"
10449 dayOfMonthFormat: function(momOptions, fcOptions) {
10450 var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY"
10452 // strip the year off the edge, as well as other misc non-whitespace chars
10453 format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, '');
10455 if (fcOptions.isRTL) {
10456 format += ' ddd'; // for RTL, add day-of-week to end
10459 format = 'ddd ' + format; // for LTR, add day-of-week to beginning
10464 // Produces format strings like "h:mma" -> "6:00pm"
10465 mediumTimeFormat: function(momOptions) { // can't be called `timeFormat` because collides with option
10466 return momOptions.longDateFormat('LT')
10467 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
10470 // Produces format strings like "h(:mm)a" -> "6pm" / "6:30pm"
10471 smallTimeFormat: function(momOptions) {
10472 return momOptions.longDateFormat('LT')
10473 .replace(':mm', '(:mm)')
10474 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales
10475 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
10478 // Produces format strings like "h(:mm)t" -> "6p" / "6:30p"
10479 extraSmallTimeFormat: function(momOptions) {
10480 return momOptions.longDateFormat('LT')
10481 .replace(':mm', '(:mm)')
10482 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales
10483 .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand
10486 // Produces format strings like "ha" / "H" -> "6pm" / "18"
10487 hourFormat: function(momOptions) {
10488 return momOptions.longDateFormat('LT')
10489 .replace(':mm', '')
10490 .replace(/(\Wmm)$/, '') // like above, but for foreign locales
10491 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
10494 // Produces format strings like "h:mm" -> "6:30" (with no AM/PM)
10495 noMeridiemTimeFormat: function(momOptions) {
10496 return momOptions.longDateFormat('LT')
10497 .replace(/\s*a$/i, ''); // remove trailing AM/PM
10503 // options that should be computed off live calendar options (considers override options)
10504 // TODO: best place for this? related to locale?
10505 // TODO: flipping text based on isRTL is a bad idea because the CSS `direction` might want to handle it
10506 var instanceComputableOptions = {
10508 // Produces format strings for results like "Mo 16"
10509 smallDayDateFormat: function(options) {
10510 return options.isRTL ?
10515 // Produces format strings for results like "Wk 5"
10516 weekFormat: function(options) {
10517 return options.isRTL ?
10518 'w[ ' + options.weekNumberTitle + ']' :
10519 '[' + options.weekNumberTitle + ' ]w';
10522 // Produces format strings for results like "Wk5"
10523 smallWeekFormat: function(options) {
10524 return options.isRTL ?
10525 'w[' + options.weekNumberTitle + ']' :
10526 '[' + options.weekNumberTitle + ']w';
10531 function populateInstanceComputableOptions(options) {
10532 $.each(instanceComputableOptions, function(name, func) {
10533 if (options[name] == null) {
10534 options[name] = func(options);
10540 // Returns moment's internal locale data. If doesn't exist, returns English.
10541 function getMomentLocaleData(localeCode) {
10542 return moment.localeData(localeCode) || moment.localeData('en');
10546 // Initialize English by forcing computation of moment-derived options.
10547 // Also, sets it as the default.
10548 FC.locale('en', Calendar.englishDefaults);
10552 /* Top toolbar area with buttons and title
10553 ----------------------------------------------------------------------------------------------------------------------*/
10554 // TODO: rename all header-related things to "toolbar"
10556 function Header(calendar) {
10561 t.removeElement = removeElement;
10562 t.updateTitle = updateTitle;
10563 t.activateButton = activateButton;
10564 t.deactivateButton = deactivateButton;
10565 t.disableButton = disableButton;
10566 t.enableButton = enableButton;
10567 t.getViewsWithButtons = getViewsWithButtons;
10568 t.el = null; // mirrors local `el`
10572 var viewsWithButtons = [];
10576 // can be called repeatedly and will rerender
10577 function render() {
10578 var options = calendar.options;
10579 var sections = options.header;
10581 tm = options.theme ? 'ui' : 'fc';
10585 el = this.el = $("<div class='fc-toolbar'/>");
10590 el.append(renderSection('left'))
10591 .append(renderSection('right'))
10592 .append(renderSection('center'))
10593 .append('<div class="fc-clear"/>');
10601 function removeElement() {
10609 function renderSection(position) {
10610 var sectionEl = $('<div class="fc-' + position + '"/>');
10611 var options = calendar.options;
10612 var buttonStr = options.header[position];
10615 $.each(buttonStr.split(' '), function(i) {
10616 var groupChildren = $();
10617 var isOnlyButtons = true;
10620 $.each(this.split(','), function(j, buttonName) {
10621 var customButtonProps;
10624 var overrideText; // text explicitly set by calendar's constructor options. overcomes icons
10630 var button; // the element
10632 if (buttonName == 'title') {
10633 groupChildren = groupChildren.add($('<h2> </h2>')); // we always want it to take up height
10634 isOnlyButtons = false;
10637 if ((customButtonProps = (options.customButtons || {})[buttonName])) {
10638 buttonClick = function(ev) {
10639 if (customButtonProps.click) {
10640 customButtonProps.click.call(button[0], ev);
10643 overrideText = ''; // icons will override text
10644 defaultText = customButtonProps.text;
10646 else if ((viewSpec = calendar.getViewSpec(buttonName))) {
10647 buttonClick = function() {
10648 calendar.changeView(buttonName);
10650 viewsWithButtons.push(buttonName);
10651 overrideText = viewSpec.buttonTextOverride;
10652 defaultText = viewSpec.buttonTextDefault;
10654 else if (calendar[buttonName]) { // a calendar method
10655 buttonClick = function() {
10656 calendar[buttonName]();
10658 overrideText = (calendar.overrides.buttonText || {})[buttonName];
10659 defaultText = options.buttonText[buttonName]; // everything else is considered default
10665 customButtonProps ?
10666 customButtonProps.themeIcon :
10667 options.themeButtonIcons[buttonName];
10670 customButtonProps ?
10671 customButtonProps.icon :
10672 options.buttonIcons[buttonName];
10674 if (overrideText) {
10675 innerHtml = htmlEscape(overrideText);
10677 else if (themeIcon && options.theme) {
10678 innerHtml = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>";
10680 else if (normalIcon && !options.theme) {
10681 innerHtml = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>";
10684 innerHtml = htmlEscape(defaultText);
10688 'fc-' + buttonName + '-button',
10690 tm + '-state-default'
10693 button = $( // type="button" so that it doesn't submit a form
10694 '<button type="button" class="' + classes.join(' ') + '">' +
10698 .click(function(ev) {
10699 // don't process clicks for disabled buttons
10700 if (!button.hasClass(tm + '-state-disabled')) {
10704 // after the click action, if the button becomes the "active" tab, or disabled,
10705 // it should never have a hover class, so remove it now.
10707 button.hasClass(tm + '-state-active') ||
10708 button.hasClass(tm + '-state-disabled')
10710 button.removeClass(tm + '-state-hover');
10714 .mousedown(function() {
10715 // the *down* effect (mouse pressed in).
10716 // only on buttons that are not the "active" tab, or disabled
10718 .not('.' + tm + '-state-active')
10719 .not('.' + tm + '-state-disabled')
10720 .addClass(tm + '-state-down');
10722 .mouseup(function() {
10723 // undo the *down* effect
10724 button.removeClass(tm + '-state-down');
10728 // the *hover* effect.
10729 // only on buttons that are not the "active" tab, or disabled
10731 .not('.' + tm + '-state-active')
10732 .not('.' + tm + '-state-disabled')
10733 .addClass(tm + '-state-hover');
10736 // undo the *hover* effect
10738 .removeClass(tm + '-state-hover')
10739 .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup
10743 groupChildren = groupChildren.add(button);
10748 if (isOnlyButtons) {
10750 .first().addClass(tm + '-corner-left').end()
10751 .last().addClass(tm + '-corner-right').end();
10754 if (groupChildren.length > 1) {
10755 groupEl = $('<div/>');
10756 if (isOnlyButtons) {
10757 groupEl.addClass('fc-button-group');
10759 groupEl.append(groupChildren);
10760 sectionEl.append(groupEl);
10763 sectionEl.append(groupChildren); // 1 or 0 children
10772 function updateTitle(text) {
10774 el.find('h2').text(text);
10779 function activateButton(buttonName) {
10781 el.find('.fc-' + buttonName + '-button')
10782 .addClass(tm + '-state-active');
10787 function deactivateButton(buttonName) {
10789 el.find('.fc-' + buttonName + '-button')
10790 .removeClass(tm + '-state-active');
10795 function disableButton(buttonName) {
10797 el.find('.fc-' + buttonName + '-button')
10798 .prop('disabled', true)
10799 .addClass(tm + '-state-disabled');
10804 function enableButton(buttonName) {
10806 el.find('.fc-' + buttonName + '-button')
10807 .prop('disabled', false)
10808 .removeClass(tm + '-state-disabled');
10813 function getViewsWithButtons() {
10814 return viewsWithButtons;
10821 FC.sourceNormalizers = [];
10822 FC.sourceFetchers = [];
10824 var ajaxDefaults = {
10832 function EventManager() { // assumed to be a calendar
10837 t.isFetchNeeded = isFetchNeeded;
10838 t.fetchEvents = fetchEvents;
10839 t.fetchEventSources = fetchEventSources;
10840 t.getEventSources = getEventSources;
10841 t.getEventSourceById = getEventSourceById;
10842 t.getEventSourcesByMatchArray = getEventSourcesByMatchArray;
10843 t.getEventSourcesByMatch = getEventSourcesByMatch;
10844 t.addEventSource = addEventSource;
10845 t.removeEventSource = removeEventSource;
10846 t.removeEventSources = removeEventSources;
10847 t.updateEvent = updateEvent;
10848 t.renderEvent = renderEvent;
10849 t.removeEvents = removeEvents;
10850 t.clientEvents = clientEvents;
10851 t.mutateEvent = mutateEvent;
10852 t.normalizeEventDates = normalizeEventDates;
10853 t.normalizeEventTimes = normalizeEventTimes;
10857 var reportEvents = t.reportEvents;
10861 var stickySource = { events: [] };
10862 var sources = [ stickySource ];
10863 var rangeStart, rangeEnd;
10864 var pendingSourceCnt = 0; // outstanding fetch requests, max one per source
10865 var cache = []; // holds events that have already been expanded
10869 (t.options.events ? [ t.options.events ] : []).concat(t.options.eventSources || []),
10870 function(i, sourceInput) {
10871 var source = buildEventSource(sourceInput);
10873 sources.push(source);
10881 -----------------------------------------------------------------------------*/
10884 // start and end are assumed to be unzoned
10885 function isFetchNeeded(start, end) {
10886 return !rangeStart || // nothing has been fetched yet?
10887 start < rangeStart || end > rangeEnd; // is part of the new range outside of the old range?
10891 function fetchEvents(start, end) {
10892 rangeStart = start;
10894 fetchEventSources(sources, 'reset');
10898 // expects an array of event source objects (the originals, not copies)
10899 // `specialFetchType` is an optimization parameter that affects purging of the event cache.
10900 function fetchEventSources(specificSources, specialFetchType) {
10903 if (specialFetchType === 'reset') {
10906 else if (specialFetchType !== 'add') {
10907 cache = excludeEventsBySources(cache, specificSources);
10910 for (i = 0; i < specificSources.length; i++) {
10911 source = specificSources[i];
10913 // already-pending sources have already been accounted for in pendingSourceCnt
10914 if (source._status !== 'pending') {
10915 pendingSourceCnt++;
10918 source._fetchId = (source._fetchId || 0) + 1;
10919 source._status = 'pending';
10922 for (i = 0; i < specificSources.length; i++) {
10923 source = specificSources[i];
10925 tryFetchEventSource(source, source._fetchId);
10930 // fetches an event source and processes its result ONLY if it is still the current fetch.
10931 // caller is responsible for incrementing pendingSourceCnt first.
10932 function tryFetchEventSource(source, fetchId) {
10933 _fetchEventSource(source, function(eventInputs) {
10934 var isArraySource = $.isArray(source.events);
10939 // is this the source's most recent fetch?
10940 // if not, rely on an upcoming fetch of this source to decrement pendingSourceCnt
10941 fetchId === source._fetchId &&
10942 // event source no longer valid?
10943 source._status !== 'rejected'
10945 source._status = 'resolved';
10948 for (i = 0; i < eventInputs.length; i++) {
10949 eventInput = eventInputs[i];
10951 if (isArraySource) { // array sources have already been convert to Event Objects
10952 abstractEvent = eventInput;
10955 abstractEvent = buildEventFromInput(eventInput, source);
10958 if (abstractEvent) { // not false (an invalid event)
10961 expandEvent(abstractEvent) // add individual expanded events to the cache
10967 decrementPendingSourceCnt();
10973 function rejectEventSource(source) {
10974 var wasPending = source._status === 'pending';
10976 source._status = 'rejected';
10979 decrementPendingSourceCnt();
10984 function decrementPendingSourceCnt() {
10985 pendingSourceCnt--;
10986 if (!pendingSourceCnt) {
10987 reportEvents(cache);
10992 function _fetchEventSource(source, callback) {
10994 var fetchers = FC.sourceFetchers;
10997 for (i=0; i<fetchers.length; i++) {
10998 res = fetchers[i].call(
10999 t, // this, the Calendar object
11001 rangeStart.clone(),
11003 t.options.timezone,
11007 if (res === true) {
11008 // the fetcher is in charge. made its own async request
11011 else if (typeof res == 'object') {
11012 // the fetcher returned a new source. process it
11013 _fetchEventSource(res, callback);
11018 var events = source.events;
11020 if ($.isFunction(events)) {
11023 t, // this, the Calendar object
11024 rangeStart.clone(),
11026 t.options.timezone,
11033 else if ($.isArray(events)) {
11040 var url = source.url;
11042 var success = source.success;
11043 var error = source.error;
11044 var complete = source.complete;
11046 // retrieve any outbound GET/POST $.ajax data from the options
11048 if ($.isFunction(source.data)) {
11049 // supplied as a function that returns a key/value object
11050 customData = source.data();
11053 // supplied as a straight key/value object
11054 customData = source.data;
11057 // use a copy of the custom data so we can modify the parameters
11058 // and not affect the passed-in object.
11059 var data = $.extend({}, customData || {});
11061 var startParam = firstDefined(source.startParam, t.options.startParam);
11062 var endParam = firstDefined(source.endParam, t.options.endParam);
11063 var timezoneParam = firstDefined(source.timezoneParam, t.options.timezoneParam);
11066 data[startParam] = rangeStart.format();
11069 data[endParam] = rangeEnd.format();
11071 if (t.options.timezone && t.options.timezone != 'local') {
11072 data[timezoneParam] = t.options.timezone;
11076 $.ajax($.extend({}, ajaxDefaults, source, {
11078 success: function(events) {
11079 events = events || [];
11080 var res = applyAll(success, this, arguments);
11081 if ($.isArray(res)) {
11086 error: function() {
11087 applyAll(error, this, arguments);
11090 complete: function() {
11091 applyAll(complete, this, arguments);
11104 -----------------------------------------------------------------------------*/
11107 function addEventSource(sourceInput) {
11108 var source = buildEventSource(sourceInput);
11110 sources.push(source);
11111 fetchEventSources([ source ], 'add'); // will eventually call reportEvents
11116 function buildEventSource(sourceInput) { // will return undefined if invalid source
11117 var normalizers = FC.sourceNormalizers;
11121 if ($.isFunction(sourceInput) || $.isArray(sourceInput)) {
11122 source = { events: sourceInput };
11124 else if (typeof sourceInput === 'string') {
11125 source = { url: sourceInput };
11127 else if (typeof sourceInput === 'object') {
11128 source = $.extend({}, sourceInput); // shallow copy
11133 // TODO: repeat code, same code for event classNames
11134 if (source.className) {
11135 if (typeof source.className === 'string') {
11136 source.className = source.className.split(/\s+/);
11138 // otherwise, assumed to be an array
11141 source.className = [];
11144 // for array sources, we convert to standard Event Objects up front
11145 if ($.isArray(source.events)) {
11146 source.origArray = source.events; // for removeEventSource
11147 source.events = $.map(source.events, function(eventInput) {
11148 return buildEventFromInput(eventInput, source);
11152 for (i=0; i<normalizers.length; i++) {
11153 normalizers[i].call(t, source);
11161 function removeEventSource(matchInput) {
11162 removeSpecificEventSources(
11163 getEventSourcesByMatch(matchInput)
11168 // if called with no arguments, removes all.
11169 function removeEventSources(matchInputs) {
11170 if (matchInputs == null) {
11171 removeSpecificEventSources(sources, true); // isAll=true
11174 removeSpecificEventSources(
11175 getEventSourcesByMatchArray(matchInputs)
11181 function removeSpecificEventSources(targetSources, isAll) {
11184 // cancel pending requests
11185 for (i = 0; i < targetSources.length; i++) {
11186 rejectEventSource(targetSources[i]);
11189 if (isAll) { // an optimization
11194 // remove from persisted source list
11195 sources = $.grep(sources, function(source) {
11196 for (i = 0; i < targetSources.length; i++) {
11197 if (source === targetSources[i]) {
11198 return false; // exclude
11201 return true; // include
11204 cache = excludeEventsBySources(cache, targetSources);
11207 reportEvents(cache);
11211 function getEventSources() {
11212 return sources.slice(1); // returns a shallow copy of sources with stickySource removed
11216 function getEventSourceById(id) {
11217 return $.grep(sources, function(source) {
11218 return source.id && source.id === id;
11223 // like getEventSourcesByMatch, but accepts multple match criteria (like multiple IDs)
11224 function getEventSourcesByMatchArray(matchInputs) {
11226 // coerce into an array
11227 if (!matchInputs) {
11230 else if (!$.isArray(matchInputs)) {
11231 matchInputs = [ matchInputs ];
11234 var matchingSources = [];
11237 // resolve raw inputs to real event source objects
11238 for (i = 0; i < matchInputs.length; i++) {
11239 matchingSources.push.apply( // append
11241 getEventSourcesByMatch(matchInputs[i])
11245 return matchingSources;
11249 // matchInput can either by a real event source object, an ID, or the function/URL for the source.
11250 // returns an array of matching source objects.
11251 function getEventSourcesByMatch(matchInput) {
11254 // given an proper event source object
11255 for (i = 0; i < sources.length; i++) {
11256 source = sources[i];
11257 if (source === matchInput) {
11263 source = getEventSourceById(matchInput);
11268 return $.grep(sources, function(source) {
11269 return isSourcesEquivalent(matchInput, source);
11274 function isSourcesEquivalent(source1, source2) {
11275 return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2);
11279 function getSourcePrimitive(source) {
11281 (typeof source === 'object') ? // a normalized event source?
11282 (source.origArray || source.googleCalendarId || source.url || source.events) : // get the primitive
11285 source; // the given argument *is* the primitive
11290 // returns a filtered array without events that are part of any of the given sources
11291 function excludeEventsBySources(specificEvents, specificSources) {
11292 return $.grep(specificEvents, function(event) {
11293 for (var i = 0; i < specificSources.length; i++) {
11294 if (event.source === specificSources[i]) {
11295 return false; // exclude
11298 return true; // keep
11305 -----------------------------------------------------------------------------*/
11308 // Only ever called from the externally-facing API
11309 function updateEvent(event) {
11311 // massage start/end values, even if date string values
11312 event.start = t.moment(event.start);
11314 event.end = t.moment(event.end);
11320 mutateEvent(event, getMiscEventProps(event)); // will handle start/end/allDay normalization
11321 reportEvents(cache); // reports event modifications (so we can redraw)
11325 // Returns a hash of misc event properties that should be copied over to related events.
11326 function getMiscEventProps(event) {
11329 $.each(event, function(name, val) {
11330 if (isMiscEventPropName(name)) {
11331 if (val !== undefined && isAtomic(val)) { // a defined non-object
11340 // non-date-related, non-id-related, non-secret
11341 function isMiscEventPropName(name) {
11342 return !/^_|^(id|allDay|start|end)$/.test(name);
11346 // returns the expanded events that were created
11347 function renderEvent(eventInput, stick) {
11348 var abstractEvent = buildEventFromInput(eventInput);
11352 if (abstractEvent) { // not false (a valid input)
11353 events = expandEvent(abstractEvent);
11355 for (i = 0; i < events.length; i++) {
11358 if (!event.source) {
11360 stickySource.events.push(event);
11361 event.source = stickySource;
11367 reportEvents(cache);
11376 function removeEvents(filter) {
11380 if (filter == null) { // null or undefined. remove all events
11381 filter = function() { return true; }; // will always match
11383 else if (!$.isFunction(filter)) { // an event ID
11384 eventID = filter + '';
11385 filter = function(event) {
11386 return event._id == eventID;
11390 // Purge event(s) from our local cache
11391 cache = $.grep(cache, filter, true); // inverse=true
11393 // Remove events from array sources.
11394 // This works because they have been converted to official Event Objects up front.
11395 // (and as a result, event._id has been calculated).
11396 for (i=0; i<sources.length; i++) {
11397 if ($.isArray(sources[i].events)) {
11398 sources[i].events = $.grep(sources[i].events, filter, true);
11402 reportEvents(cache);
11406 function clientEvents(filter) {
11407 if ($.isFunction(filter)) {
11408 return $.grep(cache, filter);
11410 else if (filter != null) { // not null, not undefined. an event ID
11412 return $.grep(cache, function(e) {
11413 return e._id == filter;
11416 return cache; // else, return all
11420 // Makes sure all array event sources have their internal event objects
11421 // converted over to the Calendar's current timezone.
11422 t.rezoneArrayEventSources = function() {
11427 for (i = 0; i < sources.length; i++) {
11428 events = sources[i].events;
11429 if ($.isArray(events)) {
11431 for (j = 0; j < events.length; j++) {
11432 rezoneEventDates(events[j]);
11438 function rezoneEventDates(event) {
11439 event.start = t.moment(event.start);
11441 event.end = t.moment(event.end);
11443 backupEventDates(event);
11447 /* Event Normalization
11448 -----------------------------------------------------------------------------*/
11451 // Given a raw object with key/value properties, returns an "abstract" Event object.
11452 // An "abstract" event is an event that, if recurring, will not have been expanded yet.
11453 // Will return `false` when input is invalid.
11454 // `source` is optional
11455 function buildEventFromInput(input, source) {
11460 if (t.options.eventDataTransform) {
11461 input = t.options.eventDataTransform(input);
11463 if (source && source.eventDataTransform) {
11464 input = source.eventDataTransform(input);
11467 // Copy all properties over to the resulting object.
11468 // The special-case properties will be copied over afterwards.
11469 $.extend(out, input);
11472 out.source = source;
11475 out._id = input._id || (input.id === undefined ? '_fc' + eventGUID++ : input.id + '');
11477 if (input.className) {
11478 if (typeof input.className == 'string') {
11479 out.className = input.className.split(/\s+/);
11481 else { // assumed to be an array
11482 out.className = input.className;
11486 out.className = [];
11489 start = input.start || input.date; // "date" is an alias for "start"
11492 // parse as a time (Duration) if applicable
11493 if (isTimeString(start)) {
11494 start = moment.duration(start);
11496 if (isTimeString(end)) {
11497 end = moment.duration(end);
11500 if (input.dow || moment.isDuration(start) || moment.isDuration(end)) {
11502 // the event is "abstract" (recurring) so don't calculate exact start/end dates just yet
11503 out.start = start ? moment.duration(start) : null; // will be a Duration or null
11504 out.end = end ? moment.duration(end) : null; // will be a Duration or null
11505 out._recurring = true; // our internal marker
11510 start = t.moment(start);
11511 if (!start.isValid()) {
11517 end = t.moment(end);
11518 if (!end.isValid()) {
11519 end = null; // let defaults take over
11523 allDay = input.allDay;
11524 if (allDay === undefined) { // still undefined? fallback to default
11525 allDay = firstDefined(
11526 source ? source.allDayDefault : undefined,
11527 t.options.allDayDefault
11529 // still undefined? normalizeEventDates will calculate it
11532 assignDatesToEvent(start, end, allDay, out);
11535 t.normalizeEvent(out); // hook for external use. a prototype method
11539 t.buildEventFromInput = buildEventFromInput;
11542 // Normalizes and assigns the given dates to the given partially-formed event object.
11543 // NOTE: mutates the given start/end moments. does not make a copy.
11544 function assignDatesToEvent(start, end, allDay, event) {
11545 event.start = start;
11547 event.allDay = allDay;
11548 normalizeEventDates(event);
11549 backupEventDates(event);
11553 // Ensures proper values for allDay/start/end. Accepts an Event object, or a plain object with event-ish properties.
11554 // NOTE: Will modify the given object.
11555 function normalizeEventDates(eventProps) {
11557 normalizeEventTimes(eventProps);
11559 if (eventProps.end && !eventProps.end.isAfter(eventProps.start)) {
11560 eventProps.end = null;
11563 if (!eventProps.end) {
11564 if (t.options.forceEventDuration) {
11565 eventProps.end = t.getDefaultEventEnd(eventProps.allDay, eventProps.start);
11568 eventProps.end = null;
11574 // Ensures the allDay property exists and the timeliness of the start/end dates are consistent
11575 function normalizeEventTimes(eventProps) {
11576 if (eventProps.allDay == null) {
11577 eventProps.allDay = !(eventProps.start.hasTime() || (eventProps.end && eventProps.end.hasTime()));
11580 if (eventProps.allDay) {
11581 eventProps.start.stripTime();
11582 if (eventProps.end) {
11583 // TODO: consider nextDayThreshold here? If so, will require a lot of testing and adjustment
11584 eventProps.end.stripTime();
11588 if (!eventProps.start.hasTime()) {
11589 eventProps.start = t.applyTimezone(eventProps.start.time(0)); // will assign a 00:00 time
11591 if (eventProps.end && !eventProps.end.hasTime()) {
11592 eventProps.end = t.applyTimezone(eventProps.end.time(0)); // will assign a 00:00 time
11598 // If the given event is a recurring event, break it down into an array of individual instances.
11599 // If not a recurring event, return an array with the single original event.
11600 // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array.
11601 // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours).
11602 function expandEvent(abstractEvent, _rangeStart, _rangeEnd) {
11608 var startTime, endTime;
11612 _rangeStart = _rangeStart || rangeStart;
11613 _rangeEnd = _rangeEnd || rangeEnd;
11615 if (abstractEvent) {
11616 if (abstractEvent._recurring) {
11618 // make a boolean hash as to whether the event occurs on each day-of-week
11619 if ((dow = abstractEvent.dow)) {
11621 for (i = 0; i < dow.length; i++) {
11622 dowHash[dow[i]] = true;
11626 // iterate through every day in the current range
11627 date = _rangeStart.clone().stripTime(); // holds the date of the current day
11628 while (date.isBefore(_rangeEnd)) {
11630 if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week
11632 startTime = abstractEvent.start; // the stored start and end properties are times (Durations)
11633 endTime = abstractEvent.end; // "
11634 start = date.clone();
11638 start = start.time(startTime);
11641 end = date.clone().time(endTime);
11644 event = $.extend({}, abstractEvent); // make a copy of the original
11645 assignDatesToEvent(
11647 !startTime && !endTime, // allDay?
11650 events.push(event);
11653 date.add(1, 'days');
11657 events.push(abstractEvent); // return the original event. will be a one-item array
11663 t.expandEvent = expandEvent;
11667 /* Event Modification Math
11668 -----------------------------------------------------------------------------------------*/
11671 // Modifies an event and all related events by applying the given properties.
11672 // Special date-diffing logic is used for manipulation of dates.
11673 // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end.
11674 // All date comparisons are done against the event's pristine _start and _end dates.
11675 // Returns an object with delta information and a function to undo all operations.
11676 // For making computations in a granularity greater than day/time, specify largeUnit.
11677 // NOTE: The given `newProps` might be mutated for normalization purposes.
11678 function mutateEvent(event, newProps, largeUnit) {
11679 var miscProps = {};
11687 // diffs the dates in the appropriate way, returning a duration
11688 function diffDates(date1, date0) { // date1 - date0
11690 return diffByUnit(date1, date0, largeUnit);
11692 else if (newProps.allDay) {
11693 return diffDay(date1, date0);
11696 return diffDayTime(date1, date0);
11700 newProps = newProps || {};
11702 // normalize new date-related properties
11703 if (!newProps.start) {
11704 newProps.start = event.start.clone();
11706 if (newProps.end === undefined) {
11707 newProps.end = event.end ? event.end.clone() : null;
11709 if (newProps.allDay == null) { // is null or undefined?
11710 newProps.allDay = event.allDay;
11712 normalizeEventDates(newProps);
11714 // create normalized versions of the original props to compare against
11715 // need a real end value, for diffing
11717 start: event._start.clone(),
11718 end: event._end ? event._end.clone() : t.getDefaultEventEnd(event._allDay, event._start),
11719 allDay: newProps.allDay // normalize the dates in the same regard as the new properties
11721 normalizeEventDates(oldProps);
11723 // need to clear the end date if explicitly changed to null
11724 clearEnd = event._end !== null && newProps.end === null;
11726 // compute the delta for moving the start date
11727 startDelta = diffDates(newProps.start, oldProps.start);
11729 // compute the delta for moving the end date
11730 if (newProps.end) {
11731 endDelta = diffDates(newProps.end, oldProps.end);
11732 durationDelta = endDelta.subtract(startDelta);
11735 durationDelta = null;
11738 // gather all non-date-related properties
11739 $.each(newProps, function(name, val) {
11740 if (isMiscEventPropName(name)) {
11741 if (val !== undefined) {
11742 miscProps[name] = val;
11747 // apply the operations to the event and all related events
11748 undoFunc = mutateEvents(
11749 clientEvents(event._id), // get events with this ID
11758 dateDelta: startDelta,
11759 durationDelta: durationDelta,
11765 // Modifies an array of events in the following ways (operations are in order):
11766 // - clear the event's `end`
11767 // - convert the event to allDay
11768 // - add `dateDelta` to the start and end
11769 // - add `durationDelta` to the event's duration
11770 // - assign `miscProps` to the event
11772 // Returns a function that can be called to undo all the operations.
11774 // TODO: don't use so many closures. possible memory issues when lots of events with same ID.
11776 function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) {
11777 var isAmbigTimezone = t.getIsAmbigTimezone();
11778 var undoFunctions = [];
11780 // normalize zero-length deltas to be null
11781 if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; }
11782 if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; }
11784 $.each(events, function(i, event) {
11788 // build an object holding all the old values, both date-related and misc.
11789 // for the undo function.
11791 start: event.start.clone(),
11792 end: event.end ? event.end.clone() : null,
11793 allDay: event.allDay
11795 $.each(miscProps, function(name) {
11796 oldProps[name] = event[name];
11799 // new date-related properties. work off the original date snapshot.
11800 // ok to use references because they will be thrown away when backupEventDates is called.
11802 start: event._start,
11804 allDay: allDay // normalize the dates in the same regard as the new properties
11806 normalizeEventDates(newProps); // massages start/end/allDay
11808 // strip or ensure the end date
11810 newProps.end = null;
11812 else if (durationDelta && !newProps.end) { // the duration translation requires an end date
11813 newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start);
11817 newProps.start.add(dateDelta);
11818 if (newProps.end) {
11819 newProps.end.add(dateDelta);
11823 if (durationDelta) {
11824 newProps.end.add(durationDelta); // end already ensured above
11827 // if the dates have changed, and we know it is impossible to recompute the
11828 // timezone offsets, strip the zone.
11831 !newProps.allDay &&
11832 (dateDelta || durationDelta)
11834 newProps.start.stripZone();
11835 if (newProps.end) {
11836 newProps.end.stripZone();
11840 $.extend(event, miscProps, newProps); // copy over misc props, then date-related props
11841 backupEventDates(event); // regenerate internal _start/_end/_allDay
11843 undoFunctions.push(function() {
11844 $.extend(event, oldProps);
11845 backupEventDates(event); // regenerate internal _start/_end/_allDay
11849 return function() {
11850 for (var i = 0; i < undoFunctions.length; i++) {
11851 undoFunctions[i]();
11857 t.getEventCache = function() {
11864 // hook for external libs to manipulate event properties upon creation.
11865 // should manipulate the event in-place.
11866 Calendar.prototype.normalizeEvent = function(event) {
11870 // Does the given span (start, end, and other location information)
11871 // fully contain the other?
11872 Calendar.prototype.spanContainsSpan = function(outerSpan, innerSpan) {
11873 var eventStart = outerSpan.start.clone().stripZone();
11874 var eventEnd = this.getEventEnd(outerSpan).stripZone();
11876 return innerSpan.start >= eventStart && innerSpan.end <= eventEnd;
11880 // Returns a list of events that the given event should be compared against when being considered for a move to
11881 // the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar.
11882 Calendar.prototype.getPeerEvents = function(span, event) {
11883 var cache = this.getEventCache();
11884 var peerEvents = [];
11887 for (i = 0; i < cache.length; i++) {
11888 otherEvent = cache[i];
11891 event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events
11893 peerEvents.push(otherEvent);
11901 // updates the "backup" properties, which are preserved in order to compute diffs later on.
11902 function backupEventDates(event) {
11903 event._allDay = event.allDay;
11904 event._start = event.start.clone();
11905 event._end = event.end ? event.end.clone() : null;
11909 /* Overlapping / Constraining
11910 -----------------------------------------------------------------------------------------*/
11913 // Determines if the given event can be relocated to the given span (unzoned start/end with other misc data)
11914 Calendar.prototype.isEventSpanAllowed = function(span, event) {
11915 var source = event.source || {};
11917 var constraint = firstDefined(
11920 this.options.eventConstraint
11923 var overlap = firstDefined(
11926 this.options.eventOverlap
11929 return this.isSpanAllowed(span, constraint, overlap, event) &&
11930 (!this.options.eventAllow || this.options.eventAllow(span, event) !== false);
11934 // Determines if an external event can be relocated to the given span (unzoned start/end with other misc data)
11935 Calendar.prototype.isExternalSpanAllowed = function(eventSpan, eventLocation, eventProps) {
11939 // note: very similar logic is in View's reportExternalDrop
11941 eventInput = $.extend({}, eventProps, eventLocation);
11942 event = this.expandEvent(
11943 this.buildEventFromInput(eventInput)
11948 return this.isEventSpanAllowed(eventSpan, event);
11950 else { // treat it as a selection
11952 return this.isSelectionSpanAllowed(eventSpan);
11957 // Determines the given span (unzoned start/end with other misc data) can be selected.
11958 Calendar.prototype.isSelectionSpanAllowed = function(span) {
11959 return this.isSpanAllowed(span, this.options.selectConstraint, this.options.selectOverlap) &&
11960 (!this.options.selectAllow || this.options.selectAllow(span) !== false);
11964 // Returns true if the given span (caused by an event drop/resize or a selection) is allowed to exist
11965 // according to the constraint/overlap settings.
11966 // `event` is not required if checking a selection.
11967 Calendar.prototype.isSpanAllowed = function(span, constraint, overlap, event) {
11968 var constraintEvents;
11969 var anyContainment;
11974 // the range must be fully contained by at least one of produced constraint events
11975 if (constraint != null) {
11977 // not treated as an event! intermediate data structure
11978 // TODO: use ranges in the future
11979 constraintEvents = this.constraintToEvents(constraint);
11980 if (constraintEvents) { // not invalid
11982 anyContainment = false;
11983 for (i = 0; i < constraintEvents.length; i++) {
11984 if (this.spanContainsSpan(constraintEvents[i], span)) {
11985 anyContainment = true;
11990 if (!anyContainment) {
11996 peerEvents = this.getPeerEvents(span, event);
11998 for (i = 0; i < peerEvents.length; i++) {
11999 peerEvent = peerEvents[i];
12001 // there needs to be an actual intersection before disallowing anything
12002 if (this.eventIntersectsRange(peerEvent, span)) {
12004 // evaluate overlap for the given range and short-circuit if necessary
12005 if (overlap === false) {
12008 // if the event's overlap is a test function, pass the peer event in question as the first param
12009 else if (typeof overlap === 'function' && !overlap(peerEvent, event)) {
12013 // if we are computing if the given range is allowable for an event, consider the other event's
12014 // EventObject-specific or Source-specific `overlap` property
12016 peerOverlap = firstDefined(
12018 (peerEvent.source || {}).overlap
12019 // we already considered the global `eventOverlap`
12021 if (peerOverlap === false) {
12024 // if the peer event's overlap is a test function, pass the subject event as the first param
12025 if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) {
12036 // Given an event input from the API, produces an array of event objects. Possible event inputs:
12038 // An event ID (number or string)
12039 // An object with specific start/end dates or a recurring event (like what businessHours accepts)
12040 Calendar.prototype.constraintToEvents = function(constraintInput) {
12042 if (constraintInput === 'businessHours') {
12043 return this.getCurrentBusinessHourEvents();
12046 if (typeof constraintInput === 'object') {
12047 if (constraintInput.start != null) { // needs to be event-like input
12048 return this.expandEvent(this.buildEventFromInput(constraintInput));
12051 return null; // invalid
12055 return this.clientEvents(constraintInput); // probably an ID
12059 // Does the event's date range intersect with the given range?
12060 // start/end already assumed to have stripped zones :(
12061 Calendar.prototype.eventIntersectsRange = function(event, range) {
12062 var eventStart = event.start.clone().stripZone();
12063 var eventEnd = this.getEventEnd(event).stripZone();
12065 return range.start < eventEnd && range.end > eventStart;
12070 -----------------------------------------------------------------------------------------*/
12072 var BUSINESS_HOUR_EVENT_DEFAULTS = {
12073 id: '_fcBusinessHours', // will relate events from different calls to expandEvent
12076 dow: [ 1, 2, 3, 4, 5 ], // monday - friday
12077 rendering: 'inverse-background'
12078 // classNames are defined in businessHoursSegClasses
12081 // Return events objects for business hours within the current view.
12082 // Abuse of our event system :(
12083 Calendar.prototype.getCurrentBusinessHourEvents = function(wholeDay) {
12084 return this.computeBusinessHourEvents(wholeDay, this.options.businessHours);
12087 // Given a raw input value from options, return events objects for business hours within the current view.
12088 Calendar.prototype.computeBusinessHourEvents = function(wholeDay, input) {
12089 if (input === true) {
12090 return this.expandBusinessHourEvents(wholeDay, [ {} ]);
12092 else if ($.isPlainObject(input)) {
12093 return this.expandBusinessHourEvents(wholeDay, [ input ]);
12095 else if ($.isArray(input)) {
12096 return this.expandBusinessHourEvents(wholeDay, input, true);
12103 // inputs expected to be an array of objects.
12104 // if ignoreNoDow is true, will ignore entries that don't specify a day-of-week (dow) key.
12105 Calendar.prototype.expandBusinessHourEvents = function(wholeDay, inputs, ignoreNoDow) {
12106 var view = this.getView();
12110 for (i = 0; i < inputs.length; i++) {
12113 if (ignoreNoDow && !input.dow) {
12117 // give defaults. will make a copy
12118 input = $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, input);
12120 // if a whole-day series is requested, clear the start/end times
12122 input.start = null;
12126 events.push.apply(events, // append
12128 this.buildEventFromInput(input),
12140 /* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells.
12141 ----------------------------------------------------------------------------------------------------------------------*/
12142 // It is a manager for a DayGrid subcomponent, which does most of the heavy lifting.
12143 // It is responsible for managing width/height.
12145 var BasicView = FC.BasicView = View.extend({
12149 dayGridClass: DayGrid, // class the dayGrid will be instantiated from (overridable by subclasses)
12150 dayGrid: null, // the main subcomponent that does most of the heavy lifting
12152 dayNumbersVisible: false, // display day numbers on each day cell?
12153 colWeekNumbersVisible: false, // display week numbers along the side?
12154 cellWeekNumbersVisible: false, // display week numbers in day cell?
12156 weekNumberWidth: null, // width of all the week-number cells running down the side
12158 headContainerEl: null, // div that hold's the dayGrid's rendered date header
12159 headRowEl: null, // the fake row element of the day-of-week header
12162 initialize: function() {
12163 this.dayGrid = this.instantiateDayGrid();
12165 this.scroller = new Scroller({
12166 overflowX: 'hidden',
12172 // Generates the DayGrid object this view needs. Draws from this.dayGridClass
12173 instantiateDayGrid: function() {
12174 // generate a subclass on the fly with BasicView-specific behavior
12175 // TODO: cache this subclass
12176 var subclass = this.dayGridClass.extend(basicDayGridMethods);
12178 return new subclass(this);
12182 // Sets the display range and computes all necessary dates
12183 setRange: function(range) {
12184 View.prototype.setRange.call(this, range); // call the super-method
12186 this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange
12187 this.dayGrid.setRange(range);
12191 // Compute the value to feed into setRange. Overrides superclass.
12192 computeRange: function(date) {
12193 var range = View.prototype.computeRange.call(this, date); // get value from the super-method
12195 // year and month views should be aligned with weeks. this is already done for week
12196 if (/year|month/.test(range.intervalUnit)) {
12197 range.start.startOf('week');
12198 range.start = this.skipHiddenDays(range.start);
12200 // make end-of-week if not already
12201 if (range.end.weekday()) {
12202 range.end.add(1, 'week').startOf('week');
12203 range.end = this.skipHiddenDays(range.end, -1, true); // exclusively move backwards
12211 // Renders the view into `this.el`, which should already be assigned
12212 renderDates: function() {
12214 this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible
12215 if (this.opt('weekNumbers')) {
12216 if (this.opt('weekNumbersWithinDays')) {
12217 this.cellWeekNumbersVisible = true;
12218 this.colWeekNumbersVisible = false;
12221 this.cellWeekNumbersVisible = false;
12222 this.colWeekNumbersVisible = true;
12225 this.dayGrid.numbersVisible = this.dayNumbersVisible ||
12226 this.cellWeekNumbersVisible || this.colWeekNumbersVisible;
12228 this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml());
12231 this.scroller.render();
12232 var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container');
12233 var dayGridEl = $('<div class="fc-day-grid" />').appendTo(dayGridContainerEl);
12234 this.el.find('.fc-body > tr > td').append(dayGridContainerEl);
12236 this.dayGrid.setElement(dayGridEl);
12237 this.dayGrid.renderDates(this.hasRigidRows());
12241 // render the day-of-week headers
12242 renderHead: function() {
12243 this.headContainerEl =
12244 this.el.find('.fc-head-container')
12245 .html(this.dayGrid.renderHeadHtml());
12246 this.headRowEl = this.headContainerEl.find('.fc-row');
12250 // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering,
12251 // always completely kill the dayGrid's rendering.
12252 unrenderDates: function() {
12253 this.dayGrid.unrenderDates();
12254 this.dayGrid.removeElement();
12255 this.scroller.destroy();
12259 renderBusinessHours: function() {
12260 this.dayGrid.renderBusinessHours();
12264 unrenderBusinessHours: function() {
12265 this.dayGrid.unrenderBusinessHours();
12269 // Builds the HTML skeleton for the view.
12270 // The day-grid component will render inside of a container defined by this HTML.
12271 renderSkeletonHtml: function() {
12274 '<thead class="fc-head">' +
12276 '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' +
12279 '<tbody class="fc-body">' +
12281 '<td class="' + this.widgetContentClass + '"></td>' +
12288 // Generates an HTML attribute string for setting the width of the week number column, if it is known
12289 weekNumberStyleAttr: function() {
12290 if (this.weekNumberWidth !== null) {
12291 return 'style="width:' + this.weekNumberWidth + 'px"';
12297 // Determines whether each row should have a constant height
12298 hasRigidRows: function() {
12299 var eventLimit = this.opt('eventLimit');
12300 return eventLimit && typeof eventLimit !== 'number';
12305 ------------------------------------------------------------------------------------------------------------------*/
12308 // Refreshes the horizontal dimensions of the view
12309 updateWidth: function() {
12310 if (this.colWeekNumbersVisible) {
12311 // Make sure all week number cells running down the side have the same width.
12312 // Record the width for cells created later.
12313 this.weekNumberWidth = matchCellWidths(
12314 this.el.find('.fc-week-number')
12320 // Adjusts the vertical dimensions of the view to the specified values
12321 setHeight: function(totalHeight, isAuto) {
12322 var eventLimit = this.opt('eventLimit');
12323 var scrollerHeight;
12324 var scrollbarWidths;
12326 // reset all heights to be natural
12327 this.scroller.clear();
12328 uncompensateScroll(this.headRowEl);
12330 this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
12332 // is the event limit a constant level number?
12333 if (eventLimit && typeof eventLimit === 'number') {
12334 this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after
12337 // distribute the height to the rows
12338 // (totalHeight is a "recommended" value if isAuto)
12339 scrollerHeight = this.computeScrollerHeight(totalHeight);
12340 this.setGridHeight(scrollerHeight, isAuto);
12342 // is the event limit dynamically calculated?
12343 if (eventLimit && typeof eventLimit !== 'number') {
12344 this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set
12347 if (!isAuto) { // should we force dimensions of the scroll container?
12349 this.scroller.setHeight(scrollerHeight);
12350 scrollbarWidths = this.scroller.getScrollbarWidths();
12352 if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
12354 compensateScroll(this.headRowEl, scrollbarWidths);
12356 // doing the scrollbar compensation might have created text overflow which created more height. redo
12357 scrollerHeight = this.computeScrollerHeight(totalHeight);
12358 this.scroller.setHeight(scrollerHeight);
12361 // guarantees the same scrollbar widths
12362 this.scroller.lockOverflow(scrollbarWidths);
12367 // given a desired total height of the view, returns what the height of the scroller should be
12368 computeScrollerHeight: function(totalHeight) {
12369 return totalHeight -
12370 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
12374 // Sets the height of just the DayGrid component in this view
12375 setGridHeight: function(height, isAuto) {
12377 undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding
12380 distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows
12386 ------------------------------------------------------------------------------------------------------------------*/
12389 queryScroll: function() {
12390 return this.scroller.getScrollTop();
12394 setScroll: function(top) {
12395 this.scroller.setScrollTop(top);
12400 ------------------------------------------------------------------------------------------------------------------*/
12401 // forward all hit-related method calls to dayGrid
12404 prepareHits: function() {
12405 this.dayGrid.prepareHits();
12409 releaseHits: function() {
12410 this.dayGrid.releaseHits();
12414 queryHit: function(left, top) {
12415 return this.dayGrid.queryHit(left, top);
12419 getHitSpan: function(hit) {
12420 return this.dayGrid.getHitSpan(hit);
12424 getHitEl: function(hit) {
12425 return this.dayGrid.getHitEl(hit);
12430 ------------------------------------------------------------------------------------------------------------------*/
12433 // Renders the given events onto the view and populates the segments array
12434 renderEvents: function(events) {
12435 this.dayGrid.renderEvents(events);
12437 this.updateHeight(); // must compensate for events that overflow the row
12441 // Retrieves all segment objects that are rendered in the view
12442 getEventSegs: function() {
12443 return this.dayGrid.getEventSegs();
12447 // Unrenders all event elements and clears internal segment data
12448 unrenderEvents: function() {
12449 this.dayGrid.unrenderEvents();
12451 // we DON'T need to call updateHeight() because
12452 // a renderEvents() call always happens after this, which will eventually call updateHeight()
12456 /* Dragging (for both events and external elements)
12457 ------------------------------------------------------------------------------------------------------------------*/
12460 // A returned value of `true` signals that a mock "helper" event has been rendered.
12461 renderDrag: function(dropLocation, seg) {
12462 return this.dayGrid.renderDrag(dropLocation, seg);
12466 unrenderDrag: function() {
12467 this.dayGrid.unrenderDrag();
12472 ------------------------------------------------------------------------------------------------------------------*/
12475 // Renders a visual indication of a selection
12476 renderSelection: function(span) {
12477 this.dayGrid.renderSelection(span);
12481 // Unrenders a visual indications of a selection
12482 unrenderSelection: function() {
12483 this.dayGrid.unrenderSelection();
12489 // Methods that will customize the rendering behavior of the BasicView's dayGrid
12490 var basicDayGridMethods = {
12493 // Generates the HTML that will go before the day-of week header cells
12494 renderHeadIntroHtml: function() {
12495 var view = this.view;
12497 if (view.colWeekNumbersVisible) {
12499 '<th class="fc-week-number ' + view.widgetHeaderClass + '" ' + view.weekNumberStyleAttr() + '>' +
12500 '<span>' + // needed for matchCellWidths
12501 htmlEscape(view.opt('weekNumberTitle')) +
12510 // Generates the HTML that will go before content-skeleton cells that display the day/week numbers
12511 renderNumberIntroHtml: function(row) {
12512 var view = this.view;
12513 var weekStart = this.getCellDate(row, 0);
12515 if (view.colWeekNumbersVisible) {
12517 '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '>' +
12518 view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths
12519 { date: weekStart, type: 'week', forceOff: this.colCnt === 1 },
12520 weekStart.format('w') // inner HTML
12529 // Generates the HTML that goes before the day bg cells for each day-row
12530 renderBgIntroHtml: function() {
12531 var view = this.view;
12533 if (view.colWeekNumbersVisible) {
12534 return '<td class="fc-week-number ' + view.widgetContentClass + '" ' +
12535 view.weekNumberStyleAttr() + '></td>';
12542 // Generates the HTML that goes before every other type of row generated by DayGrid.
12543 // Affects helper-skeleton and highlight-skeleton rows.
12544 renderIntroHtml: function() {
12545 var view = this.view;
12547 if (view.colWeekNumbersVisible) {
12548 return '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '></td>';
12558 /* A month view with day cells running in rows (one-per-week) and columns
12559 ----------------------------------------------------------------------------------------------------------------------*/
12561 var MonthView = FC.MonthView = BasicView.extend({
12563 // Produces information about what range to display
12564 computeRange: function(date) {
12565 var range = BasicView.prototype.computeRange.call(this, date); // get value from super-method
12569 if (this.isFixedWeeks()) {
12570 rowCnt = Math.ceil(range.end.diff(range.start, 'weeks', true)); // could be partial weeks due to hiddenDays
12571 range.end.add(6 - rowCnt, 'weeks');
12578 // Overrides the default BasicView behavior to have special multi-week auto-height logic
12579 setGridHeight: function(height, isAuto) {
12581 // if auto, make the height of each row the height that it would be if there were 6 weeks
12583 height *= this.rowCnt / 6;
12586 distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows
12590 isFixedWeeks: function() {
12591 return this.opt('fixedWeekCount');
12602 fcViews.basicDay = {
12604 duration: { days: 1 }
12607 fcViews.basicWeek = {
12609 duration: { weeks: 1 }
12613 'class': MonthView,
12614 duration: { months: 1 }, // important for prev/next
12616 fixedWeekCount: true
12621 /* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically.
12622 ----------------------------------------------------------------------------------------------------------------------*/
12623 // Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on).
12624 // Responsible for managing width/height.
12626 var AgendaView = FC.AgendaView = View.extend({
12630 timeGridClass: TimeGrid, // class used to instantiate the timeGrid. subclasses can override
12631 timeGrid: null, // the main time-grid subcomponent of this view
12633 dayGridClass: DayGrid, // class used to instantiate the dayGrid. subclasses can override
12634 dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null
12636 axisWidth: null, // the width of the time axis running down the side
12638 headContainerEl: null, // div that hold's the timeGrid's rendered date header
12639 noScrollRowEls: null, // set of fake row elements that must compensate when scroller has scrollbars
12641 // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath
12642 bottomRuleEl: null,
12645 initialize: function() {
12646 this.timeGrid = this.instantiateTimeGrid();
12648 if (this.opt('allDaySlot')) { // should we display the "all-day" area?
12649 this.dayGrid = this.instantiateDayGrid(); // the all-day subcomponent of this view
12652 this.scroller = new Scroller({
12653 overflowX: 'hidden',
12659 // Instantiates the TimeGrid object this view needs. Draws from this.timeGridClass
12660 instantiateTimeGrid: function() {
12661 var subclass = this.timeGridClass.extend(agendaTimeGridMethods);
12663 return new subclass(this);
12667 // Instantiates the DayGrid object this view might need. Draws from this.dayGridClass
12668 instantiateDayGrid: function() {
12669 var subclass = this.dayGridClass.extend(agendaDayGridMethods);
12671 return new subclass(this);
12676 ------------------------------------------------------------------------------------------------------------------*/
12679 // Sets the display range and computes all necessary dates
12680 setRange: function(range) {
12681 View.prototype.setRange.call(this, range); // call the super-method
12683 this.timeGrid.setRange(range);
12684 if (this.dayGrid) {
12685 this.dayGrid.setRange(range);
12690 // Renders the view into `this.el`, which has already been assigned
12691 renderDates: function() {
12693 this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml());
12696 this.scroller.render();
12697 var timeGridWrapEl = this.scroller.el.addClass('fc-time-grid-container');
12698 var timeGridEl = $('<div class="fc-time-grid" />').appendTo(timeGridWrapEl);
12699 this.el.find('.fc-body > tr > td').append(timeGridWrapEl);
12701 this.timeGrid.setElement(timeGridEl);
12702 this.timeGrid.renderDates();
12704 // the <hr> that sometimes displays under the time-grid
12705 this.bottomRuleEl = $('<hr class="fc-divider ' + this.widgetHeaderClass + '"/>')
12706 .appendTo(this.timeGrid.el); // inject it into the time-grid
12708 if (this.dayGrid) {
12709 this.dayGrid.setElement(this.el.find('.fc-day-grid'));
12710 this.dayGrid.renderDates();
12712 // have the day-grid extend it's coordinate area over the <hr> dividing the two grids
12713 this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight();
12716 this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller
12720 // render the day-of-week headers
12721 renderHead: function() {
12722 this.headContainerEl =
12723 this.el.find('.fc-head-container')
12724 .html(this.timeGrid.renderHeadHtml());
12728 // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering,
12729 // always completely kill each grid's rendering.
12730 unrenderDates: function() {
12731 this.timeGrid.unrenderDates();
12732 this.timeGrid.removeElement();
12734 if (this.dayGrid) {
12735 this.dayGrid.unrenderDates();
12736 this.dayGrid.removeElement();
12739 this.scroller.destroy();
12743 // Builds the HTML skeleton for the view.
12744 // The day-grid and time-grid components will render inside containers defined by this HTML.
12745 renderSkeletonHtml: function() {
12748 '<thead class="fc-head">' +
12750 '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' +
12753 '<tbody class="fc-body">' +
12755 '<td class="' + this.widgetContentClass + '">' +
12757 '<div class="fc-day-grid"/>' +
12758 '<hr class="fc-divider ' + this.widgetHeaderClass + '"/>' :
12768 // Generates an HTML attribute string for setting the width of the axis, if it is known
12769 axisStyleAttr: function() {
12770 if (this.axisWidth !== null) {
12771 return 'style="width:' + this.axisWidth + 'px"';
12778 ------------------------------------------------------------------------------------------------------------------*/
12781 renderBusinessHours: function() {
12782 this.timeGrid.renderBusinessHours();
12784 if (this.dayGrid) {
12785 this.dayGrid.renderBusinessHours();
12790 unrenderBusinessHours: function() {
12791 this.timeGrid.unrenderBusinessHours();
12793 if (this.dayGrid) {
12794 this.dayGrid.unrenderBusinessHours();
12800 ------------------------------------------------------------------------------------------------------------------*/
12803 getNowIndicatorUnit: function() {
12804 return this.timeGrid.getNowIndicatorUnit();
12808 renderNowIndicator: function(date) {
12809 this.timeGrid.renderNowIndicator(date);
12813 unrenderNowIndicator: function() {
12814 this.timeGrid.unrenderNowIndicator();
12819 ------------------------------------------------------------------------------------------------------------------*/
12822 updateSize: function(isResize) {
12823 this.timeGrid.updateSize(isResize);
12825 View.prototype.updateSize.call(this, isResize); // call the super-method
12829 // Refreshes the horizontal dimensions of the view
12830 updateWidth: function() {
12831 // make all axis cells line up, and record the width so newly created axis cells will have it
12832 this.axisWidth = matchCellWidths(this.el.find('.fc-axis'));
12836 // Adjusts the vertical dimensions of the view to the specified values
12837 setHeight: function(totalHeight, isAuto) {
12839 var scrollerHeight;
12840 var scrollbarWidths;
12842 // reset all dimensions back to the original state
12843 this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary
12844 this.scroller.clear(); // sets height to 'auto' and clears overflow
12845 uncompensateScroll(this.noScrollRowEls);
12847 // limit number of events in the all-day area
12848 if (this.dayGrid) {
12849 this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
12851 eventLimit = this.opt('eventLimit');
12852 if (eventLimit && typeof eventLimit !== 'number') {
12853 eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number
12856 this.dayGrid.limitRows(eventLimit);
12860 if (!isAuto) { // should we force dimensions of the scroll container?
12862 scrollerHeight = this.computeScrollerHeight(totalHeight);
12863 this.scroller.setHeight(scrollerHeight);
12864 scrollbarWidths = this.scroller.getScrollbarWidths();
12866 if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
12868 // make the all-day and header rows lines up
12869 compensateScroll(this.noScrollRowEls, scrollbarWidths);
12871 // the scrollbar compensation might have changed text flow, which might affect height, so recalculate
12872 // and reapply the desired height to the scroller.
12873 scrollerHeight = this.computeScrollerHeight(totalHeight);
12874 this.scroller.setHeight(scrollerHeight);
12877 // guarantees the same scrollbar widths
12878 this.scroller.lockOverflow(scrollbarWidths);
12880 // if there's any space below the slats, show the horizontal rule.
12881 // this won't cause any new overflow, because lockOverflow already called.
12882 if (this.timeGrid.getTotalSlatHeight() < scrollerHeight) {
12883 this.bottomRuleEl.show();
12889 // given a desired total height of the view, returns what the height of the scroller should be
12890 computeScrollerHeight: function(totalHeight) {
12891 return totalHeight -
12892 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
12897 ------------------------------------------------------------------------------------------------------------------*/
12900 // Computes the initial pre-configured scroll state prior to allowing the user to change it
12901 computeInitialScroll: function() {
12902 var scrollTime = moment.duration(this.opt('scrollTime'));
12903 var top = this.timeGrid.computeTimeTop(scrollTime);
12905 // zoom can give weird floating-point values. rather scroll a little bit further
12906 top = Math.ceil(top);
12909 top++; // to overcome top border that slots beyond the first have. looks better
12916 queryScroll: function() {
12917 return this.scroller.getScrollTop();
12921 setScroll: function(top) {
12922 this.scroller.setScrollTop(top);
12927 ------------------------------------------------------------------------------------------------------------------*/
12928 // forward all hit-related method calls to the grids (dayGrid might not be defined)
12931 prepareHits: function() {
12932 this.timeGrid.prepareHits();
12933 if (this.dayGrid) {
12934 this.dayGrid.prepareHits();
12939 releaseHits: function() {
12940 this.timeGrid.releaseHits();
12941 if (this.dayGrid) {
12942 this.dayGrid.releaseHits();
12947 queryHit: function(left, top) {
12948 var hit = this.timeGrid.queryHit(left, top);
12950 if (!hit && this.dayGrid) {
12951 hit = this.dayGrid.queryHit(left, top);
12958 getHitSpan: function(hit) {
12959 // TODO: hit.component is set as a hack to identify where the hit came from
12960 return hit.component.getHitSpan(hit);
12964 getHitEl: function(hit) {
12965 // TODO: hit.component is set as a hack to identify where the hit came from
12966 return hit.component.getHitEl(hit);
12971 ------------------------------------------------------------------------------------------------------------------*/
12974 // Renders events onto the view and populates the View's segment array
12975 renderEvents: function(events) {
12976 var dayEvents = [];
12977 var timedEvents = [];
12982 // separate the events into all-day and timed
12983 for (i = 0; i < events.length; i++) {
12984 if (events[i].allDay) {
12985 dayEvents.push(events[i]);
12988 timedEvents.push(events[i]);
12992 // render the events in the subcomponents
12993 timedSegs = this.timeGrid.renderEvents(timedEvents);
12994 if (this.dayGrid) {
12995 daySegs = this.dayGrid.renderEvents(dayEvents);
12998 // the all-day area is flexible and might have a lot of events, so shift the height
12999 this.updateHeight();
13003 // Retrieves all segment objects that are rendered in the view
13004 getEventSegs: function() {
13005 return this.timeGrid.getEventSegs().concat(
13006 this.dayGrid ? this.dayGrid.getEventSegs() : []
13011 // Unrenders all event elements and clears internal segment data
13012 unrenderEvents: function() {
13014 // unrender the events in the subcomponents
13015 this.timeGrid.unrenderEvents();
13016 if (this.dayGrid) {
13017 this.dayGrid.unrenderEvents();
13020 // we DON'T need to call updateHeight() because
13021 // a renderEvents() call always happens after this, which will eventually call updateHeight()
13025 /* Dragging (for events and external elements)
13026 ------------------------------------------------------------------------------------------------------------------*/
13029 // A returned value of `true` signals that a mock "helper" event has been rendered.
13030 renderDrag: function(dropLocation, seg) {
13031 if (dropLocation.start.hasTime()) {
13032 return this.timeGrid.renderDrag(dropLocation, seg);
13034 else if (this.dayGrid) {
13035 return this.dayGrid.renderDrag(dropLocation, seg);
13040 unrenderDrag: function() {
13041 this.timeGrid.unrenderDrag();
13042 if (this.dayGrid) {
13043 this.dayGrid.unrenderDrag();
13049 ------------------------------------------------------------------------------------------------------------------*/
13052 // Renders a visual indication of a selection
13053 renderSelection: function(span) {
13054 if (span.start.hasTime() || span.end.hasTime()) {
13055 this.timeGrid.renderSelection(span);
13057 else if (this.dayGrid) {
13058 this.dayGrid.renderSelection(span);
13063 // Unrenders a visual indications of a selection
13064 unrenderSelection: function() {
13065 this.timeGrid.unrenderSelection();
13066 if (this.dayGrid) {
13067 this.dayGrid.unrenderSelection();
13074 // Methods that will customize the rendering behavior of the AgendaView's timeGrid
13075 // TODO: move into TimeGrid
13076 var agendaTimeGridMethods = {
13079 // Generates the HTML that will go before the day-of week header cells
13080 renderHeadIntroHtml: function() {
13081 var view = this.view;
13084 if (view.opt('weekNumbers')) {
13085 weekText = this.start.format(view.opt('smallWeekFormat'));
13088 '<th class="fc-axis fc-week-number ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '>' +
13089 view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths
13090 { date: this.start, type: 'week', forceOff: this.colCnt > 1 },
13091 htmlEscape(weekText) // inner HTML
13096 return '<th class="fc-axis ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '></th>';
13101 // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column.
13102 renderBgIntroHtml: function() {
13103 var view = this.view;
13105 return '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '></td>';
13109 // Generates the HTML that goes before all other types of cells.
13110 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid.
13111 renderIntroHtml: function() {
13112 var view = this.view;
13114 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>';
13120 // Methods that will customize the rendering behavior of the AgendaView's dayGrid
13121 var agendaDayGridMethods = {
13124 // Generates the HTML that goes before the all-day cells
13125 renderBgIntroHtml: function() {
13126 var view = this.view;
13129 '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' +
13130 '<span>' + // needed for matchCellWidths
13131 view.getAllDayHtml() +
13137 // Generates the HTML that goes before all other types of cells.
13138 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid.
13139 renderIntroHtml: function() {
13140 var view = this.view;
13142 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>';
13149 var AGENDA_ALL_DAY_EVENT_LIMIT = 5;
13151 // potential nice values for the slot-duration and interval-duration
13152 // from largest to smallest
13153 var AGENDA_STOCK_SUB_DURATIONS = [
13162 'class': AgendaView,
13165 slotDuration: '00:30:00',
13166 minTime: '00:00:00',
13167 maxTime: '24:00:00',
13168 slotEventOverlap: true // a bad name. confused with overlap/constraint system
13172 fcViews.agendaDay = {
13174 duration: { days: 1 }
13177 fcViews.agendaWeek = {
13179 duration: { weeks: 1 }
13184 Responsible for the scroller, and forwarding event-related actions into the "grid"
13186 var ListView = View.extend({
13191 initialize: function() {
13192 this.grid = new ListViewGrid(this);
13193 this.scroller = new Scroller({
13194 overflowX: 'hidden',
13199 setRange: function(range) {
13200 View.prototype.setRange.call(this, range); // super
13202 this.grid.setRange(range); // needs to process range-related options
13205 renderSkeleton: function() {
13208 this.widgetContentClass
13211 this.scroller.render();
13212 this.scroller.el.appendTo(this.el);
13214 this.grid.setElement(this.scroller.scrollEl);
13217 unrenderSkeleton: function() {
13218 this.scroller.destroy(); // will remove the Grid too
13221 setHeight: function(totalHeight, isAuto) {
13222 this.scroller.setHeight(this.computeScrollerHeight(totalHeight));
13225 computeScrollerHeight: function(totalHeight) {
13226 return totalHeight -
13227 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
13230 renderEvents: function(events) {
13231 this.grid.renderEvents(events);
13234 unrenderEvents: function() {
13235 this.grid.unrenderEvents();
13238 isEventResizable: function(event) {
13242 isEventDraggable: function(event) {
13249 Responsible for event rendering and user-interaction.
13250 Its "el" is the inner-content of the above view's scroller.
13252 var ListViewGrid = Grid.extend({
13254 segSelector: '.fc-list-item', // which elements accept event actions
13255 hasDayInteractions: false, // no day selection or day clicking
13258 spanToSegs: function(span) {
13259 var view = this.view;
13260 var dayStart = view.start.clone().time(0); // timed, so segs get times!
13265 while (dayStart < view.end) {
13267 seg = intersectRanges(span, {
13269 end: dayStart.clone().add(1, 'day')
13273 seg.dayIndex = dayIndex;
13277 dayStart.add(1, 'day');
13280 // detect when span won't go fully into the next day,
13281 // and mutate the latest seg to the be the end.
13283 seg && !seg.isEnd && span.end.hasTime() &&
13284 span.end < dayStart.clone().add(this.view.nextDayThreshold)
13286 seg.end = span.end.clone();
13296 computeEventTimeFormat: function() {
13297 return this.view.opt('mediumTimeFormat');
13300 // for events with a url, the whole <tr> should be clickable,
13301 // but it's impossible to wrap with an <a> tag. simulate this.
13302 handleSegClick: function(seg, ev) {
13305 Grid.prototype.handleSegClick.apply(this, arguments); // super. might prevent the default action
13307 // not clicking on or within an <a> with an href
13308 if (!$(ev.target).closest('a[href]').length) {
13309 url = seg.event.url;
13310 if (url && !ev.isDefaultPrevented()) { // jsEvent not cancelled in handler
13311 window.location.href = url; // simulate link click
13316 // returns list of foreground segs that were actually rendered
13317 renderFgSegs: function(segs) {
13318 segs = this.renderFgSegEls(segs); // might filter away hidden events
13320 if (!segs.length) {
13321 this.renderEmptyMessage();
13324 this.renderSegList(segs);
13330 renderEmptyMessage: function() {
13332 '<div class="fc-list-empty-wrap2">' + // TODO: try less wraps
13333 '<div class="fc-list-empty-wrap1">' +
13334 '<div class="fc-list-empty">' +
13335 htmlEscape(this.view.opt('noEventsMessage')) +
13342 // render the event segments in the view
13343 renderSegList: function(allSegs) {
13344 var segsByDay = this.groupSegsByDay(allSegs); // sparse array
13348 var tableEl = $('<table class="fc-list-table"><tbody/></table>');
13349 var tbodyEl = tableEl.find('tbody');
13351 for (dayIndex = 0; dayIndex < segsByDay.length; dayIndex++) {
13352 daySegs = segsByDay[dayIndex];
13353 if (daySegs) { // sparse array, so might be undefined
13355 // append a day header
13356 tbodyEl.append(this.dayHeaderHtml(
13357 this.view.start.clone().add(dayIndex, 'days')
13360 this.sortEventSegs(daySegs);
13362 for (i = 0; i < daySegs.length; i++) {
13363 tbodyEl.append(daySegs[i].el); // append event row
13368 this.el.empty().append(tableEl);
13371 // Returns a sparse array of arrays, segs grouped by their dayIndex
13372 groupSegsByDay: function(segs) {
13373 var segsByDay = []; // sparse array
13376 for (i = 0; i < segs.length; i++) {
13378 (segsByDay[seg.dayIndex] || (segsByDay[seg.dayIndex] = []))
13385 // generates the HTML for the day headers that live amongst the event rows
13386 dayHeaderHtml: function(dayDate) {
13387 var view = this.view;
13388 var mainFormat = view.opt('listDayFormat');
13389 var altFormat = view.opt('listDayAltFormat');
13391 return '<tr class="fc-list-heading" data-date="' + dayDate.format('YYYY-MM-DD') + '">' +
13392 '<td class="' + view.widgetHeaderClass + '" colspan="3">' +
13394 view.buildGotoAnchorHtml(
13396 { 'class': 'fc-list-heading-main' },
13397 htmlEscape(dayDate.format(mainFormat)) // inner HTML
13401 view.buildGotoAnchorHtml(
13403 { 'class': 'fc-list-heading-alt' },
13404 htmlEscape(dayDate.format(altFormat)) // inner HTML
13411 // generates the HTML for a single event row
13412 fgSegHtml: function(seg) {
13413 var view = this.view;
13414 var classes = [ 'fc-list-item' ].concat(this.getSegCustomClasses(seg));
13415 var bgColor = this.getSegBackgroundColor(seg);
13416 var event = seg.event;
13417 var url = event.url;
13420 if (event.allDay) {
13421 timeHtml = view.getAllDayHtml();
13423 else if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day
13424 if (seg.isStart || seg.isEnd) { // outer segment that probably lasts part of the day
13425 timeHtml = htmlEscape(this.getEventTimeText(seg));
13427 else { // inner segment that lasts the whole day
13428 timeHtml = view.getAllDayHtml();
13432 // Display the normal time text for the *event's* times
13433 timeHtml = htmlEscape(this.getEventTimeText(event));
13437 classes.push('fc-has-url');
13440 return '<tr class="' + classes.join(' ') + '">' +
13441 (this.displayEventTime ?
13442 '<td class="fc-list-item-time ' + view.widgetContentClass + '">' +
13446 '<td class="fc-list-item-marker ' + view.widgetContentClass + '">' +
13447 '<span class="fc-event-dot"' +
13449 ' style="background-color:' + bgColor + '"' :
13453 '<td class="fc-list-item-title ' + view.widgetContentClass + '">' +
13454 '<a' + (url ? ' href="' + htmlEscape(url) + '"' : '') + '>' +
13455 htmlEscape(seg.event.title || '') +
13467 buttonTextKey: 'list', // what to lookup in locale files
13469 buttonText: 'list', // text to display for English
13470 listDayFormat: 'LL', // like "January 1, 2016"
13471 noEventsMessage: 'No events to display'
13475 fcViews.listDay = {
13477 duration: { days: 1 },
13479 listDayFormat: 'dddd' // day-of-week is all we need. full date is probably in header
13483 fcViews.listWeek = {
13485 duration: { weeks: 1 },
13487 listDayFormat: 'dddd', // day-of-week is more important
13488 listDayAltFormat: 'LL'
13492 fcViews.listMonth = {
13494 duration: { month: 1 },
13496 listDayAltFormat: 'dddd' // day-of-week is nice-to-have
13500 fcViews.listYear = {
13502 duration: { year: 1 },
13504 listDayAltFormat: 'dddd' // day-of-week is nice-to-have
13510 return FC; // export for Node/CommonJS
\r