diff options
Diffstat (limited to 'library/fullcalendar/fullcalendar.js')
-rw-r--r-- | library/fullcalendar/fullcalendar.js | 2348 |
1 files changed, 1460 insertions, 888 deletions
diff --git a/library/fullcalendar/fullcalendar.js b/library/fullcalendar/fullcalendar.js index 2460eb5e7..33c0c6a38 100644 --- a/library/fullcalendar/fullcalendar.js +++ b/library/fullcalendar/fullcalendar.js @@ -1,5 +1,5 @@ /*! - * FullCalendar v2.8.0 + * FullCalendar v3.0.0 * Docs & License: http://fullcalendar.io/ * (c) 2016 Adam Shaw */ @@ -19,8 +19,8 @@ ;; var FC = $.fullCalendar = { - version: "2.8.0", - internalApiVersion: 4 + version: "3.0.0", + internalApiVersion: 6 }; var fcViews = FC.views = {}; @@ -71,56 +71,6 @@ function mergeOptions(optionObjs) { return mergeProps(optionObjs, complexOptions); } - -// Given options specified for the calendar's constructor, massages any legacy options into a non-legacy form. -// Converts View-Option-Hashes into the View-Specific-Options format. -function massageOverrides(input) { - var overrides = { views: input.views || {} }; // the output. ensure a `views` hash - var subObj; - - // iterate through all option override properties (except `views`) - $.each(input, function(name, val) { - if (name != 'views') { - - // could the value be a legacy View-Option-Hash? - if ( - $.isPlainObject(val) && - !/(time|duration|interval)$/i.test(name) && // exclude duration options. might be given as objects - $.inArray(name, complexOptions) == -1 // complex options aren't allowed to be View-Option-Hashes - ) { - subObj = null; - - // iterate through the properties of this possible View-Option-Hash value - $.each(val, function(subName, subVal) { - - // is the property targeting a view? - if (/^(month|week|day|default|basic(Week|Day)?|agenda(Week|Day)?)$/.test(subName)) { - if (!overrides.views[subName]) { // ensure the view-target entry exists - overrides.views[subName] = {}; - } - overrides.views[subName][name] = subVal; // record the value in the `views` object - } - else { // a non-View-Option-Hash property - if (!subObj) { - subObj = {}; - } - subObj[subName] = subVal; // accumulate these unrelated values for later - } - }); - - if (subObj) { // non-View-Option-Hash properties? transfer them as-is - overrides[name] = subObj; - } - } - else { - overrides[name] = val; // transfer normal options as-is - } - } - }); - - return overrides; -} - ;; // exports @@ -247,7 +197,7 @@ function undistributeHeight(els) { function matchCellWidths(els) { var maxInnerWidth = 0; - els.find('> span').each(function(i, innerEl) { + els.find('> *').each(function(i, innerEl) { var innerWidth = $(innerEl).outerWidth(); if (innerWidth > maxInnerWidth) { maxInnerWidth = innerWidth; @@ -628,7 +578,8 @@ function flexibleCompare(a, b) { ----------------------------------------------------------------------------------------------------------------------*/ -// Computes the intersection of the two ranges. Returns undefined if no intersection. +// Computes the intersection of the two ranges. Will return fresh date clones in a range. +// Returns undefined if no intersection. // Expects all dates to be normalized to the same timezone beforehand. // TODO: move to date section? function intersectRanges(subjectRange, constraintRange) { @@ -908,22 +859,6 @@ function copyOwnProps(src, dest) { } -// Copies over certain methods with the same names as Object.prototype methods. Overcomes an IE<=8 bug: -// https://developer.mozilla.org/en-US/docs/ECMAScript_DontEnum_attribute#JScript_DontEnum_Bug -function copyNativeMethods(src, dest) { - var names = [ 'constructor', 'toString', 'valueOf' ]; - var i, name; - - for (i = 0; i < names.length; i++) { - name = names[i]; - - if (src[name] !== Object.prototype[name]) { - dest[name] = src[name]; - } - } -} - - function hasOwnProp(obj, name) { return hasOwnPropMethod.call(obj, name); } @@ -989,6 +924,21 @@ function cssToStr(cssProps) { } +// Given an object hash of HTML attribute names to values, +// generates a string that can be injected between < > in HTML +function attrsToStr(attrs) { + var parts = []; + + $.each(attrs, function(name, val) { + if (val != null) { + parts.push(name + '="' + htmlEscape(val) + '"'); + } + }); + + return parts.join(' '); +} + + function capitaliseFirstLetter(str) { return str.charAt(0).toUpperCase() + str.slice(1); } @@ -1070,14 +1020,24 @@ function syncThen(promise, thenFunc) { ;; +/* +GENERAL NOTE on moments throughout the *entire rest* of the codebase: +All moments are assumed to be ambiguously-zoned unless otherwise noted, +with the NOTABLE EXCEOPTION of start/end dates that live on *Event Objects*. +Ambiguously-TIMED moments are assumed to be ambiguously-zoned by nature. +*/ + var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/; var ambigTimeOrZoneRegex = /^\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+)?)?)?)?)?$/; var newMomentProto = moment.fn; // where we will attach our new methods var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods -var allowValueOptimization; -var setUTCValues; // function defined below -var setLocalValues; // function defined below + +// tell momentjs to transfer these properties upon clone +var momentProperties = moment.momentProperties; +momentProperties.push('_fullCalendar'); +momentProperties.push('_ambigTime'); +momentProperties.push('_ambigZone'); // Creating @@ -1123,12 +1083,8 @@ function makeMoment(args, parseAsUTC, parseZone) { var ambigMatch; var mom; - if (moment.isMoment(input)) { - mom = moment.apply(null, args); // clone it - transferAmbigs(input, mom); // the ambig flags weren't transfered with the clone - } - else if (isNativeDate(input) || input === undefined) { - mom = moment.apply(null, args); // will be local + if (moment.isMoment(input) || isNativeDate(input) || input === undefined) { + mom = moment.apply(null, args); } else { // "parsing" is required isAmbigTime = false; @@ -1169,12 +1125,7 @@ function makeMoment(args, parseAsUTC, parseZone) { mom._ambigZone = true; } else if (isSingleString) { - if (mom.utcOffset) { - mom.utcOffset(input); // if not a valid zone, will assign UTC - } - else { - mom.zone(input); // for moment-pre-2.9 - } + mom.utcOffset(input); // if not a valid zone, will assign UTC } } } @@ -1185,21 +1136,6 @@ function makeMoment(args, parseAsUTC, parseZone) { } -// A clone method that works with the flags related to our enhanced functionality. -// In the future, use moment.momentProperties -newMomentProto.clone = function() { - var mom = oldMomentProto.clone.apply(this, arguments); - - // these flags weren't transfered with the clone - transferAmbigs(this, mom); - if (this._fullCalendar) { - mom._fullCalendar = true; - } - - return mom; -}; - - // Week Number // ------------------------------------------------------------------------------------------------- @@ -1207,8 +1143,7 @@ newMomentProto.clone = function() { // Returns the week number, considering the locale's custom week number calcuation // `weeks` is an alias for `week` newMomentProto.week = newMomentProto.weeks = function(input) { - var weekCalc = (this._locale || this._lang) // works pre-moment-2.8 - ._fullCalendar_weekCalc; + var weekCalc = this._locale._fullCalendar_weekCalc; if (input == null && typeof weekCalc === 'function') { // custom function only works for getter return weekCalc(this); @@ -1275,19 +1210,21 @@ newMomentProto.time = function(time) { // but preserving its YMD. A moment with a stripped time will display no time // nor timezone offset when .format() is called. newMomentProto.stripTime = function() { - var a; if (!this._ambigTime) { - // get the values before any conversion happens - a = this.toArray(); // array of y/m/d/h/m/s/ms + this.utc(true); // keepLocalTime=true (for keeping *date* value) - // TODO: use keepLocalTime in the future - this.utc(); // set the internal UTC flag (will clear the ambig flags) - setUTCValues(this, a.slice(0, 3)); // set the year/month/date. time will be zero + // set time to zero + this.set({ + hours: 0, + minutes: 0, + seconds: 0, + ms: 0 + }); // Mark the time as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), - // which clears all ambig flags. Same with setUTCValues with moment-timezone. + // which clears all ambig flags. this._ambigTime = true; this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset } @@ -1307,24 +1244,20 @@ newMomentProto.hasTime = function() { // Converts the moment to UTC, stripping out its timezone offset, but preserving its // YMD and time-of-day. A moment with a stripped timezone offset will display no // timezone offset when .format() is called. -// TODO: look into Moment's keepLocalTime functionality newMomentProto.stripZone = function() { - var a, wasAmbigTime; + var wasAmbigTime; if (!this._ambigZone) { - // get the values before any conversion happens - a = this.toArray(); // array of y/m/d/h/m/s/ms wasAmbigTime = this._ambigTime; - this.utc(); // set the internal UTC flag (might clear the ambig flags, depending on Moment internals) - setUTCValues(this, a); // will set the year/month/date/hours/minutes/seconds/ms + this.utc(true); // keepLocalTime=true (for keeping date and time values) // the above call to .utc()/.utcOffset() unfortunately might clear the ambig flags, so restore this._ambigTime = wasAmbigTime || false; // Mark the zone as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), - // which clears the ambig flags. Same with setUTCValues with moment-timezone. + // which clears the ambig flags. this._ambigZone = true; } @@ -1337,32 +1270,26 @@ newMomentProto.hasZone = function() { }; -// this method implicitly marks a zone -newMomentProto.local = function() { - var a = this.toArray(); // year,month,date,hours,minutes,seconds,ms as an array - var wasAmbigZone = this._ambigZone; +// implicitly marks a zone +newMomentProto.local = function(keepLocalTime) { - oldMomentProto.local.apply(this, arguments); + // for when converting from ambiguously-zoned to local, + // keep the time values when converting from UTC -> local + oldMomentProto.local.call(this, this._ambigZone || keepLocalTime); // ensure non-ambiguous // this probably already happened via local() -> utcOffset(), but don't rely on Moment's internals this._ambigTime = false; this._ambigZone = false; - if (wasAmbigZone) { - // If the moment was ambiguously zoned, the date fields were stored as UTC. - // We want to preserve these, but in local time. - // TODO: look into Moment's keepLocalTime functionality - setLocalValues(this, a); - } - return this; // for chaining }; // implicitly marks a zone -newMomentProto.utc = function() { - oldMomentProto.utc.apply(this, arguments); +newMomentProto.utc = function(keepLocalTime) { + + oldMomentProto.utc.call(this, keepLocalTime); // ensure non-ambiguous // this probably already happened via utc() -> utcOffset(), but don't rely on Moment's internals @@ -1373,28 +1300,18 @@ newMomentProto.utc = function() { }; -// methods for arbitrarily manipulating timezone offset. -// should clear time/zone ambiguity when called. -$.each([ - 'zone', // only in moment-pre-2.9. deprecated afterwards - 'utcOffset' -], function(i, name) { - if (oldMomentProto[name]) { // original method exists? - - // this method implicitly marks a zone (will probably get called upon .utc() and .local()) - newMomentProto[name] = function(tzo) { +// implicitly marks a zone (will probably get called upon .utc() and .local()) +newMomentProto.utcOffset = function(tzo) { - if (tzo != null) { // setter - // these assignments needs to happen before the original zone method is called. - // I forget why, something to do with a browser crash. - this._ambigTime = false; - this._ambigZone = false; - } - - return oldMomentProto[name].apply(this, arguments); - }; + if (tzo != null) { // setter + // these assignments needs to happen before the original zone method is called. + // I forget why, something to do with a browser crash. + this._ambigTime = false; + this._ambigZone = false; } -}); + + return oldMomentProto.utcOffset.apply(this, arguments); +}; // Formatting @@ -1423,156 +1340,6 @@ newMomentProto.toISOString = function() { return oldMomentProto.toISOString.apply(this, arguments); }; - -// Querying -// ------------------------------------------------------------------------------------------------- - -// Is the moment within the specified range? `end` is exclusive. -// FYI, this method is not a standard Moment method, so always do our enhanced logic. -newMomentProto.isWithin = function(start, end) { - var a = commonlyAmbiguate([ this, start, end ]); - return a[0] >= a[1] && a[0] < a[2]; -}; - -// When isSame is called with units, timezone ambiguity is normalized before the comparison happens. -// If no units specified, the two moments must be identically the same, with matching ambig flags. -newMomentProto.isSame = function(input, units) { - var a; - - // only do custom logic if this is an enhanced moment - if (!this._fullCalendar) { - return oldMomentProto.isSame.apply(this, arguments); - } - - if (units) { - a = commonlyAmbiguate([ this, input ], true); // normalize timezones but don't erase times - return oldMomentProto.isSame.call(a[0], a[1], units); - } - else { - input = FC.moment.parseZone(input); // normalize input - return oldMomentProto.isSame.call(this, input) && - Boolean(this._ambigTime) === Boolean(input._ambigTime) && - Boolean(this._ambigZone) === Boolean(input._ambigZone); - } -}; - -// Make these query methods work with ambiguous moments -$.each([ - 'isBefore', - 'isAfter' -], function(i, methodName) { - newMomentProto[methodName] = function(input, units) { - var a; - - // only do custom logic if this is an enhanced moment - if (!this._fullCalendar) { - return oldMomentProto[methodName].apply(this, arguments); - } - - a = commonlyAmbiguate([ this, input ]); - return oldMomentProto[methodName].call(a[0], a[1], units); - }; -}); - - -// Misc Internals -// ------------------------------------------------------------------------------------------------- - -// given an array of moment-like inputs, return a parallel array w/ moments similarly ambiguated. -// for example, of one moment has ambig time, but not others, all moments will have their time stripped. -// set `preserveTime` to `true` to keep times, but only normalize zone ambiguity. -// returns the original moments if no modifications are necessary. -function commonlyAmbiguate(inputs, preserveTime) { - var anyAmbigTime = false; - var anyAmbigZone = false; - var len = inputs.length; - var moms = []; - var i, mom; - - // parse inputs into real moments and query their ambig flags - for (i = 0; i < len; i++) { - mom = inputs[i]; - if (!moment.isMoment(mom)) { - mom = FC.moment.parseZone(mom); - } - anyAmbigTime = anyAmbigTime || mom._ambigTime; - anyAmbigZone = anyAmbigZone || mom._ambigZone; - moms.push(mom); - } - - // strip each moment down to lowest common ambiguity - // use clones to avoid modifying the original moments - for (i = 0; i < len; i++) { - mom = moms[i]; - if (!preserveTime && anyAmbigTime && !mom._ambigTime) { - moms[i] = mom.clone().stripTime(); - } - else if (anyAmbigZone && !mom._ambigZone) { - moms[i] = mom.clone().stripZone(); - } - } - - return moms; -} - -// Transfers all the flags related to ambiguous time/zone from the `src` moment to the `dest` moment -// TODO: look into moment.momentProperties for this. -function transferAmbigs(src, dest) { - if (src._ambigTime) { - dest._ambigTime = true; - } - else if (dest._ambigTime) { - dest._ambigTime = false; - } - - if (src._ambigZone) { - dest._ambigZone = true; - } - else if (dest._ambigZone) { - dest._ambigZone = false; - } -} - - -// Sets the year/month/date/etc values of the moment from the given array. -// Inefficient because it calls each individual setter. -function setMomentValues(mom, a) { - mom.year(a[0] || 0) - .month(a[1] || 0) - .date(a[2] || 0) - .hours(a[3] || 0) - .minutes(a[4] || 0) - .seconds(a[5] || 0) - .milliseconds(a[6] || 0); -} - -// Can we set the moment's internal date directly? -allowValueOptimization = '_d' in moment() && 'updateOffset' in moment; - -// Utility function. Accepts a moment and an array of the UTC year/month/date/etc values to set. -// Assumes the given moment is already in UTC mode. -setUTCValues = allowValueOptimization ? function(mom, a) { - // simlate what moment's accessors do - mom._d.setTime(Date.UTC.apply(Date, a)); - moment.updateOffset(mom, false); // keepTime=false -} : setMomentValues; - -// Utility function. Accepts a moment and an array of the local year/month/date/etc values to set. -// Assumes the given moment is already in local mode. -setLocalValues = allowValueOptimization ? function(mom, a) { - // simlate what moment's accessors do - mom._d.setTime(+new Date( // FYI, there is now way to apply an array of args to a constructor - a[0] || 0, - a[1] || 0, - a[2] || 0, - a[3] || 0, - a[4] || 0, - a[5] || 0, - a[6] || 0 - )); - moment.updateOffset(mom, false); // keepTime=false -} : setMomentValues; - ;; // Single Date Formatting @@ -1653,7 +1420,7 @@ function formatRange(date1, date2, formatStr, separator, isRTL) { date1 = FC.moment.parseZone(date1); date2 = FC.moment.parseZone(date2); - localeData = (date1.localeData || date1.lang).call(date1); // works with moment-pre-2.8 + localeData = date1.localeData(); // Expand localized format strings, like "LL" -> "MMMM D YYYY" formatStr = localeData.longDateFormat(formatStr) || formatStr; @@ -1858,7 +1625,6 @@ function extendClass(superClass, members) { // copy each member variable/method onto the the subclass's prototype copyOwnProps(members, subClass.prototype); - copyNativeMethods(members, subClass.prototype); // hack for IE8 // copy over all class variables/methods to the subclass, such as `extend` and `mixin` copyOwnProps(superClass, subClass); @@ -1868,7 +1634,7 @@ function extendClass(superClass, members) { function mixIntoClass(theClass, members) { - copyOwnProps(members, theClass.prototype); // TODO: copyNativeMethods? + copyOwnProps(members, theClass.prototype); } ;; @@ -2263,17 +2029,6 @@ var CoordCache = FC.CoordCache = Class.extend({ }, - // Compute and return what the elements' bounding rectangle is, from the user's perspective. - // Right now, only returns a rectangle if constrained by an overflow:scroll element. - queryBoundingRect: function() { - var scrollParentEl = getScrollParent(this.els.eq(0)); - - if (!scrollParentEl.is(document)) { - return getClientRect(scrollParentEl); - } - }, - - // Populates the left/right internal coordinate arrays buildElHorizontals: function() { var lefts = []; @@ -2313,42 +2068,36 @@ var CoordCache = FC.CoordCache = Class.extend({ // Given a left offset (from document left), returns the index of the el that it horizontally intersects. - // If no intersection is made, or outside of the boundingRect, returns undefined. + // If no intersection is made, returns undefined. getHorizontalIndex: function(leftOffset) { this.ensureBuilt(); - var boundingRect = this.boundingRect; var lefts = this.lefts; var rights = this.rights; var len = lefts.length; var i; - if (!boundingRect || (leftOffset >= boundingRect.left && leftOffset < boundingRect.right)) { - for (i = 0; i < len; i++) { - if (leftOffset >= lefts[i] && leftOffset < rights[i]) { - return i; - } + for (i = 0; i < len; i++) { + if (leftOffset >= lefts[i] && leftOffset < rights[i]) { + return i; } } }, // Given a top offset (from document top), returns the index of the el that it vertically intersects. - // If no intersection is made, or outside of the boundingRect, returns undefined. + // If no intersection is made, returns undefined. getVerticalIndex: function(topOffset) { this.ensureBuilt(); - var boundingRect = this.boundingRect; var tops = this.tops; var bottoms = this.bottoms; var len = tops.length; var i; - if (!boundingRect || (topOffset >= boundingRect.top && topOffset < boundingRect.bottom)) { - for (i = 0; i < len; i++) { - if (topOffset >= tops[i] && topOffset < bottoms[i]) { - return i; - } + for (i = 0; i < len; i++) { + if (topOffset >= tops[i] && topOffset < bottoms[i]) { + return i; } } }, @@ -2424,6 +2173,32 @@ var CoordCache = FC.CoordCache = Class.extend({ getHeight: function(topIndex) { this.ensureBuilt(); return this.bottoms[topIndex] - this.tops[topIndex]; + }, + + + // Bounding Rect + // TODO: decouple this from CoordCache + + // Compute and return what the elements' bounding rectangle is, from the user's perspective. + // Right now, only returns a rectangle if constrained by an overflow:scroll element. + queryBoundingRect: function() { + var scrollParentEl = getScrollParent(this.els.eq(0)); + + if (!scrollParentEl.is(document)) { + return getClientRect(scrollParentEl); + } + }, + + isPointInBounds: function(leftOffset, topOffset) { + return this.isLeftInBounds(leftOffset) && this.isTopInBounds(topOffset); + }, + + isLeftInBounds: function(leftOffset) { + return !this.boundingRect || (leftOffset >= this.boundingRect.left && leftOffset < this.boundingRect.right); + }, + + isTopInBounds: function(topOffset) { + return !this.boundingRect || (topOffset >= this.boundingRect.top && topOffset < this.boundingRect.bottom); } }); @@ -2437,10 +2212,7 @@ var CoordCache = FC.CoordCache = Class.extend({ var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMixin, { options: null, - - // for IE8 bug-fighting behavior subjectEl: null, - subjectHref: null, // coordinates of the initial mousedown originX: null, @@ -2631,7 +2403,6 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMix handleDragStart: function(ev) { this.trigger('dragStart', ev); - this.initHrefHack(); }, @@ -2671,7 +2442,6 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMix handleDragEnd: function(ev) { this.trigger('dragEnd', ev); - this.destroyHrefHack(); }, @@ -2747,33 +2517,6 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMix }, - // <A> HREF Hack - // ----------------------------------------------------------------------------------------------------------------- - - - initHrefHack: function() { - var subjectEl = this.subjectEl; - - // remove a mousedown'd <a>'s href so it is not visited (IE8 bug) - if ((this.subjectHref = subjectEl ? subjectEl.attr('href') : null)) { - subjectEl.removeAttr('href'); - } - }, - - - destroyHrefHack: function() { - var subjectEl = this.subjectEl; - var subjectHref = this.subjectHref; - - // restore a mousedown'd <a>'s href (for IE8 bug) - setTimeout(function() { // must be outside of the click's execution - if (subjectHref) { - subjectEl.attr('href', subjectHref); - } - }, 0); - }, - - // Utils // ----------------------------------------------------------------------------------------------------------------- @@ -3259,11 +3002,11 @@ var MouseFollower = Class.extend(ListenerMixin, { var _this = this; var revertDuration = this.options.revertDuration; - function complete() { - this.isAnimating = false; + function complete() { // might be called by .animate(), which might change `this` context + _this.isAnimating = false; _this.removeElement(); - this.top0 = this.left0 = null; // reset state for future updatePosition calls + _this.top0 = _this.left0 = null; // reset state for future updatePosition calls if (callback) { callback(); @@ -3297,7 +3040,6 @@ var MouseFollower = Class.extend(ListenerMixin, { var el = this.el; if (!el) { - this.sourceEl.width(); // hack to force IE8 to compute correct bounding box el = this.el = this.sourceEl.clone() .addClass(this.options.additionalClass || '') .css({ @@ -3342,7 +3084,6 @@ var MouseFollower = Class.extend(ListenerMixin, { // make sure origin info was computed if (this.top0 === null) { - this.sourceEl.width(); // hack to force IE8 to compute correct bounding box sourceOffset = this.sourceEl.offset(); origin = this.el.offsetParent().offset(); this.top0 = sourceOffset.top - origin.top; @@ -3396,6 +3137,9 @@ var MouseFollower = Class.extend(ListenerMixin, { var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { + // self-config, overridable by subclasses + hasDayInteractions: true, // can user click/select ranges of time? + view: null, // a View object isRTL: null, // shortcut to the view's isRTL option @@ -3563,10 +3307,13 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { // Does other DOM-related initializations. setElement: function(el) { this.el = el; - preventSelection(el); - this.bindDayHandler('touchstart', this.dayTouchStart); - this.bindDayHandler('mousedown', this.dayMousedown); + if (this.hasDayInteractions) { + preventSelection(el); + + this.bindDayHandler('touchstart', this.dayTouchStart); + this.bindDayHandler('mousedown', this.dayMousedown); + } // attach event-element-related handlers. in Grid.events // same garbage collection note as above. @@ -3583,8 +3330,12 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { // jQuery will take care of unregistering them when removeElement gets called. this.el.on(name, function(ev) { if ( - !$(ev.target).is('.fc-event-container *, .fc-more') && // not an an event element, or "more.." link - !$(ev.target).closest('.fc-popover').length // not on a popover (like the "more.." events one) + !$(ev.target).is( + _this.segSelector + ',' + // directly on an event element + _this.segSelector + ' *,' + // within an event element + '.fc-more,' + // a "more.." link + 'a[data-goto]' // a clickable nav link + ) ) { return handler.call(_this, ev); } @@ -3683,6 +3434,7 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { scroll: view.opt('dragScroll'), interactionStart: function() { dayClickHit = dragListener.origHit; // for dayClick, where no dragging happens + selectionSpan = null; }, dragStart: function() { view.unselect(); // since we could be rendering a new selection, we want to clear any old one @@ -3709,10 +3461,12 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { } } }, - hitOut: function() { + hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits dayClickHit = null; selectionSpan = null; _this.unrenderSelection(); + }, + hitDone: function() { // called after a hitOut OR before a dragEnd enableCursor(); }, interactionEnd: function(ev, isCancelled) { @@ -3731,7 +3485,6 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { // the selection will already have been rendered. just report it view.reportSelection(selectionSpan, ev); } - enableCursor(); } } }); @@ -4034,6 +3787,9 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { Grid.mixin({ + // self-config, overridable by subclasses + segSelector: '.fc-event-container > *', // what constitutes an event element? + mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing isDraggingSeg: false, // is a segment being dragged? boolean isResizingSeg: false, // is a segment being resized? boolean @@ -4172,7 +3928,7 @@ Grid.mixin({ // Generates an array of classNames to be used for the default rendering of a background event. - // Called by the fill system. + // Called by fillSegHtml. bgEventSegClasses: function(seg) { var event = seg.event; var source = event.source || {}; @@ -4185,7 +3941,7 @@ Grid.mixin({ // Generates a semicolon-separated CSS string to be used for the default rendering of a background event. - // Called by the fill system. + // Called by fillSegHtml. bgEventSegCss: function(seg) { return { 'background-color': this.getSegSkinCss(seg)['background-color'] @@ -4194,32 +3950,68 @@ Grid.mixin({ // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system. + // Called by fillSegHtml. businessHoursSegClasses: function(seg) { return [ 'fc-nonbusiness', 'fc-bgevent' ]; }, + /* Business Hours + ------------------------------------------------------------------------------------------------------------------*/ + + + // Compute business hour segs for the grid's current date range. + // Caller must ask if whole-day business hours are needed. + buildBusinessHourSegs: function(wholeDay) { + var events = this.view.calendar.getCurrentBusinessHourEvents(wholeDay); + + // HACK. Eventually refactor business hours "events" system. + // If no events are given, but businessHours is activated, this means the entire visible range should be + // marked as *not* business-hours, via inverse-background rendering. + if ( + !events.length && + this.view.calendar.options.businessHours // don't access view option. doesn't update with dynamic options + ) { + events = [ + $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, { + start: this.view.end, // guaranteed out-of-range + end: this.view.end, // " + dow: null + }) + ]; + } + + return this.eventsToSegs(events); + }, + + /* Handlers ------------------------------------------------------------------------------------------------------------------*/ - // Attaches event-element-related handlers to the container element and leverage bubbling + // Attaches event-element-related handlers for *all* rendered event segments of the view. bindSegHandlers: function() { - this.bindSegHandler('touchstart', this.handleSegTouchStart); - this.bindSegHandler('touchend', this.handleSegTouchEnd); - this.bindSegHandler('mouseenter', this.handleSegMouseover); - this.bindSegHandler('mouseleave', this.handleSegMouseout); - this.bindSegHandler('mousedown', this.handleSegMousedown); - this.bindSegHandler('click', this.handleSegClick); + this.bindSegHandlersToEl(this.el); + }, + + + // Attaches event-element-related handlers to an arbitrary container element. leverages bubbling. + bindSegHandlersToEl: function(el) { + this.bindSegHandlerToEl(el, 'touchstart', this.handleSegTouchStart); + this.bindSegHandlerToEl(el, 'touchend', this.handleSegTouchEnd); + this.bindSegHandlerToEl(el, 'mouseenter', this.handleSegMouseover); + this.bindSegHandlerToEl(el, 'mouseleave', this.handleSegMouseout); + this.bindSegHandlerToEl(el, 'mousedown', this.handleSegMousedown); + this.bindSegHandlerToEl(el, 'click', this.handleSegClick); }, // Executes a handler for any a user-interaction on a segment. // Handler gets called with (seg, ev), and with the `this` context of the Grid - bindSegHandler: function(name, handler) { + bindSegHandlerToEl: function(el, name, handler) { var _this = this; - this.el.on(name, '.fc-event-container > *', function(ev) { + el.on(name, this.segSelector, function(ev) { var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents // only call the handlers if there is not a drag/resize in progress @@ -4231,7 +4023,10 @@ Grid.mixin({ handleSegClick: function(seg, ev) { - return this.view.trigger('eventClick', seg.el[0], seg.event, ev); // can return `false` to cancel + var res = this.view.trigger('eventClick', seg.el[0], seg.event, ev); // can return `false` to cancel + if (res === false) { + ev.preventDefault(); + } }, @@ -4242,7 +4037,9 @@ Grid.mixin({ !this.mousedOverSeg ) { this.mousedOverSeg = seg; - seg.el.addClass('fc-allow-mouse-resize'); + if (this.view.isEventResizable(seg.event)) { + seg.el.addClass('fc-allow-mouse-resize'); + } this.view.trigger('eventMouseover', seg.el[0], seg.event, ev); } }, @@ -4256,7 +4053,9 @@ Grid.mixin({ if (this.mousedOverSeg) { seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment this.mousedOverSeg = null; - seg.el.removeClass('fc-allow-mouse-resize'); + if (this.view.isEventResizable(seg.event)) { + seg.el.removeClass('fc-allow-mouse-resize'); + } this.view.trigger('eventMouseout', seg.el[0], seg.event, ev); } }, @@ -4353,6 +4152,7 @@ Grid.mixin({ subjectEl: el, subjectCenter: true, interactionStart: function(ev) { + seg.component = _this; // for renderDrag isDragging = false; mouseFollower = new MouseFollower(seg.el, { additionalClass: 'fc-dragging', @@ -4421,6 +4221,8 @@ Grid.mixin({ enableCursor(); }, interactionEnd: function(ev) { + delete seg.component; // prevent side effects + // do revert animation if hasn't changed. calls a callback when finished (whether animation or not) mouseFollower.stop(!dropLocation, function() { if (isDragging) { @@ -4508,11 +4310,7 @@ Grid.mixin({ } // othewise, work off existing values else { - dropLocation = { - start: event.start.clone(), - end: event.end ? event.end.clone() : null, - allDay: event.allDay // keep it the same - }; + dropLocation = pluckEventDateProps(event); } dropLocation.start.add(delta); @@ -4538,11 +4336,7 @@ Grid.mixin({ var opacity = this.view.opt('dragOpacity'); if (opacity != null) { - els.each(function(i, node) { - // Don't use jQuery (will set an IE filter), do it the old fashioned way. - // In IE8, a helper element will disappears if there's a filter. - node.style.opacity = opacity; - }); + els.css('opacity', opacity); } }, @@ -4708,8 +4502,11 @@ Grid.mixin({ disableCursor(); resizeLocation = null; } - // no change? (TODO: how does this work with timezones?) - else if (resizeLocation.start.isSame(event.start) && resizeLocation.end.isSame(eventEnd)) { + // no change? (FYI, event dates might have zones) + else if ( + resizeLocation.start.isSame(event.start.clone().stripZone()) && + resizeLocation.end.isSame(eventEnd.clone().stripZone()) + ) { resizeLocation = null; } } @@ -4862,15 +4659,11 @@ Grid.mixin({ // Generic utility for generating the HTML classNames for an event segment's element getSegClasses: function(seg, isDraggable, isResizable) { var view = this.view; - var event = seg.event; var classes = [ 'fc-event', seg.isStart ? 'fc-start' : 'fc-not-start', seg.isEnd ? 'fc-end' : 'fc-not-end' - ].concat( - event.className, - event.source ? event.source.className : [] - ); + ].concat(this.getSegCustomClasses(seg)); if (isDraggable) { classes.push('fc-draggable'); @@ -4880,7 +4673,7 @@ Grid.mixin({ } // event is currently selected? attach a className. - if (view.isEventSelected(event)) { + if (view.isEventSelected(seg.event)) { classes.push('fc-selected'); } @@ -4888,38 +4681,78 @@ Grid.mixin({ }, - // Utility for generating event skin-related CSS properties - getSegSkinCss: function(seg) { + // List of classes that were defined by the caller of the API in some way + getSegCustomClasses: function(seg) { var event = seg.event; - var view = this.view; - var source = event.source || {}; - var eventColor = event.color; - var sourceColor = source.color; - var optionColor = view.opt('eventColor'); + return [].concat( + event.className, // guaranteed to be an array + event.source ? event.source.className : [] + ); + }, + + + // Utility for generating event skin-related CSS properties + getSegSkinCss: function(seg) { return { - 'background-color': - event.backgroundColor || - eventColor || - source.backgroundColor || - sourceColor || - view.opt('eventBackgroundColor') || - optionColor, - 'border-color': - event.borderColor || - eventColor || - source.borderColor || - sourceColor || - view.opt('eventBorderColor') || - optionColor, - color: - event.textColor || - source.textColor || - view.opt('eventTextColor') + 'background-color': this.getSegBackgroundColor(seg), + 'border-color': this.getSegBorderColor(seg), + color: this.getSegTextColor(seg) }; }, + // Queries for caller-specified color, then falls back to default + getSegBackgroundColor: function(seg) { + return seg.event.backgroundColor || + seg.event.color || + this.getSegDefaultBackgroundColor(seg); + }, + + + getSegDefaultBackgroundColor: function(seg) { + var source = seg.event.source || {}; + + return source.backgroundColor || + source.color || + this.view.opt('eventBackgroundColor') || + this.view.opt('eventColor'); + }, + + + // Queries for caller-specified color, then falls back to default + getSegBorderColor: function(seg) { + return seg.event.borderColor || + seg.event.color || + this.getSegDefaultBorderColor(seg); + }, + + + getSegDefaultBorderColor: function(seg) { + var source = seg.event.source || {}; + + return source.borderColor || + source.color || + this.view.opt('eventBorderColor') || + this.view.opt('eventColor'); + }, + + + // Queries for caller-specified color, then falls back to default + getSegTextColor: function(seg) { + return seg.event.textColor || + this.getSegDefaultTextColor(seg); + }, + + + getSegDefaultTextColor: function(seg) { + var source = seg.event.source || {}; + + return source.textColor || + this.view.opt('eventTextColor'); + }, + + /* Converting events -> eventRange -> eventSpan -> eventSegs ------------------------------------------------------------------------------------------------------------------*/ @@ -4987,20 +4820,25 @@ Grid.mixin({ // Generates the unzoned start/end dates an event appears to occupy // Can accept an event "location" as well (which only has start/end and no allDay) eventToRange: function(event) { - return { - start: event.start.clone().stripZone(), - end: ( + var calendar = this.view.calendar; + var start = event.start.clone().stripZone(); + var end = ( event.end ? event.end.clone() : // derive the end from the start and allDay. compute allDay if necessary - this.view.calendar.getDefaultEventEnd( + calendar.getDefaultEventEnd( event.allDay != null ? event.allDay : !event.start.hasTime(), event.start ) - ).stripZone() - }; + ).stripZone(); + + // hack: dynamic locale change forgets to upate stored event localed + calendar.localizeMoment(start); + calendar.localizeMoment(end); + + return { start: start, end: end }; }, @@ -5103,6 +4941,16 @@ Grid.mixin({ ----------------------------------------------------------------------------------------------------------------------*/ +function pluckEventDateProps(event) { + return { + start: event.start.clone(), + end: event.end ? event.end.clone() : null, + allDay: event.allDay // keep it the same + }; +} +FC.pluckEventDateProps = pluckEventDateProps; + + function isBgEvent(event) { // returns true if background OR inverse-background var rendering = getEventRendering(event); return rendering === 'background' || rendering === 'inverse-background'; @@ -5493,7 +5341,7 @@ var DayTableMixin = FC.DayTableMixin = { return '' + '<th class="fc-day-header ' + view.widgetHeaderClass + ' fc-' + dayIDs[date.day()] + '"' + - (this.rowCnt == 1 ? + (this.rowCnt === 1 ? ' data-date="' + date.format('YYYY-MM-DD') + '"' : '') + (colspan > 1 ? @@ -5502,8 +5350,12 @@ var DayTableMixin = FC.DayTableMixin = { (otherAttrs ? ' ' + otherAttrs : '') + - '>' + - htmlEscape(date.format(this.colHeadFormat)) + + '>' + + // don't make a link if the heading could represent multiple days, or if there's only one day (forceOff) + view.buildGotoAnchorHtml( + { date: date, forceOff: this.rowCnt > 1 || this.colCnt === 1 }, + htmlEscape(date.format(this.colHeadFormat)) // inner HTML + ) + '</th>'; }, @@ -5656,13 +5508,16 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { renderBusinessHours: function() { - var events = this.view.calendar.getBusinessHoursEvents(true); // wholeDay=true - var segs = this.eventsToSegs(events); - + var segs = this.buildBusinessHourSegs(true); // wholeDay=true this.renderFill('businessHours', segs, 'bgevent'); }, + unrenderBusinessHours: function() { + this.unrenderFill('businessHours'); + }, + + // Generates the HTML for a single row, which is a div that wraps a table. // `row` is the row number. renderDayRowHtml: function(row, isRigid) { @@ -5729,19 +5584,53 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton. // The number row will only exist if either day numbers or week numbers are turned on. renderNumberCellHtml: function(date) { + var html = ''; var classes; + var weekCalcFirstDoW; - if (!this.view.dayNumbersVisible) { // if there are week numbers but not day numbers + if (!this.view.dayNumbersVisible && !this.view.cellWeekNumbersVisible) { + // no numbers in day cell (week number must be along the side) return '<td/>'; // will create an empty space above events :( } classes = this.getDayClasses(date); - classes.unshift('fc-day-number'); + classes.unshift('fc-day-top'); - return '' + - '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '">' + - date.date() + - '</td>'; + if (this.view.cellWeekNumbersVisible) { + // To determine the day of week number change under ISO, we cannot + // rely on moment.js methods such as firstDayOfWeek() or weekday(), + // because they rely on the locale's dow (possibly overridden by + // our firstDay option), which may not be Monday. We cannot change + // dow, because that would affect the calendar start day as well. + if (date._locale._fullCalendar_weekCalc === 'ISO') { + weekCalcFirstDoW = 1; // Monday by ISO 8601 definition + } + else { + weekCalcFirstDoW = date._locale.firstDayOfWeek(); + } + } + + html += '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '">'; + + if (this.view.cellWeekNumbersVisible && (date.day() == weekCalcFirstDoW)) { + html += this.view.buildGotoAnchorHtml( + { date: date, type: 'week' }, + { 'class': 'fc-week-number' }, + date.format('w') // inner HTML + ); + } + + if (this.view.dayNumbersVisible) { + html += this.view.buildGotoAnchorHtml( + date, + { 'class': 'fc-day-number' }, + date.date() // inner HTML + ); + } + + html += '</td>'; + + return html; }, @@ -5809,11 +5698,13 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { queryHit: function(leftOffset, topOffset) { - var col = this.colCoordCache.getHorizontalIndex(leftOffset); - var row = this.rowCoordCache.getVerticalIndex(topOffset); + if (this.colCoordCache.isLeftInBounds(leftOffset) && this.rowCoordCache.isTopInBounds(topOffset)) { + var col = this.colCoordCache.getHorizontalIndex(leftOffset); + var row = this.rowCoordCache.getVerticalIndex(topOffset); - if (row != null && col != null) { - return this.getCellHit(row, col); + if (row != null && col != null) { + return this.getCellHit(row, col); + } } }, @@ -5864,8 +5755,7 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { this.renderHighlight(this.eventToSpan(eventLocation)); // if a segment from the same calendar but another component is being dragged, render a helper event - if (seg && !seg.el.closest(this.el).length) { - + if (seg && seg.component !== this) { return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements } }, @@ -6573,7 +6463,7 @@ DayGrid.mixin({ options = { className: 'fc-more-popover', content: this.renderSegPopoverContent(row, col, segs), - parentEl: this.el, + parentEl: this.view.el, // attach to root of view. guarantees outside of scrollbars. top: topEl.offset().top, autoHide: true, // when the user clicks elsewhere, hide the popover viewportConstrain: view.opt('popoverViewportConstrain'), @@ -6596,6 +6486,10 @@ DayGrid.mixin({ this.segPopover = new Popover(options); this.segPopover.show(); + + // the popover doesn't live within the grid's container element, and thus won't get the event + // delegated-handlers for free. attach event-related handlers to the popover. + this.bindSegHandlersToEl(this.segPopover.el); }, @@ -6844,7 +6738,6 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { this.labelFormat = input || - view.opt('axisFormat') || // deprecated view.opt('smallTimeFormat'); // the computed default input = view.opt('slotLabelInterval'); @@ -6905,27 +6798,30 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { var snapsPerSlot = this.snapsPerSlot; var colCoordCache = this.colCoordCache; var slatCoordCache = this.slatCoordCache; - var colIndex = colCoordCache.getHorizontalIndex(leftOffset); - var slatIndex = slatCoordCache.getVerticalIndex(topOffset); - - if (colIndex != null && slatIndex != null) { - var slatTop = slatCoordCache.getTopOffset(slatIndex); - var slatHeight = slatCoordCache.getHeight(slatIndex); - var partial = (topOffset - slatTop) / slatHeight; // floating point number between 0 and 1 - var localSnapIndex = Math.floor(partial * snapsPerSlot); // the snap # relative to start of slat - var snapIndex = slatIndex * snapsPerSlot + localSnapIndex; - var snapTop = slatTop + (localSnapIndex / snapsPerSlot) * slatHeight; - var snapBottom = slatTop + ((localSnapIndex + 1) / snapsPerSlot) * slatHeight; - - return { - col: colIndex, - snap: snapIndex, - component: this, // needed unfortunately :( - left: colCoordCache.getLeftOffset(colIndex), - right: colCoordCache.getRightOffset(colIndex), - top: snapTop, - bottom: snapBottom - }; + + if (colCoordCache.isLeftInBounds(leftOffset) && slatCoordCache.isTopInBounds(topOffset)) { + var colIndex = colCoordCache.getHorizontalIndex(leftOffset); + var slatIndex = slatCoordCache.getVerticalIndex(topOffset); + + if (colIndex != null && slatIndex != null) { + var slatTop = slatCoordCache.getTopOffset(slatIndex); + var slatHeight = slatCoordCache.getHeight(slatIndex); + var partial = (topOffset - slatTop) / slatHeight; // floating point number between 0 and 1 + var localSnapIndex = Math.floor(partial * snapsPerSlot); // the snap # relative to start of slat + var snapIndex = slatIndex * snapsPerSlot + localSnapIndex; + var snapTop = slatTop + (localSnapIndex / snapsPerSlot) * slatHeight; + var snapBottom = slatTop + ((localSnapIndex + 1) / snapsPerSlot) * slatHeight; + + return { + col: colIndex, + snap: snapIndex, + component: this, // needed unfortunately :( + left: colCoordCache.getLeftOffset(colIndex), + right: colCoordCache.getRightOffset(colIndex), + top: snapTop, + bottom: snapBottom + }; + } } }, @@ -7128,10 +7024,9 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { renderBusinessHours: function() { - var events = this.view.calendar.getBusinessHoursEvents(); - var segs = this.eventsToSegs(events); - - this.renderBusinessSegs(segs); + this.renderBusinessSegs( + this.buildBusinessHourSegs() + ); }, @@ -8074,6 +7969,62 @@ var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, { }, + getAllDayHtml: function() { + return this.opt('allDayHtml') || htmlEscape(this.opt('allDayText')); + }, + + + /* Navigation + ------------------------------------------------------------------------------------------------------------------*/ + + + // Generates HTML for an anchor to another view into the calendar. + // Will either generate an <a> tag or a non-clickable <span> tag, depending on enabled settings. + // `gotoOptions` can either be a moment input, or an object with the form: + // { date, type, forceOff } + // `type` is a view-type like "day" or "week". default value is "day". + // `attrs` and `innerHtml` are use to generate the rest of the HTML tag. + buildGotoAnchorHtml: function(gotoOptions, attrs, innerHtml) { + var date, type, forceOff; + var finalOptions; + + if ($.isPlainObject(gotoOptions)) { + date = gotoOptions.date; + type = gotoOptions.type; + forceOff = gotoOptions.forceOff; + } + else { + date = gotoOptions; // a single moment input + } + date = FC.moment(date); // if a string, parse it + + finalOptions = { // for serialization into the link + date: date.format('YYYY-MM-DD'), + type: type || 'day' + }; + + if (typeof attrs === 'string') { + innerHtml = attrs; + attrs = null; + } + + attrs = attrs ? ' ' + attrsToStr(attrs) : ''; // will have a leading space + innerHtml = innerHtml || ''; + + if (!forceOff && this.opt('navLinks')) { + return '<a' + attrs + + ' data-goto="' + htmlEscape(JSON.stringify(finalOptions)) + '">' + + innerHtml + + '</a>'; + } + else { + return '<span' + attrs + '>' + + innerHtml + + '</span>'; + } + }, + + /* Rendering ------------------------------------------------------------------------------------------------------------------*/ @@ -8110,12 +8061,12 @@ var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, { // Does everything necessary to display the view centered around the given unzoned date. // Does every type of rendering EXCEPT rendering events. // Is asychronous and returns a promise. - display: function(date) { + display: function(date, explicitScrollState) { var _this = this; - var scrollState = null; + var prevScrollState = null; - if (this.displaying) { - scrollState = this.queryScroll(); + if (explicitScrollState != null && this.displaying) { // don't need prevScrollState if explicitScrollState + prevScrollState = this.queryScroll(); } this.calendar.freezeContentHeight(); @@ -8124,7 +8075,17 @@ var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, { return ( _this.displaying = syncThen(_this.displayView(date), function() { // displayView might return a promise - _this.forceScroll(_this.computeInitialScroll(scrollState)); + + // caller of display() wants a specific scroll state? + if (explicitScrollState != null) { + // we make an assumption that this is NOT the initial render, + // and thus don't need forceScroll (is inconveniently asynchronous) + _this.setScroll(explicitScrollState); + } + else { + _this.forceScroll(_this.computeInitialScroll(prevScrollState)); + } + _this.calendar.unfreezeContentHeight(); _this.triggerRender(); }) @@ -8559,14 +8520,24 @@ var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, { // Computes if the given event is allowed to be dragged by the user isEventDraggable: function(event) { - var source = event.source || {}; + return this.isEventStartEditable(event); + }, + + isEventStartEditable: function(event) { return firstDefined( event.startEditable, - source.startEditable, + (event.source || {}).startEditable, this.opt('eventStartEditable'), + this.isEventGenerallyEditable(event) + ); + }, + + + isEventGenerallyEditable: function(event) { + return firstDefined( event.editable, - source.editable, + (event.source || {}).editable, this.opt('editable') ); }, @@ -9066,8 +9037,9 @@ var Scroller = FC.Scroller = Class.extend({ var Calendar = FC.Calendar = Class.extend({ dirDefaults: null, // option defaults related to LTR or RTL - langDefaults: null, // option defaults related to current locale + localeDefaults: null, // option defaults related to current locale overrides: null, // option overrides given to the fullCalendar constructor + dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides. options: null, // all defaults combined with overrides viewSpecCache: null, // cache of view definitions view: null, // current View object @@ -9085,41 +9057,40 @@ var Calendar = FC.Calendar = Class.extend({ }, - // Initializes `this.options` and other important options-related objects - initOptions: function(overrides) { - var lang, langDefaults; + // Computes the flattened options hash for the calendar and assigns to `this.options`. + // Assumes this.overrides and this.dynamicOverrides have already been initialized. + populateOptionsHash: function() { + var locale, localeDefaults; var isRTL, dirDefaults; - // converts legacy options into non-legacy ones. - // in the future, when this is removed, don't use `overrides` reference. make a copy. - overrides = massageOverrides(overrides); - - lang = overrides.lang; - langDefaults = langOptionHash[lang]; - if (!langDefaults) { - lang = Calendar.defaults.lang; - langDefaults = langOptionHash[lang] || {}; + locale = firstDefined( // explicit locale option given? + this.dynamicOverrides.locale, + this.overrides.locale + ); + localeDefaults = localeOptionHash[locale]; + if (!localeDefaults) { // explicit locale option not given or invalid? + locale = Calendar.defaults.locale; + localeDefaults = localeOptionHash[locale] || {}; } - isRTL = firstDefined( - overrides.isRTL, - langDefaults.isRTL, + isRTL = firstDefined( // based on options computed so far, is direction RTL? + this.dynamicOverrides.isRTL, + this.overrides.isRTL, + localeDefaults.isRTL, Calendar.defaults.isRTL ); dirDefaults = isRTL ? Calendar.rtlDefaults : {}; this.dirDefaults = dirDefaults; - this.langDefaults = langDefaults; - this.overrides = overrides; + this.localeDefaults = localeDefaults; this.options = mergeOptions([ // merge defaults and overrides. lowest to highest precedence Calendar.defaults, // global defaults dirDefaults, - langDefaults, - overrides + localeDefaults, + this.overrides, + this.dynamicOverrides ]); - populateInstanceComputableOptions(this.options); - - this.viewSpecCache = {}; // somewhat unrelated + populateInstanceComputableOptions(this.options); // fill in gaps with computed options }, @@ -9231,9 +9202,10 @@ var Calendar = FC.Calendar = Class.extend({ Calendar.defaults, // global defaults spec.defaults, // view's defaults (from ViewSubclass.defaults) this.dirDefaults, - this.langDefaults, // locale and dir take precedence over view's defaults! + this.localeDefaults, // locale and dir take precedence over view's defaults! this.overrides, // calendar's overrides (options given to constructor) - spec.overrides // view's overrides (view-specific options) + spec.overrides, // view's overrides (view-specific options) + this.dynamicOverrides // dynamically set via setter. highest precedence ]); populateInstanceComputableOptions(spec.options); }, @@ -9247,17 +9219,21 @@ var Calendar = FC.Calendar = Class.extend({ function queryButtonText(options) { var buttonText = options.buttonText || {}; return buttonText[requestedViewType] || + // view can decide to look up a certain key + (spec.buttonTextKey ? buttonText[spec.buttonTextKey] : null) || + // a key like "month" (spec.singleUnit ? buttonText[spec.singleUnit] : null); } // highest to lowest priority spec.buttonTextOverride = + queryButtonText(this.dynamicOverrides) || queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence spec.overrides.buttonText; // `buttonText` for view-specific options is a string // highest to lowest priority. mirrors buildViewSpecOptions spec.buttonTextDefault = - queryButtonText(this.langDefaults) || + queryButtonText(this.localeDefaults) || queryButtonText(this.dirDefaults) || spec.defaults.buttonText || // a single string. from ViewSubclass.defaults queryButtonText(Calendar.defaults) || @@ -9324,10 +9300,6 @@ function Calendar_constructor(element, overrides) { var t = this; - t.initOptions(overrides || {}); - var options = this.options; - - // Exports // ----------------------------------------------------------------------------------- @@ -9352,67 +9324,95 @@ function Calendar_constructor(element, overrides) { t.getDate = getDate; t.getCalendar = getCalendar; t.getView = getView; - t.option = option; + t.option = option; // getter/setter method t.trigger = trigger; + // Options + // ----------------------------------------------------------------------------------- + + t.dynamicOverrides = {}; + t.viewSpecCache = {}; + t.optionHandlers = {}; // for Calendar.options.js + t.overrides = $.extend({}, overrides); // make a copy - // Language-data Internals + t.populateOptionsHash(); // sets this.options + + + + // Locale-data Internals // ----------------------------------------------------------------------------------- - // Apply overrides to the current language's data + // Apply overrides to the current locale's data + var localeData; - var localeData = createObject( // make a cheap copy - getMomentLocaleData(options.lang) // will fall back to en - ); + // Called immediately, and when any of the options change. + // Happens before any internal objects rebuild or rerender, because this is very core. + t.bindOptions([ + 'locale', 'monthNames', 'monthNamesShort', 'dayNames', 'dayNamesShort', 'firstDay', 'weekNumberCalculation' + ], function(locale, monthNames, monthNamesShort, dayNames, dayNamesShort, firstDay, weekNumberCalculation) { - if (options.monthNames) { - localeData._months = options.monthNames; - } - if (options.monthNamesShort) { - localeData._monthsShort = options.monthNamesShort; - } - if (options.dayNames) { - localeData._weekdays = options.dayNames; - } - if (options.dayNamesShort) { - localeData._weekdaysShort = options.dayNamesShort; - } - if (options.firstDay != null) { - var _week = createObject(localeData._week); // _week: { dow: # } - _week.dow = options.firstDay; - localeData._week = _week; - } + // normalize + if (weekNumberCalculation === 'iso') { + weekNumberCalculation = 'ISO'; // normalize + } + + localeData = createObject( // make a cheap copy + getMomentLocaleData(locale) // will fall back to en + ); + + if (monthNames) { + localeData._months = monthNames; + } + if (monthNamesShort) { + localeData._monthsShort = monthNamesShort; + } + if (dayNames) { + localeData._weekdays = dayNames; + } + if (dayNamesShort) { + localeData._weekdaysShort = dayNamesShort; + } - // assign a normalized value, to be used by our .week() moment extension - localeData._fullCalendar_weekCalc = (function(weekCalc) { - if (typeof weekCalc === 'function') { - return weekCalc; + if (firstDay == null && weekNumberCalculation === 'ISO') { + firstDay = 1; } - else if (weekCalc === 'local') { - return weekCalc; + if (firstDay != null) { + var _week = createObject(localeData._week); // _week: { dow: # } + _week.dow = firstDay; + localeData._week = _week; } - else if (weekCalc === 'iso' || weekCalc === 'ISO') { - return 'ISO'; + + if ( // whitelist certain kinds of input + weekNumberCalculation === 'ISO' || + weekNumberCalculation === 'local' || + typeof weekNumberCalculation === 'function' + ) { + localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it } - })(options.weekNumberCalculation); + // If the internal current date object already exists, move to new locale. + // We do NOT need to do this technique for event dates, because this happens when converting to "segments". + if (date) { + localizeMoment(date); // sets to localeData + } + }); // Calendar-specific Date Utilities // ----------------------------------------------------------------------------------- - t.defaultAllDayEventDuration = moment.duration(options.defaultAllDayEventDuration); - t.defaultTimedEventDuration = moment.duration(options.defaultTimedEventDuration); + t.defaultAllDayEventDuration = moment.duration(t.options.defaultAllDayEventDuration); + t.defaultTimedEventDuration = moment.duration(t.options.defaultTimedEventDuration); - // Builds a moment using the settings of the current calendar: timezone and language. + // Builds a moment using the settings of the current calendar: timezone and locale. // Accepts anything the vanilla moment() constructor accepts. t.moment = function() { var mom; - if (options.timezone === 'local') { + if (t.options.timezone === 'local') { mom = FC.moment.apply(null, arguments); // Force the moment to be local, because FC.moment doesn't guarantee it. @@ -9420,28 +9420,30 @@ function Calendar_constructor(element, overrides) { mom.local(); } } - else if (options.timezone === 'UTC') { + else if (t.options.timezone === 'UTC') { mom = FC.moment.utc.apply(null, arguments); // process as UTC } else { mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone } - if ('_locale' in mom) { // moment 2.8 and above - mom._locale = localeData; - } - else { // pre-moment-2.8 - mom._lang = localeData; - } + localizeMoment(mom); return mom; }; + // Updates the given moment's locale settings to the current calendar locale settings. + function localizeMoment(mom) { + mom._locale = localeData; + } + t.localizeMoment = localizeMoment; + + // Returns a boolean about whether or not the calendar knows how to calculate // the timezone offset of arbitrary dates in the current timezone. t.getIsAmbigTimezone = function() { - return options.timezone !== 'local' && options.timezone !== 'UTC'; + return t.options.timezone !== 'local' && t.options.timezone !== 'UTC'; }; @@ -9470,7 +9472,7 @@ function Calendar_constructor(element, overrides) { // Returns a moment for the current date, as defined by the client's computer or from the `now` option. // Will return an moment with an ambiguous timezone. t.getNow = function() { - var now = options.now; + var now = t.options.now; if (typeof now === 'function') { now = now(); } @@ -9512,8 +9514,7 @@ function Calendar_constructor(element, overrides) { // Produces a human-readable string for the given duration. // Side-effect: changes the locale of the given duration. t.humanizeDuration = function(duration) { - return (duration.locale || duration.lang).call(duration, options.lang) // works moment-pre-2.8 - .humanize(); + return duration.locale(t.options.locale).humanize(); }; @@ -9522,7 +9523,7 @@ function Calendar_constructor(element, overrides) { // ----------------------------------------------------------------------------------- - EventManager.call(t, options); + EventManager.call(t); var isFetchNeeded = t.isFetchNeeded; var fetchEvents = t.fetchEvents; var fetchEventSources = t.fetchEventSources; @@ -9535,7 +9536,6 @@ function Calendar_constructor(element, overrides) { var _element = element[0]; var header; - var headerElement; var content; var tm; // for making theme classes var currentView; // NOTE: keep this in sync with this.view @@ -9553,8 +9553,8 @@ function Calendar_constructor(element, overrides) { // compute the initial ambig-timezone date - if (options.defaultDate != null) { - date = t.moment(options.defaultDate).stripZone(); + if (t.options.defaultDate != null) { + date = t.moment(t.options.defaultDate).stripZone(); } else { date = t.getNow(); // getNow already returns unzoned @@ -9574,38 +9574,64 @@ function Calendar_constructor(element, overrides) { function initialRender() { - tm = options.theme ? 'ui' : 'fc'; element.addClass('fc'); - if (options.isRTL) { - element.addClass('fc-rtl'); - } - else { - element.addClass('fc-ltr'); - } + // event delegation for nav links + element.on('click.fc', 'a[data-goto]', function(ev) { + var anchorEl = $(this); + var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON + var date = t.moment(gotoOptions.date); + var viewType = gotoOptions.type; - if (options.theme) { - element.addClass('ui-widget'); - } - else { - element.addClass('fc-unthemed'); - } + // property like "navLinkDayClick". might be a string or a function + var customAction = currentView.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click'); + + if (typeof customAction === 'function') { + customAction(date, ev); + } + else { + if (typeof customAction === 'string') { + viewType = customAction; + } + zoomTo(date, viewType); + } + }); + + // called immediately, and upon option change + t.bindOption('theme', function(theme) { + tm = theme ? 'ui' : 'fc'; // affects a larger scope + element.toggleClass('ui-widget', theme); + element.toggleClass('fc-unthemed', !theme); + }); + + // called immediately, and upon option change. + // HACK: locale often affects isRTL, so we explicitly listen to that too. + t.bindOptions([ 'isRTL', 'locale' ], function(isRTL) { + element.toggleClass('fc-ltr', !isRTL); + element.toggleClass('fc-rtl', isRTL); + }); content = $("<div class='fc-view-container'/>").prependTo(element); - header = t.header = new Header(t, options); - headerElement = header.render(); - if (headerElement) { - element.prepend(headerElement); - } + header = t.header = new Header(t); + renderHeader(); - renderView(options.defaultView); + renderView(t.options.defaultView); - if (options.handleWindowResize) { - windowResizeProxy = debounce(windowResize, options.windowResizeDelay); // prevents rapid calls + if (t.options.handleWindowResize) { + windowResizeProxy = debounce(windowResize, t.options.windowResizeDelay); // prevents rapid calls $(window).resize(windowResizeProxy); } } + + + // can be called repeatedly and Header will rerender + function renderHeader() { + header.render(); + if (header.el) { + element.prepend(header.el); + } + } function destroy() { @@ -9621,6 +9647,8 @@ function Calendar_constructor(element, overrides) { content.remove(); element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget'); + element.off('.fc'); // unbind nav link handlers + if (windowResizeProxy) { $(window).unbind('resize', windowResizeProxy); } @@ -9639,15 +9667,14 @@ function Calendar_constructor(element, overrides) { // Renders a view because of a date change, view-type change, or for the first time. // If not given a viewType, keep the current view but render different dates. - function renderView(viewType) { + // Accepts an optional scroll state to restore to. + function renderView(viewType, explicitScrollState) { ignoreWindowResize++; // if viewType is changing, remove the old view's rendering if (currentView && viewType && currentView.type !== viewType) { - header.deactivateButton(currentView.type); freezeContentHeight(); // prevent a scroll jump when view element is removed - currentView.removeElement(); - currentView = t.view = null; + clearView(); } // if viewType changed, or the view was never created, create a fresh view @@ -9670,11 +9697,14 @@ function Calendar_constructor(element, overrides) { // render or rerender the view if ( !currentView.displaying || - !date.isWithin(currentView.intervalStart, currentView.intervalEnd) // implicit date window change + !( // NOT within interval range signals an implicit date window change + date >= currentView.intervalStart && + date < currentView.intervalEnd + ) ) { if (elementVisible()) { - currentView.display(date); // will call freezeContentHeight + currentView.display(date, explicitScrollState); // will call freezeContentHeight unfreezeContentHeight(); // immediately unfreeze regardless of whether display is async // need to do this after View::render, so dates are calculated @@ -9690,6 +9720,32 @@ function Calendar_constructor(element, overrides) { ignoreWindowResize--; } + + // Unrenders the current view and reflects this change in the Header. + // Unregsiters the `currentView`, but does not remove from viewByType hash. + function clearView() { + header.deactivateButton(currentView.type); + currentView.removeElement(); + currentView = t.view = null; + } + + + // Destroys the view, including the view object. Then, re-instantiates it and renders it. + // Maintains the same scroll state. + // TODO: maintain any other user-manipulated state. + function reinitView() { + ignoreWindowResize++; + freezeContentHeight(); + + var viewType = currentView.type; + var scrollState = currentView.queryScroll(); + clearView(); + renderView(viewType, scrollState); + + unfreezeContentHeight(); + ignoreWindowResize--; + } + // Resizing @@ -9705,7 +9761,7 @@ function Calendar_constructor(element, overrides) { t.isHeightAuto = function() { - return options.contentHeight === 'auto' || options.height === 'auto'; + return t.options.contentHeight === 'auto' || t.options.height === 'auto'; }; @@ -9733,16 +9789,33 @@ function Calendar_constructor(element, overrides) { function _calcSize() { // assumes elementVisible - if (typeof options.contentHeight === 'number') { // exists and not 'auto' - suggestedViewHeight = options.contentHeight; + var contentHeightInput = t.options.contentHeight; + var heightInput = t.options.height; + + if (typeof contentHeightInput === 'number') { // exists and not 'auto' + suggestedViewHeight = contentHeightInput; + } + else if (typeof contentHeightInput === 'function') { // exists and is a function + suggestedViewHeight = contentHeightInput(); } - else if (typeof options.height === 'number') { // exists and not 'auto' - suggestedViewHeight = options.height - (headerElement ? headerElement.outerHeight(true) : 0); + else if (typeof heightInput === 'number') { // exists and not 'auto' + suggestedViewHeight = heightInput - queryHeaderHeight(); + } + else if (typeof heightInput === 'function') { // exists and is a function + suggestedViewHeight = heightInput() - queryHeaderHeight(); + } + else if (heightInput === 'parent') { // set to height of parent element + suggestedViewHeight = element.parent().height() - queryHeaderHeight(); } else { - suggestedViewHeight = Math.round(content.width() / Math.max(options.aspectRatio, .5)); + suggestedViewHeight = Math.round(content.width() / Math.max(t.options.aspectRatio, .5)); } } + + + function queryHeaderHeight() { + return header.el ? header.el.outerHeight(true) : 0; // includes margin + } function windowResize(ev) { @@ -9785,7 +9858,7 @@ function Calendar_constructor(element, overrides) { function getAndRenderEvents() { - if (!options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) { + if (!t.options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) { fetchAndRenderEvents(); } else { @@ -9826,7 +9899,8 @@ function Calendar_constructor(element, overrides) { function updateTodayButton() { var now = t.getNow(); - if (now.isWithin(currentView.intervalStart, currentView.intervalEnd)) { + + if (now >= currentView.intervalStart && now < currentView.intervalEnd) { header.disableButton('today'); } else { @@ -9964,14 +10038,70 @@ function Calendar_constructor(element, overrides) { function option(name, value) { - if (value === undefined) { - return options[name]; + var newOptionHash; + + if (typeof name === 'string') { + if (value === undefined) { // getter + return t.options[name]; + } + else { // setter for individual option + newOptionHash = {}; + newOptionHash[name] = value; + setOptions(newOptionHash); + } } - if (name == 'height' || name == 'contentHeight' || name == 'aspectRatio') { - options[name] = value; - updateSize(true); // true = allow recalculation of height + else if (typeof name === 'object') { // compound setter with object input + setOptions(name); } } + + + function setOptions(newOptionHash) { + var optionCnt = 0; + var optionName; + + for (optionName in newOptionHash) { + t.dynamicOverrides[optionName] = newOptionHash[optionName]; + } + + t.viewSpecCache = {}; // the dynamic override invalidates the options in this cache, so just clear it + t.populateOptionsHash(); // this.options needs to be recomputed after the dynamic override + + // trigger handlers after this.options has been updated + for (optionName in newOptionHash) { + t.triggerOptionHandlers(optionName); // recall bindOption/bindOptions + optionCnt++; + } + + // special-case handling of single option change. + // if only one option change, `optionName` will be its name. + if (optionCnt === 1) { + if (optionName === 'height' || optionName === 'contentHeight' || optionName === 'aspectRatio') { + updateSize(true); // true = allow recalculation of height + return; + } + else if (optionName === 'defaultDate') { + return; // can't change date this way. use gotoDate instead + } + else if (optionName === 'businessHours') { + if (currentView) { + currentView.unrenderBusinessHours(); + currentView.renderBusinessHours(); + } + return; + } + else if (optionName === 'timezone') { + t.rezoneArrayEventSources(); + refetchEvents(); + return; + } + } + + // catch-all. rerender the header and rebuild/rerender the current view + renderHeader(); + viewsByType = {}; // even non-current views will be affected by this option change. do before rerender + reinitView(); + } function trigger(name, thisObj) { // overrides the Emitter's trigger method :( @@ -9980,8 +10110,8 @@ function Calendar_constructor(element, overrides) { thisObj = thisObj || _element; this.triggerWith(name, thisObj, args); // Emitter's method - if (options[name]) { - return options[name].apply(thisObj, args); + if (t.options[name]) { + return t.options[name].apply(thisObj, args); } } @@ -9989,11 +10119,75 @@ function Calendar_constructor(element, overrides) { } ;; +/* +Options binding/triggering system. +*/ +Calendar.mixin({ + + // A map of option names to arrays of handler objects. Initialized to {} in Calendar. + // Format for a handler object: + // { + // func // callback function to be called upon change + // names // option names whose values should be given to func + // } + optionHandlers: null, + + // Calls handlerFunc immediately, and when the given option has changed. + // handlerFunc will be given the option value. + bindOption: function(optionName, handlerFunc) { + this.bindOptions([ optionName ], handlerFunc); + }, + + // Calls handlerFunc immediately, and when any of the given options change. + // handlerFunc will be given each option value as ordered function arguments. + bindOptions: function(optionNames, handlerFunc) { + var handlerObj = { func: handlerFunc, names: optionNames }; + var i; + + for (i = 0; i < optionNames.length; i++) { + this.registerOptionHandlerObj(optionNames[i], handlerObj); + } + + this.triggerOptionHandlerObj(handlerObj); + }, + + // Puts the given handler object into the internal hash + registerOptionHandlerObj: function(optionName, handlerObj) { + (this.optionHandlers[optionName] || (this.optionHandlers[optionName] = [])) + .push(handlerObj); + }, + + // Reports that the given option has changed, and calls all appropriate handlers. + triggerOptionHandlers: function(optionName) { + var handlerObjs = this.optionHandlers[optionName] || []; + var i; + + for (i = 0; i < handlerObjs.length; i++) { + this.triggerOptionHandlerObj(handlerObjs[i]); + } + }, + + // Calls the callback for a specific handler object, passing in the appropriate arguments. + triggerOptionHandlerObj: function(handlerObj) { + var optionNames = handlerObj.names; + var optionValues = []; + var i; + + for (i = 0; i < optionNames.length; i++) { + optionValues.push(this.options[optionNames[i]]); + } + + handlerObj.func.apply(this, optionValues); // maintain the Calendar's `this` context + } + +}); + +;; Calendar.defaults = { titleRangeSeparator: ' \u2013 ', // en dash - monthYearFormat: 'MMMM YYYY', // required for en. other languages rely on datepicker computable option + monthYearFormat: 'MMMM YYYY', // required for en. other locales rely on datepicker computable option defaultTimedEventDuration: '02:00:00', defaultAllDayEventDuration: { days: 1 }, @@ -10050,6 +10244,8 @@ Calendar.defaults = { prevYear: 'left-double-arrow', nextYear: 'right-double-arrow' }, + + allDayText: 'all-day', // jquery-ui theming theme: false, @@ -10078,14 +10274,14 @@ Calendar.defaults = { dayPopoverFormat: 'LL', handleWindowResize: true, - windowResizeDelay: 200, // milliseconds before an updateSize happens + windowResizeDelay: 100, // milliseconds before an updateSize happens longPressDelay: 1000 }; -Calendar.englishDefaults = { // used by lang.js +Calendar.englishDefaults = { // used by locale.js dayPopoverFormat: 'dddd, MMMM D' }; @@ -10112,19 +10308,18 @@ Calendar.rtlDefaults = { // right-to-left defaults ;; -var langOptionHash = FC.langs = {}; // initialize and expose +var localeOptionHash = FC.locales = {}; // initialize and expose -// TODO: document the structure and ordering of a FullCalendar lang file -// TODO: rename everything "lang" to "locale", like what the moment project did +// TODO: document the structure and ordering of a FullCalendar locale file // Initialize jQuery UI datepicker translations while using some of the translations -// Will set this as the default language for datepicker. -FC.datepickerLang = function(langCode, dpLangCode, dpOptions) { +// Will set this as the default locales for datepicker. +FC.datepickerLocale = function(localeCode, dpLocaleCode, dpOptions) { - // get the FullCalendar internal option hash for this language. create if necessary - var fcOptions = langOptionHash[langCode] || (langOptionHash[langCode] = {}); + // get the FullCalendar internal option hash for this locale. create if necessary + var fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {}); // transfer some simple options from datepicker to fc fcOptions.isRTL = dpOptions.isRTL; @@ -10138,15 +10333,15 @@ FC.datepickerLang = function(langCode, dpLangCode, dpOptions) { // is jQuery UI Datepicker is on the page? if ($.datepicker) { - // Register the language data. - // FullCalendar and MomentJS use language codes like "pt-br" but Datepicker - // does it like "pt-BR" or if it doesn't have the language, maybe just "pt". - // Make an alias so the language can be referenced either way. - $.datepicker.regional[dpLangCode] = - $.datepicker.regional[langCode] = // alias + // Register the locale data. + // FullCalendar and MomentJS use locale codes like "pt-br" but Datepicker + // does it like "pt-BR" or if it doesn't have the locale, maybe just "pt". + // Make an alias so the locale can be referenced either way. + $.datepicker.regional[dpLocaleCode] = + $.datepicker.regional[localeCode] = // alias dpOptions; - // Alias 'en' to the default language data. Do this every time. + // Alias 'en' to the default locale data. Do this every time. $.datepicker.regional.en = $.datepicker.regional['']; // Set as Datepicker's global defaults. @@ -10155,35 +10350,35 @@ FC.datepickerLang = function(langCode, dpLangCode, dpOptions) { }; -// Sets FullCalendar-specific translations. Will set the language as the global default. -FC.lang = function(langCode, newFcOptions) { +// Sets FullCalendar-specific translations. Will set the locales as the global default. +FC.locale = function(localeCode, newFcOptions) { var fcOptions; var momOptions; - // get the FullCalendar internal option hash for this language. create if necessary - fcOptions = langOptionHash[langCode] || (langOptionHash[langCode] = {}); + // get the FullCalendar internal option hash for this locale. create if necessary + fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {}); - // provided new options for this language? merge them in + // provided new options for this locales? merge them in if (newFcOptions) { - fcOptions = langOptionHash[langCode] = mergeOptions([ fcOptions, newFcOptions ]); + fcOptions = localeOptionHash[localeCode] = mergeOptions([ fcOptions, newFcOptions ]); } - // compute language options that weren't defined. + // compute locale options that weren't defined. // always do this. newFcOptions can be undefined when initializing from i18n file, // so no way to tell if this is an initialization or a default-setting. - momOptions = getMomentLocaleData(langCode); // will fall back to en + momOptions = getMomentLocaleData(localeCode); // will fall back to en $.each(momComputableOptions, function(name, func) { if (fcOptions[name] == null) { fcOptions[name] = func(momOptions, fcOptions); } }); - // set it as the default language for FullCalendar - Calendar.defaults.lang = langCode; + // set it as the default locale for FullCalendar + Calendar.defaults.locale = localeCode; }; -// NOTE: can't guarantee any of these computations will run because not every language has datepicker +// NOTE: can't guarantee any of these computations will run because not every locale has datepicker // configs, so make sure there are English fallbacks for these in the defaults file. var dpComputableOptions = { @@ -10233,7 +10428,7 @@ var momComputableOptions = { smallTimeFormat: function(momOptions) { return momOptions.longDateFormat('LT') .replace(':mm', '(:mm)') - .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs + .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand }, @@ -10241,7 +10436,7 @@ var momComputableOptions = { extraSmallTimeFormat: function(momOptions) { return momOptions.longDateFormat('LT') .replace(':mm', '(:mm)') - .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs + .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand }, @@ -10249,7 +10444,7 @@ var momComputableOptions = { hourFormat: function(momOptions) { return momOptions.longDateFormat('LT') .replace(':mm', '') - .replace(/(\Wmm)$/, '') // like above, but for foreign langs + .replace(/(\Wmm)$/, '') // like above, but for foreign locales .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand }, @@ -10263,7 +10458,7 @@ var momComputableOptions = { // options that should be computed off live calendar options (considers override options) -// TODO: best place for this? related to lang? +// TODO: best place for this? related to locale? // TODO: flipping text based on isRTL is a bad idea because the CSS `direction` might want to handle it var instanceComputableOptions = { @@ -10300,17 +10495,14 @@ function populateInstanceComputableOptions(options) { // Returns moment's internal locale data. If doesn't exist, returns English. -// Works with moment-pre-2.8 -function getMomentLocaleData(langCode) { - var func = moment.localeData || moment.langData; - return func.call(moment, langCode) || - func.call(moment, 'en'); // the newer localData could return null, so fall back to en +function getMomentLocaleData(localeCode) { + return moment.localeData(localeCode) || moment.localeData('en'); } // Initialize English by forcing computation of moment-derived options. // Also, sets it as the default. -FC.lang('en', Calendar.englishDefaults); +FC.locale('en', Calendar.englishDefaults); ;; @@ -10318,7 +10510,7 @@ FC.lang('en', Calendar.englishDefaults); ----------------------------------------------------------------------------------------------------------------------*/ // TODO: rename all header-related things to "toolbar" -function Header(calendar, options) { +function Header(calendar) { var t = this; // exports @@ -10330,38 +10522,50 @@ function Header(calendar, options) { t.disableButton = disableButton; t.enableButton = enableButton; t.getViewsWithButtons = getViewsWithButtons; + t.el = null; // mirrors local `el` // locals - var el = $(); + var el; var viewsWithButtons = []; var tm; + // can be called repeatedly and will rerender function render() { + var options = calendar.options; var sections = options.header; tm = options.theme ? 'ui' : 'fc'; if (sections) { - el = $("<div class='fc-toolbar'/>") - .append(renderSection('left')) + if (!el) { + el = this.el = $("<div class='fc-toolbar'/>"); + } + else { + el.empty(); + } + el.append(renderSection('left')) .append(renderSection('right')) .append(renderSection('center')) .append('<div class="fc-clear"/>'); - - return el; + } + else { + removeElement(); } } function removeElement() { - el.remove(); - el = $(); + if (el) { + el.remove(); + el = t.el = null; + } } function renderSection(position) { var sectionEl = $('<div class="fc-' + position + '"/>'); + var options = calendar.options; var buttonStr = options.header[position]; if (buttonStr) { @@ -10387,7 +10591,7 @@ function Header(calendar, options) { isOnlyButtons = false; } else { - if ((customButtonProps = (calendar.options.customButtons || {})[buttonName])) { + if ((customButtonProps = (options.customButtons || {})[buttonName])) { buttonClick = function(ev) { if (customButtonProps.click) { customButtonProps.click.call(button[0], ev); @@ -10523,33 +10727,43 @@ function Header(calendar, options) { function updateTitle(text) { - el.find('h2').text(text); + if (el) { + el.find('h2').text(text); + } } function activateButton(buttonName) { - el.find('.fc-' + buttonName + '-button') - .addClass(tm + '-state-active'); + if (el) { + el.find('.fc-' + buttonName + '-button') + .addClass(tm + '-state-active'); + } } function deactivateButton(buttonName) { - el.find('.fc-' + buttonName + '-button') - .removeClass(tm + '-state-active'); + if (el) { + el.find('.fc-' + buttonName + '-button') + .removeClass(tm + '-state-active'); + } } function disableButton(buttonName) { - el.find('.fc-' + buttonName + '-button') - .prop('disabled', true) - .addClass(tm + '-state-disabled'); + if (el) { + el.find('.fc-' + buttonName + '-button') + .prop('disabled', true) + .addClass(tm + '-state-disabled'); + } } function enableButton(buttonName) { - el.find('.fc-' + buttonName + '-button') - .prop('disabled', false) - .removeClass(tm + '-state-disabled'); + if (el) { + el.find('.fc-' + buttonName + '-button') + .prop('disabled', false) + .removeClass(tm + '-state-disabled'); + } } @@ -10572,7 +10786,7 @@ var ajaxDefaults = { var eventGUID = 1; -function EventManager(options) { // assumed to be a calendar +function EventManager() { // assumed to be a calendar var t = this; @@ -10609,7 +10823,7 @@ function EventManager(options) { // assumed to be a calendar $.each( - (options.events ? [ options.events ] : []).concat(options.eventSources || []), + (t.options.events ? [ t.options.events ] : []).concat(t.options.eventSources || []), function(i, sourceInput) { var source = buildEventSource(sourceInput); if (source) { @@ -10743,7 +10957,7 @@ function EventManager(options) { // assumed to be a calendar source, rangeStart.clone(), rangeEnd.clone(), - options.timezone, + t.options.timezone, callback ); @@ -10766,7 +10980,7 @@ function EventManager(options) { // assumed to be a calendar t, // this, the Calendar object rangeStart.clone(), rangeEnd.clone(), - options.timezone, + t.options.timezone, function(events) { callback(events); t.popLoading(); @@ -10801,9 +11015,9 @@ function EventManager(options) { // assumed to be a calendar // and not affect the passed-in object. var data = $.extend({}, customData || {}); - var startParam = firstDefined(source.startParam, options.startParam); - var endParam = firstDefined(source.endParam, options.endParam); - var timezoneParam = firstDefined(source.timezoneParam, options.timezoneParam); + var startParam = firstDefined(source.startParam, t.options.startParam); + var endParam = firstDefined(source.endParam, t.options.endParam); + var timezoneParam = firstDefined(source.timezoneParam, t.options.timezoneParam); if (startParam) { data[startParam] = rangeStart.format(); @@ -10811,8 +11025,8 @@ function EventManager(options) { // assumed to be a calendar if (endParam) { data[endParam] = rangeEnd.format(); } - if (options.timezone && options.timezone != 'local') { - data[timezoneParam] = options.timezone; + if (t.options.timezone && t.options.timezone != 'local') { + data[timezoneParam] = t.options.timezone; } t.pushLoading(); @@ -11144,7 +11358,7 @@ function EventManager(options) { // assumed to be a calendar reportEvents(cache); } - + function clientEvents(filter) { if ($.isFunction(filter)) { @@ -11158,7 +11372,33 @@ function EventManager(options) { // assumed to be a calendar } return cache; // else, return all } - + + + // Makes sure all array event sources have their internal event objects + // converted over to the Calendar's current timezone. + t.rezoneArrayEventSources = function() { + var i; + var events; + var j; + + for (i = 0; i < sources.length; i++) { + events = sources[i].events; + if ($.isArray(events)) { + + for (j = 0; j < events.length; j++) { + rezoneEventDates(events[j]); + } + } + } + }; + + function rezoneEventDates(event) { + event.start = t.moment(event.start); + if (event.end) { + event.end = t.moment(event.end); + } + backupEventDates(event); + } /* Event Normalization @@ -11174,8 +11414,8 @@ function EventManager(options) { // assumed to be a calendar var start, end; var allDay; - if (options.eventDataTransform) { - input = options.eventDataTransform(input); + if (t.options.eventDataTransform) { + input = t.options.eventDataTransform(input); } if (source && source.eventDataTransform) { input = source.eventDataTransform(input); @@ -11241,7 +11481,7 @@ function EventManager(options) { // assumed to be a calendar if (allDay === undefined) { // still undefined? fallback to default allDay = firstDefined( source ? source.allDayDefault : undefined, - options.allDayDefault + t.options.allDayDefault ); // still undefined? normalizeEventDates will calculate it } @@ -11253,6 +11493,7 @@ function EventManager(options) { // assumed to be a calendar return out; } + t.buildEventFromInput = buildEventFromInput; // Normalizes and assigns the given dates to the given partially-formed event object. @@ -11277,7 +11518,7 @@ function EventManager(options) { // assumed to be a calendar } if (!eventProps.end) { - if (options.forceEventDuration) { + if (t.options.forceEventDuration) { eventProps.end = t.getDefaultEventEnd(eventProps.allDay, eventProps.start); } else { @@ -11376,6 +11617,7 @@ function EventManager(options) { // assumed to be a calendar return events; } + t.expandEvent = expandEvent; @@ -11569,125 +11811,134 @@ function EventManager(options) { // assumed to be a calendar } - /* Business Hours - -----------------------------------------------------------------------------------------*/ + t.getEventCache = function() { + return cache; + }; - t.getBusinessHoursEvents = getBusinessHoursEvents; +} - // Returns an array of events as to when the business hours occur in the given view. - // Abuse of our event system :( - function getBusinessHoursEvents(wholeDay) { - var optionVal = options.businessHours; - var defaultVal = { - className: 'fc-nonbusiness', - start: '09:00', - end: '17:00', - dow: [ 1, 2, 3, 4, 5 ], // monday - friday - rendering: 'inverse-background' - }; - var view = t.getView(); - var eventInput; +// hook for external libs to manipulate event properties upon creation. +// should manipulate the event in-place. +Calendar.prototype.normalizeEvent = function(event) { +}; - if (optionVal) { // `true` (which means "use the defaults") or an override object - eventInput = $.extend( - {}, // copy to a new object in either case - defaultVal, - typeof optionVal === 'object' ? optionVal : {} // override the defaults - ); - } - if (eventInput) { +// Does the given span (start, end, and other location information) +// fully contain the other? +Calendar.prototype.spanContainsSpan = function(outerSpan, innerSpan) { + var eventStart = outerSpan.start.clone().stripZone(); + var eventEnd = this.getEventEnd(outerSpan).stripZone(); - // if a whole-day series is requested, clear the start/end times - if (wholeDay) { - eventInput.start = null; - eventInput.end = null; - } + return innerSpan.start >= eventStart && innerSpan.end <= eventEnd; +}; - return expandEvent( - buildEventFromInput(eventInput), - view.start, - view.end - ); - } - return []; +// Returns a list of events that the given event should be compared against when being considered for a move to +// the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar. +Calendar.prototype.getPeerEvents = function(span, event) { + var cache = this.getEventCache(); + var peerEvents = []; + var i, otherEvent; + + for (i = 0; i < cache.length; i++) { + otherEvent = cache[i]; + if ( + !event || + event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events + ) { + peerEvents.push(otherEvent); + } } + return peerEvents; +}; - /* Overlapping / Constraining - -----------------------------------------------------------------------------------------*/ - t.isEventSpanAllowed = isEventSpanAllowed; - t.isExternalSpanAllowed = isExternalSpanAllowed; - t.isSelectionSpanAllowed = isSelectionSpanAllowed; +// updates the "backup" properties, which are preserved in order to compute diffs later on. +function backupEventDates(event) { + event._allDay = event.allDay; + event._start = event.start.clone(); + event._end = event.end ? event.end.clone() : null; +} - // Determines if the given event can be relocated to the given span (unzoned start/end with other misc data) - function isEventSpanAllowed(span, event) { - var source = event.source || {}; - var constraint = firstDefined( - event.constraint, - source.constraint, - options.eventConstraint - ); - var overlap = firstDefined( - event.overlap, - source.overlap, - options.eventOverlap - ); - return isSpanAllowed(span, constraint, overlap, event); - } +/* Overlapping / Constraining +-----------------------------------------------------------------------------------------*/ - // Determines if an external event can be relocated to the given span (unzoned start/end with other misc data) - function isExternalSpanAllowed(eventSpan, eventLocation, eventProps) { - var eventInput; - var event; +// Determines if the given event can be relocated to the given span (unzoned start/end with other misc data) +Calendar.prototype.isEventSpanAllowed = function(span, event) { + var source = event.source || {}; - // note: very similar logic is in View's reportExternalDrop - if (eventProps) { - eventInput = $.extend({}, eventProps, eventLocation); - event = expandEvent(buildEventFromInput(eventInput))[0]; - } + var constraint = firstDefined( + event.constraint, + source.constraint, + this.options.eventConstraint + ); - if (event) { - return isEventSpanAllowed(eventSpan, event); - } - else { // treat it as a selection + var overlap = firstDefined( + event.overlap, + source.overlap, + this.options.eventOverlap + ); - return isSelectionSpanAllowed(eventSpan); - } + return this.isSpanAllowed(span, constraint, overlap, event) && + (!this.options.eventAllow || this.options.eventAllow(span, event) !== false); +}; + + +// Determines if an external event can be relocated to the given span (unzoned start/end with other misc data) +Calendar.prototype.isExternalSpanAllowed = function(eventSpan, eventLocation, eventProps) { + var eventInput; + var event; + + // note: very similar logic is in View's reportExternalDrop + if (eventProps) { + eventInput = $.extend({}, eventProps, eventLocation); + event = this.expandEvent( + this.buildEventFromInput(eventInput) + )[0]; } + if (event) { + return this.isEventSpanAllowed(eventSpan, event); + } + else { // treat it as a selection - // Determines the given span (unzoned start/end with other misc data) can be selected. - function isSelectionSpanAllowed(span) { - return isSpanAllowed(span, options.selectConstraint, options.selectOverlap); + return this.isSelectionSpanAllowed(eventSpan); } +}; - // Returns true if the given span (caused by an event drop/resize or a selection) is allowed to exist - // according to the constraint/overlap settings. - // `event` is not required if checking a selection. - function isSpanAllowed(span, constraint, overlap, event) { - var constraintEvents; - var anyContainment; - var peerEvents; - var i, peerEvent; - var peerOverlap; +// Determines the given span (unzoned start/end with other misc data) can be selected. +Calendar.prototype.isSelectionSpanAllowed = function(span) { + return this.isSpanAllowed(span, this.options.selectConstraint, this.options.selectOverlap) && + (!this.options.selectAllow || this.options.selectAllow(span) !== false); +}; + - // the range must be fully contained by at least one of produced constraint events - if (constraint != null) { +// Returns true if the given span (caused by an event drop/resize or a selection) is allowed to exist +// according to the constraint/overlap settings. +// `event` is not required if checking a selection. +Calendar.prototype.isSpanAllowed = function(span, constraint, overlap, event) { + var constraintEvents; + var anyContainment; + var peerEvents; + var i, peerEvent; + var peerOverlap; - // not treated as an event! intermediate data structure - // TODO: use ranges in the future - constraintEvents = constraintToEvents(constraint); + // the range must be fully contained by at least one of produced constraint events + if (constraint != null) { + + // not treated as an event! intermediate data structure + // TODO: use ranges in the future + constraintEvents = this.constraintToEvents(constraint); + if (constraintEvents) { // not invalid anyContainment = false; for (i = 0; i < constraintEvents.length; i++) { - if (eventContainsRange(constraintEvents[i], span)) { + if (this.spanContainsSpan(constraintEvents[i], span)) { anyContainment = true; break; } @@ -11697,126 +11948,150 @@ function EventManager(options) { // assumed to be a calendar return false; } } + } - peerEvents = t.getPeerEvents(span, event); + peerEvents = this.getPeerEvents(span, event); - for (i = 0; i < peerEvents.length; i++) { - peerEvent = peerEvents[i]; + for (i = 0; i < peerEvents.length; i++) { + peerEvent = peerEvents[i]; - // there needs to be an actual intersection before disallowing anything - if (eventIntersectsRange(peerEvent, span)) { + // there needs to be an actual intersection before disallowing anything + if (this.eventIntersectsRange(peerEvent, span)) { - // evaluate overlap for the given range and short-circuit if necessary - if (overlap === false) { + // evaluate overlap for the given range and short-circuit if necessary + if (overlap === false) { + return false; + } + // if the event's overlap is a test function, pass the peer event in question as the first param + else if (typeof overlap === 'function' && !overlap(peerEvent, event)) { + return false; + } + + // if we are computing if the given range is allowable for an event, consider the other event's + // EventObject-specific or Source-specific `overlap` property + if (event) { + peerOverlap = firstDefined( + peerEvent.overlap, + (peerEvent.source || {}).overlap + // we already considered the global `eventOverlap` + ); + if (peerOverlap === false) { return false; } - // if the event's overlap is a test function, pass the peer event in question as the first param - else if (typeof overlap === 'function' && !overlap(peerEvent, event)) { + // if the peer event's overlap is a test function, pass the subject event as the first param + if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) { return false; } - - // if we are computing if the given range is allowable for an event, consider the other event's - // EventObject-specific or Source-specific `overlap` property - if (event) { - peerOverlap = firstDefined( - peerEvent.overlap, - (peerEvent.source || {}).overlap - // we already considered the global `eventOverlap` - ); - if (peerOverlap === false) { - return false; - } - // if the peer event's overlap is a test function, pass the subject event as the first param - if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) { - return false; - } - } } } - - return true; } + return true; +}; - // Given an event input from the API, produces an array of event objects. Possible event inputs: - // 'businessHours' - // An event ID (number or string) - // An object with specific start/end dates or a recurring event (like what businessHours accepts) - function constraintToEvents(constraintInput) { - if (constraintInput === 'businessHours') { - return getBusinessHoursEvents(); - } +// Given an event input from the API, produces an array of event objects. Possible event inputs: +// 'businessHours' +// An event ID (number or string) +// An object with specific start/end dates or a recurring event (like what businessHours accepts) +Calendar.prototype.constraintToEvents = function(constraintInput) { - if (typeof constraintInput === 'object') { - return expandEvent(buildEventFromInput(constraintInput)); - } + if (constraintInput === 'businessHours') { + return this.getCurrentBusinessHourEvents(); + } - return clientEvents(constraintInput); // probably an ID + if (typeof constraintInput === 'object') { + if (constraintInput.start != null) { // needs to be event-like input + return this.expandEvent(this.buildEventFromInput(constraintInput)); + } + else { + return null; // invalid + } } + return this.clientEvents(constraintInput); // probably an ID +}; - // Does the event's date range fully contain the given range? - // start/end already assumed to have stripped zones :( - function eventContainsRange(event, range) { - var eventStart = event.start.clone().stripZone(); - var eventEnd = t.getEventEnd(event).stripZone(); - return range.start >= eventStart && range.end <= eventEnd; - } +// Does the event's date range intersect with the given range? +// start/end already assumed to have stripped zones :( +Calendar.prototype.eventIntersectsRange = function(event, range) { + var eventStart = event.start.clone().stripZone(); + var eventEnd = this.getEventEnd(event).stripZone(); + return range.start < eventEnd && range.end > eventStart; +}; - // Does the event's date range intersect with the given range? - // start/end already assumed to have stripped zones :( - function eventIntersectsRange(event, range) { - var eventStart = event.start.clone().stripZone(); - var eventEnd = t.getEventEnd(event).stripZone(); - return range.start < eventEnd && range.end > eventStart; - } +/* Business Hours +-----------------------------------------------------------------------------------------*/ +var BUSINESS_HOUR_EVENT_DEFAULTS = { + id: '_fcBusinessHours', // will relate events from different calls to expandEvent + start: '09:00', + end: '17:00', + dow: [ 1, 2, 3, 4, 5 ], // monday - friday + rendering: 'inverse-background' + // classNames are defined in businessHoursSegClasses +}; - t.getEventCache = function() { - return cache; - }; +// Return events objects for business hours within the current view. +// Abuse of our event system :( +Calendar.prototype.getCurrentBusinessHourEvents = function(wholeDay) { + return this.computeBusinessHourEvents(wholeDay, this.options.businessHours); +}; -} +// Given a raw input value from options, return events objects for business hours within the current view. +Calendar.prototype.computeBusinessHourEvents = function(wholeDay, input) { + if (input === true) { + return this.expandBusinessHourEvents(wholeDay, [ {} ]); + } + else if ($.isPlainObject(input)) { + return this.expandBusinessHourEvents(wholeDay, [ input ]); + } + else if ($.isArray(input)) { + return this.expandBusinessHourEvents(wholeDay, input, true); + } + else { + return []; + } +}; +// inputs expected to be an array of objects. +// if ignoreNoDow is true, will ignore entries that don't specify a day-of-week (dow) key. +Calendar.prototype.expandBusinessHourEvents = function(wholeDay, inputs, ignoreNoDow) { + var view = this.getView(); + var events = []; + var i, input; -// hook for external libs to manipulate event properties upon creation. -// should manipulate the event in-place. -Calendar.prototype.normalizeEvent = function(event) { -}; + for (i = 0; i < inputs.length; i++) { + input = inputs[i]; + if (ignoreNoDow && !input.dow) { + continue; + } -// Returns a list of events that the given event should be compared against when being considered for a move to -// the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar. -Calendar.prototype.getPeerEvents = function(span, event) { - var cache = this.getEventCache(); - var peerEvents = []; - var i, otherEvent; + // give defaults. will make a copy + input = $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, input); - for (i = 0; i < cache.length; i++) { - otherEvent = cache[i]; - if ( - !event || - event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events - ) { - peerEvents.push(otherEvent); + // if a whole-day series is requested, clear the start/end times + if (wholeDay) { + input.start = null; + input.end = null; } + + events.push.apply(events, // append + this.expandEvent( + this.buildEventFromInput(input), + view.start, + view.end + ) + ); } - return peerEvents; + return events; }; - -// updates the "backup" properties, which are preserved in order to compute diffs later on. -function backupEventDates(event) { - event._allDay = event.allDay; - event._start = event.start.clone(); - event._end = event.end ? event.end.clone() : null; -} - ;; /* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells. @@ -11832,7 +12107,8 @@ var BasicView = FC.BasicView = View.extend({ dayGrid: null, // the main subcomponent that does most of the heavy lifting dayNumbersVisible: false, // display day numbers on each day cell? - weekNumbersVisible: false, // display week numbers along the side? + colWeekNumbersVisible: false, // display week numbers along the side? + cellWeekNumbersVisible: false, // display week numbers in day cell? weekNumberWidth: null, // width of all the week-number cells running down the side @@ -11893,8 +12169,18 @@ var BasicView = FC.BasicView = View.extend({ renderDates: function() { this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible - this.weekNumbersVisible = this.opt('weekNumbers'); - this.dayGrid.numbersVisible = this.dayNumbersVisible || this.weekNumbersVisible; + if (this.opt('weekNumbers')) { + if (this.opt('weekNumbersWithinDays')) { + this.cellWeekNumbersVisible = true; + this.colWeekNumbersVisible = false; + } + else { + this.cellWeekNumbersVisible = false; + this.colWeekNumbersVisible = true; + }; + } + this.dayGrid.numbersVisible = this.dayNumbersVisible || + this.cellWeekNumbersVisible || this.colWeekNumbersVisible; this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml()); this.renderHead(); @@ -11932,6 +12218,11 @@ var BasicView = FC.BasicView = View.extend({ }, + unrenderBusinessHours: function() { + this.dayGrid.unrenderBusinessHours(); + }, + + // Builds the HTML skeleton for the view. // The day-grid component will render inside of a container defined by this HTML. renderSkeletonHtml: function() { @@ -11973,7 +12264,7 @@ var BasicView = FC.BasicView = View.extend({ // Refreshes the horizontal dimensions of the view updateWidth: function() { - if (this.weekNumbersVisible) { + if (this.colWeekNumbersVisible) { // Make sure all week number cells running down the side have the same width. // Record the width for cells created later. this.weekNumberWidth = matchCellWidths( @@ -12114,9 +12405,8 @@ var BasicView = FC.BasicView = View.extend({ unrenderEvents: function() { this.dayGrid.unrenderEvents(); - // we DON'T need to call updateHeight() because: - // A) a renderEvents() call always happens after this, which will eventually call updateHeight() - // B) in IE8, this causes a flash whenever events are rerendered + // we DON'T need to call updateHeight() because + // a renderEvents() call always happens after this, which will eventually call updateHeight() }, @@ -12161,7 +12451,7 @@ var basicDayGridMethods = { renderHeadIntroHtml: function() { var view = this.view; - if (view.weekNumbersVisible) { + if (view.colWeekNumbersVisible) { return '' + '<th class="fc-week-number ' + view.widgetHeaderClass + '" ' + view.weekNumberStyleAttr() + '>' + '<span>' + // needed for matchCellWidths @@ -12177,13 +12467,15 @@ var basicDayGridMethods = { // Generates the HTML that will go before content-skeleton cells that display the day/week numbers renderNumberIntroHtml: function(row) { var view = this.view; + var weekStart = this.getCellDate(row, 0); - if (view.weekNumbersVisible) { + if (view.colWeekNumbersVisible) { return '' + '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '>' + - '<span>' + // needed for matchCellWidths - this.getCellDate(row, 0).format('w') + - '</span>' + + view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths + { date: weekStart, type: 'week', forceOff: this.colCnt === 1 }, + weekStart.format('w') // inner HTML + ) + '</td>'; } @@ -12195,7 +12487,7 @@ var basicDayGridMethods = { renderBgIntroHtml: function() { var view = this.view; - if (view.weekNumbersVisible) { + if (view.colWeekNumbersVisible) { return '<td class="fc-week-number ' + view.widgetContentClass + '" ' + view.weekNumberStyleAttr() + '></td>'; } @@ -12209,7 +12501,7 @@ var basicDayGridMethods = { renderIntroHtml: function() { var view = this.view; - if (view.weekNumbersVisible) { + if (view.colWeekNumbersVisible) { return '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '></td>'; } @@ -12243,8 +12535,6 @@ var MonthView = FC.MonthView = BasicView.extend({ // Overrides the default BasicView behavior to have special multi-week auto-height logic setGridHeight: function(height, isAuto) { - isAuto = isAuto || this.opt('weekMode') === 'variable'; // LEGACY: weekMode is deprecated - // if auto, make the height of each row the height that it would be if there were 6 weeks if (isAuto) { height *= this.rowCnt / 6; @@ -12255,11 +12545,6 @@ var MonthView = FC.MonthView = BasicView.extend({ isFixedWeeks: function() { - var weekMode = this.opt('weekMode'); // LEGACY: weekMode is deprecated - if (weekMode) { - return weekMode === 'fixed'; // if any other type of weekMode, assume NOT fixed - } - return this.opt('fixedWeekCount'); } @@ -12689,9 +12974,8 @@ var AgendaView = FC.AgendaView = View.extend({ this.dayGrid.unrenderEvents(); } - // we DON'T need to call updateHeight() because: - // A) a renderEvents() call always happens after this, which will eventually call updateHeight() - // B) in IE8, this causes a flash whenever events are rerendered + // we DON'T need to call updateHeight() because + // a renderEvents() call always happens after this, which will eventually call updateHeight() }, @@ -12759,9 +13043,10 @@ var agendaTimeGridMethods = { return '' + '<th class="fc-axis fc-week-number ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '>' + - '<span>' + // needed for matchCellWidths - htmlEscape(weekText) + - '</span>' + + view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths + { date: this.start, type: 'week', forceOff: this.colCnt > 1 }, + htmlEscape(weekText) // inner HTML + ) + '</th>'; } else { @@ -12800,7 +13085,7 @@ var agendaDayGridMethods = { return '' + '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' + '<span>' + // needed for matchCellWidths - (view.opt('allDayHtml') || htmlEscape(view.opt('allDayText'))) + + view.getAllDayHtml() + '</span>' + '</td>'; }, @@ -12834,7 +13119,6 @@ fcViews.agenda = { 'class': AgendaView, defaults: { allDaySlot: true, - allDayText: 'all-day', slotDuration: '00:30:00', minTime: '00:00:00', maxTime: '24:00:00', @@ -12853,5 +13137,293 @@ fcViews.agendaWeek = { }; ;; -return FC; // export for Node/CommonJS +/* +Responsible for the scroller, and forwarding event-related actions into the "grid" +*/ +var ListView = View.extend({ + + grid: null, + scroller: null, + + initialize: function() { + this.grid = new ListViewGrid(this); + this.scroller = new Scroller({ + overflowX: 'hidden', + overflowY: 'auto' + }); + }, + + setRange: function(range) { + View.prototype.setRange.call(this, range); // super + + this.grid.setRange(range); // needs to process range-related options + }, + + renderSkeleton: function() { + this.el.addClass( + 'fc-list-view ' + + this.widgetContentClass + ); + + this.scroller.render(); + this.scroller.el.appendTo(this.el); + + this.grid.setElement(this.scroller.scrollEl); + }, + + unrenderSkeleton: function() { + this.scroller.destroy(); // will remove the Grid too + }, + + setHeight: function(totalHeight, isAuto) { + this.scroller.setHeight(this.computeScrollerHeight(totalHeight)); + }, + + computeScrollerHeight: function(totalHeight) { + return totalHeight - + subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller + }, + + renderEvents: function(events) { + this.grid.renderEvents(events); + }, + + unrenderEvents: function() { + this.grid.unrenderEvents(); + }, + + isEventResizable: function(event) { + return false; + }, + + isEventDraggable: function(event) { + return false; + } + +}); + +/* +Responsible for event rendering and user-interaction. +Its "el" is the inner-content of the above view's scroller. +*/ +var ListViewGrid = Grid.extend({ + + segSelector: '.fc-list-item', // which elements accept event actions + hasDayInteractions: false, // no day selection or day clicking + + // slices by day + spanToSegs: function(span) { + var view = this.view; + var dayStart = view.start.clone(); + var dayEnd; + var seg; + var segs = []; + + while (dayStart < view.end) { + dayEnd = dayStart.clone().add(1, 'day'); + seg = intersectRanges(span, { + start: dayStart, + end: dayEnd + }); + if (seg) { + segs.push(seg); + } + dayStart = dayEnd; + } + + return segs; + }, + + // like "4:00am" + computeEventTimeFormat: function() { + return this.view.opt('mediumTimeFormat'); + }, + + // for events with a url, the whole <tr> should be clickable, + // but it's impossible to wrap with an <a> tag. simulate this. + handleSegClick: function(seg, ev) { + var url; + + Grid.prototype.handleSegClick.apply(this, arguments); // super. might prevent the default action + + // not clicking on or within an <a> with an href + if (!$(ev.target).closest('a[href]').length) { + url = seg.event.url; + if (url && !ev.isDefaultPrevented()) { // jsEvent not cancelled in handler + window.location.href = url; // simulate link click + } + } + }, + + // returns list of foreground segs that were actually rendered + renderFgSegs: function(segs) { + segs = this.renderFgSegEls(segs); // might filter away hidden events + + if (!segs.length) { + this.renderEmptyMessage(); + return segs; + } + else { + return this.renderSegList(segs); + } + }, + + renderEmptyMessage: function() { + this.el.html( + '<div class="fc-list-empty-wrap2">' + // TODO: try less wraps + '<div class="fc-list-empty-wrap1">' + + '<div class="fc-list-empty">' + + htmlEscape(this.view.opt('noEventsMessage')) + + '</div>' + + '</div>' + + '</div>' + ); + }, + + // render the event segments in the view. returns the mutated array. + renderSegList: function(segs) { + var tableEl = $('<table class="fc-list-table"><tbody/></table>'); + var tbodyEl = tableEl.find('tbody'); + var i, seg; + var dayDate; + + this.sortEventSegs(segs); + + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + + // append a day header + if (!dayDate || !seg.start.isSame(dayDate, 'day')) { + dayDate = seg.start.clone().stripTime(); + tbodyEl.append(this.dayHeaderHtml(dayDate)); + } + + tbodyEl.append(seg.el); // append event row + } + + this.el.empty().append(tableEl); + + return segs; // return the sorted list + }, + + // generates the HTML for the day headers that live amongst the event rows + dayHeaderHtml: function(dayDate) { + var view = this.view; + var mainFormat = view.opt('listDayFormat'); + var altFormat = view.opt('listDayAltFormat'); + + return '<tr class="fc-list-heading" data-date="' + dayDate.format('YYYY-MM-DD') + '">' + + '<td class="' + view.widgetHeaderClass + '" colspan="3">' + + (mainFormat ? + view.buildGotoAnchorHtml( + dayDate, + { 'class': 'fc-list-heading-main' }, + htmlEscape(dayDate.format(mainFormat)) // inner HTML + ) : + '') + + (altFormat ? + view.buildGotoAnchorHtml( + dayDate, + { 'class': 'fc-list-heading-alt' }, + htmlEscape(dayDate.format(altFormat)) // inner HTML + ) : + '') + + '</td>' + + '</tr>'; + }, + + // generates the HTML for a single event row + fgSegHtml: function(seg) { + var view = this.view; + var classes = [ 'fc-list-item' ].concat(this.getSegCustomClasses(seg)); + var bgColor = this.getSegBackgroundColor(seg); + var event = seg.event; + var url = event.url; + var timeHtml; + + if (!seg.start.hasTime()) { + if (this.displayEventTime) { + timeHtml = view.getAllDayHtml(); + } + } + else { + timeHtml = htmlEscape(this.getEventTimeText(event)); // might return empty + } + + if (url) { + classes.push('fc-has-url'); + } + + return '<tr class="' + classes.join(' ') + '">' + + (timeHtml ? + '<td class="fc-list-item-time ' + view.widgetContentClass + '">' + + timeHtml + + '</td>' : + '') + + '<td class="fc-list-item-marker ' + view.widgetContentClass + '">' + + '<span class="fc-event-dot"' + + (bgColor ? + ' style="background-color:' + bgColor + '"' : + '') + + '></span>' + + '</td>' + + '<td class="fc-list-item-title ' + view.widgetContentClass + '">' + + '<a' + (url ? ' href="' + htmlEscape(url) + '"' : '') + '>' + + htmlEscape(seg.event.title) + + '</a>' + + '</td>' + + '</tr>'; + } + +}); + +;; + +fcViews.list = { + 'class': ListView, + buttonTextKey: 'list', // what to lookup in locale files + defaults: { + buttonText: 'list', // text to display for English + listTime: true, // show the time column? + listDayFormat: 'LL', // like "January 1, 2016" + noEventsMessage: 'No events to display' + } +}; + +fcViews.listDay = { + type: 'list', + duration: { days: 1 }, + defaults: { + listDayFormat: 'dddd' // day-of-week is all we need. full date is probably in header + } +}; + +fcViews.listWeek = { + type: 'list', + duration: { weeks: 1 }, + defaults: { + listDayFormat: 'dddd', // day-of-week is more important + listDayAltFormat: 'LL' + } +}; + +fcViews.listMonth = { + type: 'list', + duration: { month: 1 }, + defaults: { + listDayAltFormat: 'dddd' // day-of-week is nice-to-have + } +}; + +fcViews.listYear = { + type: 'list', + duration: { year: 1 }, + defaults: { + listDayAltFormat: 'dddd' // day-of-week is nice-to-have + } +}; + +;; +
+return FC; // export for Node/CommonJS
});
\ No newline at end of file |