diff options
Diffstat (limited to 'library/fullcalendar/fullcalendar.js')
-rw-r--r-- | library/fullcalendar/fullcalendar.js | 1726 |
1 files changed, 1080 insertions, 646 deletions
diff --git a/library/fullcalendar/fullcalendar.js b/library/fullcalendar/fullcalendar.js index 4d17f353e..d6042cc17 100644 --- a/library/fullcalendar/fullcalendar.js +++ b/library/fullcalendar/fullcalendar.js @@ -1,5 +1,5 @@ /*! - * FullCalendar v2.5.0-beta + * FullCalendar v2.6.1 * Docs & License: http://fullcalendar.io/ * (c) 2015 Adam Shaw */ @@ -18,7 +18,10 @@ ;; -var FC = $.fullCalendar = { version: "2.5.0-beta" }; +var FC = $.fullCalendar = { + version: "2.6.1", + internalApiVersion: 3 +}; var fcViews = FC.views = {}; @@ -121,7 +124,7 @@ function massageOverrides(input) { ;; // exports -FC.intersectionToSeg = intersectionToSeg; +FC.intersectRanges = intersectRanges; FC.applyAll = applyAll; FC.debounce = debounce; FC.isInt = isInt; @@ -244,7 +247,7 @@ function undistributeHeight(els) { function matchCellWidths(els) { var maxInnerWidth = 0; - els.find('> *').each(function(i, innerEl) { + els.find('> span').each(function(i, innerEl) { var innerWidth = $(innerEl).outerWidth(); if (innerWidth > maxInnerWidth) { maxInnerWidth = innerWidth; @@ -553,10 +556,10 @@ function flexibleCompare(a, b) { ----------------------------------------------------------------------------------------------------------------------*/ -// Creates a basic segment with the intersection of the two ranges. Returns undefined if no intersection. +// Computes the intersection of the two ranges. Returns undefined if no intersection. // Expects all dates to be normalized to the same timezone beforehand. // TODO: move to date section? -function intersectionToSeg(subjectRange, constraintRange) { +function intersectRanges(subjectRange, constraintRange) { var subjectStart = subjectRange.start; var subjectEnd = subjectRange.end; var constraintStart = constraintRange.start; @@ -1580,6 +1583,8 @@ FC.formatRange = formatRange; // expose function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { + var unzonedDate1 = date1.clone().stripZone(); // for formatSimilarChunk + var unzonedDate2 = date2.clone().stripZone(); // " var chunkStr; // the rendering of the chunk var leftI; var leftStr = ''; @@ -1593,7 +1598,7 @@ function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { // Start at the leftmost side of the formatting string and continue until you hit a token // that is not the same between dates. for (leftI=0; leftI<chunks.length; leftI++) { - chunkStr = formatSimilarChunk(date1, date2, chunks[leftI]); + chunkStr = formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunks[leftI]); if (chunkStr === false) { break; } @@ -1602,7 +1607,7 @@ function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { // Similarly, start at the rightmost side of the formatting string and move left for (rightI=chunks.length-1; rightI>leftI; rightI--) { - chunkStr = formatSimilarChunk(date1, date2, chunks[rightI]); + chunkStr = formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunks[rightI]); if (chunkStr === false) { break; } @@ -1649,7 +1654,7 @@ var similarUnitMap = { // Given a formatting chunk, and given that both dates are similar in the regard the // formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`. -function formatSimilarChunk(date1, date2, chunk) { +function formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunk) { var token; var unit; @@ -1658,8 +1663,10 @@ function formatSimilarChunk(date1, date2, chunk) { } else if ((token = chunk.token)) { unit = similarUnitMap[token.charAt(0)]; + // are the dates the same for this unit of measurement? - if (unit && date1.isSame(date2, unit)) { + // use the unzoned dates for this calculation because unreliable when near DST (bug #2396) + if (unit && unzonedDate1.isSame(unzonedDate2, unit)) { return oldMomentFormat(date1, token); // would be the same if we used `date2` // BTW, don't support custom tokens } @@ -2008,6 +2015,7 @@ options: var CoordCache = FC.CoordCache = Class.extend({ els: null, // jQuery set (assumed to be siblings) + forcedOffsetParentEl: null, // options can override the natural offsetParent origin: null, // {left,top} position of offsetParent of els boundingRect: null, // constrain cordinates to this rectangle. {left,right,top,bottom} or null isHorizontal: false, // whether to query for left/right/width @@ -2024,13 +2032,16 @@ var CoordCache = FC.CoordCache = Class.extend({ this.els = $(options.els); this.isHorizontal = options.isHorizontal; this.isVertical = options.isVertical; + this.forcedOffsetParentEl = options.offsetParent ? $(options.offsetParent) : null; }, // Queries the els for coordinates and stores them. // Call this method before using and of the get* methods below. build: function() { - this.origin = this.els.eq(0).offsetParent().offset(); + var offsetParentEl = this.forcedOffsetParentEl || this.els.eq(0).offsetParent(); + + this.origin = offsetParentEl.offset(); this.boundingRect = this.queryBoundingRect(); if (this.isHorizontal) { @@ -2053,6 +2064,14 @@ var CoordCache = FC.CoordCache = Class.extend({ }, + // When called, if coord caches aren't built, builds them + ensureBuilt: function() { + if (!this.origin) { + this.build(); + } + }, + + // 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() { @@ -2105,6 +2124,8 @@ 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. getHorizontalIndex: function(leftOffset) { + this.ensureBuilt(); + var boundingRect = this.boundingRect; var lefts = this.lefts; var rights = this.rights; @@ -2124,6 +2145,8 @@ var CoordCache = FC.CoordCache = Class.extend({ // 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. getVerticalIndex: function(topOffset) { + this.ensureBuilt(); + var boundingRect = this.boundingRect; var tops = this.tops; var bottoms = this.bottoms; @@ -2142,12 +2165,14 @@ var CoordCache = FC.CoordCache = Class.extend({ // Gets the left offset (from document left) of the element at the given index getLeftOffset: function(leftIndex) { + this.ensureBuilt(); return this.lefts[leftIndex]; }, // Gets the left position (from offsetParent left) of the element at the given index getLeftPosition: function(leftIndex) { + this.ensureBuilt(); return this.lefts[leftIndex] - this.origin.left; }, @@ -2155,6 +2180,7 @@ var CoordCache = FC.CoordCache = Class.extend({ // Gets the right offset (from document left) of the element at the given index. // This value is NOT relative to the document's right edge, like the CSS concept of "right" would be. getRightOffset: function(leftIndex) { + this.ensureBuilt(); return this.rights[leftIndex]; }, @@ -2162,30 +2188,35 @@ var CoordCache = FC.CoordCache = Class.extend({ // Gets the right position (from offsetParent left) of the element at the given index. // This value is NOT relative to the offsetParent's right edge, like the CSS concept of "right" would be. getRightPosition: function(leftIndex) { + this.ensureBuilt(); return this.rights[leftIndex] - this.origin.left; }, // Gets the width of the element at the given index getWidth: function(leftIndex) { + this.ensureBuilt(); return this.rights[leftIndex] - this.lefts[leftIndex]; }, // Gets the top offset (from document top) of the element at the given index getTopOffset: function(topIndex) { + this.ensureBuilt(); return this.tops[topIndex]; }, // Gets the top position (from offsetParent top) of the element at the given position getTopPosition: function(topIndex) { + this.ensureBuilt(); return this.tops[topIndex] - this.origin.top; }, // Gets the bottom offset (from the document top) of the element at the given index. // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be. getBottomOffset: function(topIndex) { + this.ensureBuilt(); return this.bottoms[topIndex]; }, @@ -2193,12 +2224,14 @@ var CoordCache = FC.CoordCache = Class.extend({ // Gets the bottom position (from the offsetParent top) of the element at the given index. // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be. getBottomPosition: function(topIndex) { + this.ensureBuilt(); return this.bottoms[topIndex] - this.origin.top; }, // Gets the height of the element at the given index getHeight: function(topIndex) { + this.ensureBuilt(); return this.bottoms[topIndex] - this.tops[topIndex]; } @@ -3102,8 +3135,9 @@ var Grid = FC.Grid = Class.extend({ }, - // Converts a range with an inclusive `start` and an exclusive `end` into an array of segment objects - rangeToSegs: function(range) { + // Converts a span (has unzoned start/end and any other grid-specific location information) + // into an array of segments (pieces of events whose format is decided by the grid). + spanToSegs: function(span) { // subclasses must implement }, @@ -3273,7 +3307,7 @@ var Grid = FC.Grid = Class.extend({ listenStop: function(ev) { if (dayClickHit) { view.triggerDayClick( - _this.getHitSpan(dayClickHit).start, + _this.getHitSpan(dayClickHit), _this.getHitEl(dayClickHit), ev ); @@ -3295,24 +3329,24 @@ var Grid = FC.Grid = Class.extend({ // TODO: should probably move this to Grid.events, like we did event dragging / resizing - // Renders a mock event over the given range - renderRangeHelper: function(range, sourceSeg) { - var fakeEvent = this.fabricateHelperEvent(range, sourceSeg); + // Renders a mock event at the given event location, which contains zoned start/end properties. + renderEventLocationHelper: function(eventLocation, sourceSeg) { + var fakeEvent = this.fabricateHelperEvent(eventLocation, sourceSeg); this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering }, - // Builds a fake event given a date range it should cover, and a segment is should be inspired from. + // Builds a fake event given zoned event date properties and a segment is should be inspired from. // The range's end can be null, in which case the mock event that is rendered will have a null end time. // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging. - fabricateHelperEvent: function(range, sourceSeg) { + fabricateHelperEvent: function(eventLocation, sourceSeg) { var fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible - fakeEvent.start = range.start.clone(); - fakeEvent.end = range.end ? range.end.clone() : null; - fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventRange - this.view.calendar.normalizeEventRange(fakeEvent); + fakeEvent.start = eventLocation.start.clone(); + fakeEvent.end = eventLocation.end ? eventLocation.end.clone() : null; + fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventDates + this.view.calendar.normalizeEventDates(fakeEvent); // this extra className will be useful for differentiating real events from mock events in CSS fakeEvent.className = (fakeEvent.className || []).concat('fc-helper'); @@ -3326,8 +3360,8 @@ var Grid = FC.Grid = Class.extend({ }, - // Renders a mock event - renderHelper: function(event, sourceSeg) { + // Renders a mock event. Given zoned event date properties. + renderHelper: function(eventLocation, sourceSeg) { // subclasses must implement }, @@ -3343,8 +3377,9 @@ var Grid = FC.Grid = Class.extend({ // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses. - renderSelection: function(range) { - this.renderHighlight(this.selectionRangeToSegs(range)); + // Given a span (unzoned start/end and other misc data) + renderSelection: function(span) { + this.renderHighlight(span); }, @@ -3359,22 +3394,24 @@ var Grid = FC.Grid = Class.extend({ // Will return false if the selection is invalid and this should be indicated to the user. // Will return null/undefined if a selection invalid but no error should be reported. computeSelection: function(span0, span1) { - var dates = [ span0.start, span0.end, span1.start, span1.end ]; - var combinedSpan; + var span = this.computeSelectionSpan(span0, span1); - dates.sort(compareNumbers); // sorts chronologically. works with Moments - combinedSpan = { start: dates[0].clone(), end: dates[3].clone() }; - - if (!this.view.calendar.isSelectionRangeAllowed(combinedSpan)) { + if (span && !this.view.calendar.isSelectionSpanAllowed(span)) { return false; } - return combinedSpan; + return span; }, - selectionRangeToSegs: function(range) { - return this.rangeToSegs(range); + // Given two spans, must return the combination of the two. + // TODO: do this separation of concerns (combining VS validation) for event dnd/resize too. + computeSelectionSpan: function(span0, span1) { + var dates = [ span0.start, span0.end, span1.start, span1.end ]; + + dates.sort(compareNumbers); // sorts chronologically. works with Moments + + return { start: dates[0].clone(), end: dates[3].clone() }; }, @@ -3382,9 +3419,9 @@ var Grid = FC.Grid = Class.extend({ ------------------------------------------------------------------------------------------------------------------*/ - // Renders an emphasis on the given date range. Given an array of segments. - renderHighlight: function(segs) { - this.renderFill('highlight', segs); + // Renders an emphasis on the given date range. Given a span (unzoned start/end and other misc data) + renderHighlight: function(span) { + this.renderFill('highlight', this.spanToSegs(span)); }, @@ -3400,10 +3437,40 @@ var Grid = FC.Grid = Class.extend({ }, - /* Fill System (highlight, background events, business hours) + /* Business Hours + ------------------------------------------------------------------------------------------------------------------*/ + + + renderBusinessHours: function() { + }, + + + unrenderBusinessHours: function() { + }, + + + /* Now Indicator ------------------------------------------------------------------------------------------------------------------*/ + getNowIndicatorUnit: function() { + }, + + + renderNowIndicator: function(date) { + }, + + + unrenderNowIndicator: function() { + }, + + + /* Fill System (highlight, background events, business hours) + -------------------------------------------------------------------------------------------------------------------- + TODO: remove this system. like we did in TimeGrid + */ + + // Renders a set of rectangles over the given segments of time. // MUST RETURN a subset of segs, the segs that were actually rendered. // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement @@ -3496,7 +3563,7 @@ var Grid = FC.Grid = Class.extend({ // Computes HTML classNames for a single-day element getDayClasses: function(date) { var view = this.view; - var today = view.calendar.getNow().stripTime(); + var today = view.calendar.getNow(); var classes = [ 'fc-' + dayIDs[date.day()] ]; if ( @@ -3535,33 +3602,39 @@ Grid.mixin({ isDraggingSeg: false, // is a segment being dragged? boolean isResizingSeg: false, // is a segment being resized? boolean isDraggingExternal: false, // jqui-dragging an external element? boolean - segs: null, // the event segments currently rendered in the grid + segs: null, // the *event* segments currently rendered in the grid. TODO: rename to `eventSegs` // Renders the given events onto the grid renderEvents: function(events) { + var bgEvents = []; + var fgEvents = []; + var i; + + for (i = 0; i < events.length; i++) { + (isBgEvent(events[i]) ? bgEvents : fgEvents).push(events[i]); + } + + this.segs = [].concat( // record all segs + this.renderBgEvents(bgEvents), + this.renderFgEvents(fgEvents) + ); + }, + + + renderBgEvents: function(events) { var segs = this.eventsToSegs(events); - var bgSegs = []; - var fgSegs = []; - var i, seg; - for (i = 0; i < segs.length; i++) { - seg = segs[i]; + // renderBgSegs might return a subset of segs, segs that were actually rendered + return this.renderBgSegs(segs) || segs; + }, - if (isBgEvent(seg.event)) { - bgSegs.push(seg); - } - else { - fgSegs.push(seg); - } - } - // Render each different type of segment. - // Each function may return a subset of the segs, segs that were actually rendered. - bgSegs = this.renderBgSegs(bgSegs) || bgSegs; - fgSegs = this.renderFgSegs(fgSegs) || fgSegs; + renderFgEvents: function(events) { + var segs = this.eventsToSegs(events); - this.segs = bgSegs.concat(fgSegs); + // renderFgSegs might return a subset of segs, segs that were actually rendered + return this.renderFgSegs(segs) || segs; }, @@ -3676,20 +3749,9 @@ Grid.mixin({ // Generates a semicolon-separated CSS string to be used for the default rendering of a background event. // Called by the fill system. - // TODO: consolidate with getEventSkinCss? bgEventSegCss: function(seg) { - var view = this.view; - var event = seg.event; - var source = event.source || {}; - return { - 'background-color': - event.backgroundColor || - event.color || - source.backgroundColor || - source.color || - view.opt('eventBackgroundColor') || - view.opt('eventColor') + 'background-color': this.getSegSkinCss(seg)['background-color'] }; }, @@ -3778,7 +3840,7 @@ Grid.mixin({ var calendar = view.calendar; var el = seg.el; var event = seg.event; - var dropLocation; // a "span" (start/end possibly with additional properties) + var dropLocation; // zoned event date properties // A clone of the original element that will move with the mouse var mouseFollower = new MouseFollower(seg.el, { @@ -3818,7 +3880,7 @@ Grid.mixin({ event ); - if (dropLocation && !calendar.isEventRangeAllowed(dropLocation, event)) { + if (dropLocation && !calendar.isEventSpanAllowed(_this.eventToSpan(dropLocation), event)) { disableCursor(); dropLocation = null; } @@ -3878,15 +3940,16 @@ Grid.mixin({ }, - // Given the spans an event drag began, and the span event was dropped, calculates the new start/end/allDay + // Given the spans an event drag began, and the span event was dropped, calculates the new zoned start/end/allDay // values for the event. Subclasses may override and set additional properties to be used by renderDrag. // A falsy returned value indicates an invalid drop. + // DOES NOT consider overlap/constraint. computeEventDrop: function(startSpan, endSpan, event) { var calendar = this.view.calendar; var dragStart = startSpan.start; var dragEnd = endSpan.start; var delta; - var dropLocation; + var dropLocation; // zoned event date properties if (dragStart.hasTime() === dragEnd.hasTime()) { delta = this.diffDates(dragEnd, dragStart); @@ -3897,9 +3960,9 @@ Grid.mixin({ dropLocation = { start: event.start.clone(), end: calendar.getEventEnd(event), // will be an ambig day - allDay: false // for normalizeEventRangeTimes + allDay: false // for normalizeEventTimes }; - calendar.normalizeEventRangeTimes(dropLocation); + calendar.normalizeEventTimes(dropLocation); } // othewise, work off existing values else { @@ -3970,6 +4033,7 @@ Grid.mixin({ // Called when a jQuery UI drag starts and it needs to be monitored for dropping listenToExternalDrag: function(el, ev, ui) { var _this = this; + var calendar = this.view.calendar; var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create var dropLocation; // a null value signals an unsuccessful drag @@ -3983,22 +4047,27 @@ Grid.mixin({ hit.component.getHitSpan(hit), // since we are querying the parent view, might not belong to this grid meta ); + + if ( // invalid hit? + dropLocation && + !calendar.isExternalSpanAllowed(_this.eventToSpan(dropLocation), dropLocation, meta.eventProps) + ) { + disableCursor(); + dropLocation = null; + } + if (dropLocation) { _this.renderDrag(dropLocation); // called without a seg parameter } - else { // invalid hit - disableCursor(); - } }, hitOut: function() { dropLocation = null; // signal unsuccessful - _this.unrenderDrag(); + }, + hitDone: function() { // Called after a hitOut OR before a dragStop enableCursor(); + _this.unrenderDrag(); }, dragStop: function() { - _this.unrenderDrag(); - enableCursor(); - if (dropLocation) { // element was dropped on a valid hit _this.view.reportExternalDrop(meta, dropLocation, el, ev, ui); } @@ -4013,11 +4082,13 @@ Grid.mixin({ // Given a hit to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object), - // returns start/end dates for the event that would result from the hypothetical drop. end might be null. + // returns the zoned start/end dates for the event that would result from the hypothetical drop. end might be null. // Returning a null value signals an invalid drop hit. + // DOES NOT consider overlap/constraint. computeExternalDrop: function(span, meta) { + var calendar = this.view.calendar; var dropLocation = { - start: span.start, + start: calendar.applyTimezone(span.start), // simulate a zoned event start date end: null }; @@ -4030,10 +4101,6 @@ Grid.mixin({ dropLocation.end = dropLocation.start.clone().add(meta.duration); } - if (!this.view.calendar.isExternalDropRangeAllowed(dropLocation, meta.eventProps)) { - return null; - } - return dropLocation; }, @@ -4071,7 +4138,7 @@ Grid.mixin({ var el = seg.el; var event = seg.event; var eventEnd = calendar.getEventEnd(event); - var resizeLocation; // falsy if invalid resize + var resizeLocation; // zoned event date properties. falsy if invalid resize // Tracks mouse movement over the *grid's* coordinate map var dragListener = new HitDragListener(this, { @@ -4091,7 +4158,7 @@ Grid.mixin({ _this.computeEventEndResize(origHitSpan, hitSpan, event); if (resizeLocation) { - if (!calendar.isEventRangeAllowed(resizeLocation, event)) { + if (!calendar.isEventSpanAllowed(_this.eventToSpan(resizeLocation), event)) { disableCursor(); resizeLocation = null; } @@ -4153,31 +4220,32 @@ Grid.mixin({ }, - // Returns new date-information for an event segment being resized from its start OR end - // `type` is either 'start' or 'end' + // Returns new zoned date information for an event segment being resized from its start OR end + // `type` is either 'start' or 'end'. + // DOES NOT consider overlap/constraint. computeEventResize: function(type, startSpan, endSpan, event) { var calendar = this.view.calendar; var delta = this.diffDates(endSpan[type], startSpan[type]); - var range; + var resizeLocation; // zoned event date properties var defaultDuration; // build original values to work from, guaranteeing a start and end - range = { + resizeLocation = { start: event.start.clone(), end: calendar.getEventEnd(event), allDay: event.allDay }; // if an all-day event was in a timed area and was resized to a time, adjust start/end to have times - if (range.allDay && durationHasTime(delta)) { - range.allDay = false; - calendar.normalizeEventRangeTimes(range); + if (resizeLocation.allDay && durationHasTime(delta)) { + resizeLocation.allDay = false; + calendar.normalizeEventTimes(resizeLocation); } - range[type].add(delta); // apply delta to start or end + resizeLocation[type].add(delta); // apply delta to start or end // if the event was compressed too small, find a new reasonable duration for it - if (!range.start.isBefore(range.end)) { + if (!resizeLocation.start.isBefore(resizeLocation.end)) { defaultDuration = this.minResizeDuration || // TODO: hack @@ -4186,14 +4254,14 @@ Grid.mixin({ calendar.defaultTimedEventDuration); if (type == 'start') { // resizing the start? - range.start = range.end.clone().subtract(defaultDuration); + resizeLocation.start = resizeLocation.end.clone().subtract(defaultDuration); } else { // resizing the end? - range.end = range.start.clone().add(defaultDuration); + resizeLocation.end = resizeLocation.start.clone().add(defaultDuration); } } - return range; + return resizeLocation; }, @@ -4266,7 +4334,8 @@ Grid.mixin({ // Utility for generating event skin-related CSS properties - getEventSkinCss: function(event) { + getSegSkinCss: function(seg) { + var event = seg.event; var view = this.view; var source = event.source || {}; var eventColor = event.color; @@ -4296,116 +4365,160 @@ Grid.mixin({ }, - /* Converting events -> ranges -> segs + /* Converting events -> eventRange -> eventSpan -> eventSegs ------------------------------------------------------------------------------------------------------------------*/ + // Generates an array of segments for the given single event + // Can accept an event "location" as well (which only has start/end and no allDay) + eventToSegs: function(event) { + return this.eventsToSegs([ event ]); + }, + + + eventToSpan: function(event) { + return this.eventToSpans(event)[0]; + }, + + + // Generates spans (always unzoned) for the given event. + // Does not do any inverting for inverse-background events. + // Can accept an event "location" as well (which only has start/end and no allDay) + eventToSpans: function(event) { + var range = this.eventToRange(event); + return this.eventRangeToSpans(range, event); + }, + + + // Converts an array of event objects into an array of event segment objects. - // A custom `rangeToSegsFunc` may be given for arbitrarily slicing up events. + // A custom `segSliceFunc` may be given for arbitrarily slicing up events. // Doesn't guarantee an order for the resulting array. - eventsToSegs: function(events, rangeToSegsFunc) { - var eventRanges = this.eventsToRanges(events); + eventsToSegs: function(allEvents, segSliceFunc) { + var _this = this; + var eventsById = groupEventsById(allEvents); + var segs = []; + + $.each(eventsById, function(id, events) { + var ranges = []; + var i; + + for (i = 0; i < events.length; i++) { + ranges.push(_this.eventToRange(events[i])); + } + + // inverse-background events (utilize only the first event in calculations) + if (isInverseBgEvent(events[0])) { + ranges = _this.invertRanges(ranges); + + for (i = 0; i < ranges.length; i++) { + segs.push.apply(segs, // append to + _this.eventRangeToSegs(ranges[i], events[0], segSliceFunc)); + } + } + // normal event ranges + else { + for (i = 0; i < ranges.length; i++) { + segs.push.apply(segs, // append to + _this.eventRangeToSegs(ranges[i], events[i], segSliceFunc)); + } + } + }); + + return segs; + }, + + + // 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: ( + event.end ? + event.end.clone() : + // derive the end from the start and allDay. compute allDay if necessary + this.view.calendar.getDefaultEventEnd( + event.allDay != null ? + event.allDay : + !event.start.hasTime(), + event.start + ) + ).stripZone() + }; + }, + + + // Given an event's range (unzoned start/end), and the event itself, + // slice into segments (using the segSliceFunc function if specified) + eventRangeToSegs: function(range, event, segSliceFunc) { + var spans = this.eventRangeToSpans(range, event); var segs = []; var i; - for (i = 0; i < eventRanges.length; i++) { - segs.push.apply( - segs, - this.eventRangeToSegs(eventRanges[i], rangeToSegsFunc) - ); + for (i = 0; i < spans.length; i++) { + segs.push.apply(segs, // append to + this.eventSpanToSegs(spans[i], event, segSliceFunc)); } return segs; }, - // Converts an array of events into an array of "range" objects. - // A "range" object is a plain object with start/end properties denoting the time it covers. Also an event property. - // For "normal" events, this will be identical to the event's start/end, but for "inverse-background" events, - // will create an array of ranges that span the time *not* covered by the given event. - // Doesn't guarantee an order for the resulting array. - eventsToRanges: function(events) { - var _this = this; - var eventsById = groupEventsById(events); - var ranges = []; - - // group by ID so that related inverse-background events can be rendered together - $.each(eventsById, function(id, eventGroup) { - if (eventGroup.length) { - ranges.push.apply( - ranges, - isInverseBgEvent(eventGroup[0]) ? - _this.eventsToInverseRanges(eventGroup) : - _this.eventsToNormalRanges(eventGroup) - ); - } - }); - - return ranges; + // Given an event's unzoned date range, return an array of "span" objects. + // Subclasses can override. + eventRangeToSpans: function(range, event) { + return [ $.extend({}, range) ]; // copy into a single-item array }, - // Converts an array of "normal" events (not inverted rendering) into a parallel array of ranges - eventsToNormalRanges: function(events) { - var calendar = this.view.calendar; - var ranges = []; - var i, event; - var eventStart, eventEnd; + // Given an event's span (unzoned start/end and other misc data), and the event itself, + // slices into segments and attaches event-derived properties to them. + eventSpanToSegs: function(span, event, segSliceFunc) { + var segs = segSliceFunc ? segSliceFunc(span) : this.spanToSegs(span); + var i, seg; - for (i = 0; i < events.length; i++) { - event = events[i]; - - // make copies and normalize by stripping timezone - eventStart = event.start.clone().stripZone(); - eventEnd = calendar.getEventEnd(event).stripZone(); - - ranges.push({ - event: event, - start: eventStart, - end: eventEnd, - eventStartMS: +eventStart, - eventDurationMS: eventEnd - eventStart - }); + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + seg.event = event; + seg.eventStartMS = +span.start; // TODO: not the best name after making spans unzoned + seg.eventDurationMS = span.end - span.start; } - return ranges; + return segs; }, - // Converts an array of events, with inverse-background rendering, into an array of range objects. - // The range objects will cover all the time NOT covered by the events. - eventsToInverseRanges: function(events) { + // Produces a new array of range objects that will cover all the time NOT covered by the given ranges. + // SIDE EFFECT: will mutate the given array and will use its date references. + invertRanges: function(ranges) { var view = this.view; - var viewStart = view.start.clone().stripZone(); // normalize timezone - var viewEnd = view.end.clone().stripZone(); // normalize timezone - var normalRanges = this.eventsToNormalRanges(events); // will give us normalized dates we can use w/o copies + var viewStart = view.start.clone(); // need a copy + var viewEnd = view.end.clone(); // need a copy var inverseRanges = []; - var event0 = events[0]; // assign this to each range's `.event` var start = viewStart; // the end of the previous range. the start of the new range - var i, normalRange; + var i, range; // ranges need to be in order. required for our date-walking algorithm - normalRanges.sort(compareNormalRanges); + ranges.sort(compareRanges); - for (i = 0; i < normalRanges.length; i++) { - normalRange = normalRanges[i]; + for (i = 0; i < ranges.length; i++) { + range = ranges[i]; // add the span of time before the event (if there is any) - if (normalRange.start > start) { // compare millisecond time (skip any ambig logic) + if (range.start > start) { // compare millisecond time (skip any ambig logic) inverseRanges.push({ - event: event0, start: start, - end: normalRange.start + end: range.start }); } - start = normalRange.end; + start = range.end; } // add the span of time after the last event (if there is any) if (start < viewEnd) { // compare millisecond time (skip any ambig logic) inverseRanges.push({ - event: event0, start: start, end: viewEnd }); @@ -4415,40 +4528,13 @@ Grid.mixin({ }, - // Slices the given event range into one or more segment objects. - // A `rangeToSegsFunc` custom slicing function can be given. - eventRangeToSegs: function(eventRange, rangeToSegsFunc) { - var segs; - var i, seg; - - eventRange = this.view.calendar.ensureVisibleEventRange(eventRange); - - if (rangeToSegsFunc) { - segs = rangeToSegsFunc(eventRange); - } - else { - segs = this.rangeToSegs(eventRange); // defined by the subclass - } - - for (i = 0; i < segs.length; i++) { - seg = segs[i]; - seg.event = eventRange.event; - seg.eventStartMS = eventRange.eventStartMS; - seg.eventDurationMS = eventRange.eventDurationMS; - } - - return segs; - }, - - - sortSegs: function(segs) { - segs.sort(proxy(this, 'compareSegs')); + sortEventSegs: function(segs) { + segs.sort(proxy(this, 'compareEventSegs')); }, // A cmp function for determining which segments should take visual priority - // DOES NOT WORK ON INVERTED BACKGROUND EVENTS because they have no eventStartMS/eventDurationMS - compareSegs: function(seg1, seg2) { + compareEventSegs: function(seg1, seg2) { return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1) @@ -4466,6 +4552,7 @@ function isBgEvent(event) { // returns true if background OR inverse-background var rendering = getEventRendering(event); return rendering === 'background' || rendering === 'inverse-background'; } +FC.isBgEvent = isBgEvent; // export function isInverseBgEvent(event) { @@ -4492,8 +4579,8 @@ function groupEventsById(events) { // A cmp function for determining which non-inverted "ranges" (see above) happen earlier -function compareNormalRanges(range1, range2) { - return range1.eventStartMS - range2.eventStartMS; // earlier ranges go first +function compareRanges(range1, range2) { + return range1.start - range2.start; // earlier ranges go first } @@ -4844,13 +4931,23 @@ var DayTableMixin = FC.DayTableMixin = { }, - renderHeadDateCellHtml: function(date, colspan) { + // TODO: when internalApiVersion, accept an object for HTML attributes + // (colspan should be no different) + renderHeadDateCellHtml: function(date, colspan, otherAttrs) { var view = this.view; return '' + '<th class="fc-day-header ' + view.widgetHeaderClass + ' fc-' + dayIDs[date.day()] + '"' + - (colspan > 1 ? ' colspan="' + colspan + '"' : '') + - '>' + + (this.rowCnt == 1 ? + ' data-date="' + date.format('YYYY-MM-DD') + '"' : + '') + + (colspan > 1 ? + ' colspan="' + colspan + '"' : + '') + + (otherAttrs ? + ' ' + otherAttrs : + '') + + '>' + htmlEscape(date.format(this.colHeadFormat)) + '</th>'; }, @@ -4888,7 +4985,7 @@ var DayTableMixin = FC.DayTableMixin = { }, - renderBgCellHtml: function(date) { + renderBgCellHtml: function(date, otherAttrs) { var view = this.view; var classes = this.getDayClasses(date); @@ -4896,6 +4993,9 @@ var DayTableMixin = FC.DayTableMixin = { return '<td class="' + classes.join(' ') + '"' + ' data-date="' + date.format('YYYY-MM-DD') + '"' + // if date has a time, won't format it + (otherAttrs ? + ' ' + otherAttrs : + '') + '></td>'; }, @@ -4909,6 +5009,11 @@ var DayTableMixin = FC.DayTableMixin = { }, + // TODO: a generic method for dealing with <tr>, RTL, intro + // when increment internalApiVersion + // wrapTr (scheduler) + + /* Utils ------------------------------------------------------------------------------------------------------------------*/ @@ -5110,9 +5215,9 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { }, - // Slices up a date range by row into an array of segments - rangeToSegs: function(range) { - var segs = this.sliceRangeByRow(range); + // Slices up the given span (unzoned start/end with other misc data) into an array of segments + spanToSegs: function(span) { + var segs = this.sliceRangeByRow(span); var i, seg; for (i = 0; i < segs.length; i++) { @@ -5197,16 +5302,16 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { // Renders a visual indication of an event or external element being dragged. - // The dropLocation's end can be null. seg can be null. See Grid::renderDrag for more info. - renderDrag: function(dropLocation, seg) { + // `eventLocation` has zoned start and end (optional) + renderDrag: function(eventLocation, seg) { // always render a highlight underneath - this.renderHighlight(this.eventRangeToSegs(dropLocation)); + 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) { - this.renderRangeHelper(dropLocation, seg); + this.renderEventLocationHelper(eventLocation, seg); this.applyDragOpacity(this.helperEls); return true; // a helper has been rendered @@ -5226,9 +5331,9 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { // Renders a visual indication of an event being resized - renderEventResize: function(range, seg) { - this.renderHighlight(this.eventRangeToSegs(range)); - this.renderRangeHelper(range, seg); + renderEventResize: function(eventLocation, seg) { + this.renderHighlight(this.eventToSpan(eventLocation)); + this.renderEventLocationHelper(eventLocation, seg); }, @@ -5246,7 +5351,7 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null. renderHelper: function(event, sourceSeg) { var helperNodes = []; - var segs = this.eventsToSegs([ event ]); + var segs = this.eventToSegs(event); var rowStructs; segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered @@ -5453,7 +5558,7 @@ DayGrid.mixin({ var isResizableFromEnd = !disableResizing && event.allDay && seg.isEnd && view.isEventResizableFromEnd(event); var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); - var skinCss = cssToStr(this.getEventSkinCss(event)); + var skinCss = cssToStr(this.getSegSkinCss(seg)); var timeHtml = ''; var timeText; var titleHtml; @@ -5600,7 +5705,7 @@ DayGrid.mixin({ // Give preference to elements with certain criteria, so they have // a chance to be closer to the top. - this.sortSegs(segs); + this.sortEventSegs(segs); for (i = 0; i < segs.length; i++) { seg = segs[i]; @@ -5989,7 +6094,7 @@ DayGrid.mixin({ return seg.event; }); - var dayStart = dayDate.clone().stripTime(); + var dayStart = dayDate.clone(); var dayEnd = dayStart.clone().add(1, 'days'); var dayRange = { start: dayStart, end: dayEnd }; @@ -5997,13 +6102,13 @@ DayGrid.mixin({ segs = this.eventsToSegs( events, function(range) { - var seg = intersectionToSeg(range, dayRange); // undefind if no intersection + var seg = intersectRanges(range, dayRange); // undefind if no intersection return seg ? [ seg ] : []; // must return an array of segments } ); // force an order because eventsToSegs doesn't guarantee one - this.sortSegs(segs); + this.sortEventSegs(segs); return segs; }, @@ -6061,13 +6166,11 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { colEls: null, // cells elements in the day-row background slatEls: null, // elements running horizontally across all columns - helperEl: null, // cell skeleton element for rendering the mock event "helper" + nowIndicatorEls: null, colCoordCache: null, slatCoordCache: null, - businessHourSegs: null, - constructor: function() { Grid.apply(this, arguments); // call the super-constructor @@ -6091,12 +6194,8 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { els: this.slatEls, isVertical: true }); - }, - - renderBusinessHours: function() { - var events = this.view.calendar.getBusinessHoursEvents(); - this.businessHourSegs = this.renderFill('businessHours', this.eventsToSegs(events), 'bgevent'); + this.renderContentSkeleton(); }, @@ -6128,7 +6227,7 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { // Calculate the time for each slot while (slotTime < this.maxTime) { - slotDate = this.start.clone().time(slotTime); // after .time() will be in UTC. but that's good, avoids DST issues + slotDate = this.start.clone().time(slotTime); isLabeled = isInt(divideDurationByDuration(slotTime, this.labelInterval)); axisHtml = @@ -6142,7 +6241,9 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { '</td>'; html += - '<tr ' + (isLabeled ? '' : 'class="fc-minor"') + '>' + + '<tr data-time="' + slotDate.format('HH:mm:ss') + '"' + + (isLabeled ? '' : ' class="fc-minor"') + + '>' + (!isRTL ? axisHtml : '') + '<td class="' + view.widgetContentClass + '"/>' + (isRTL ? axisHtml : '') + @@ -6274,9 +6375,8 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { getHitSpan: function(hit) { - var date = this.getCellDate(0, hit.col); // row=0 + var start = this.getCellDate(0, hit.col); // row=0 var time = this.computeSnapTime(hit.snap); // pass in the snap-index - var start = this.view.calendar.rezoneDate(date); // gives it a 00:00 time var end; start.time(time); @@ -6306,9 +6406,9 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { }, - // Slices up a date range by column into an array of segments - rangeToSegs: function(range) { - var segs = this.sliceRangeByTimes(range); + // Slices up the given span (unzoned start/end with other misc data) into an array of segments + spanToSegs: function(span) { + var segs = this.sliceRangeByTimes(span); var i; for (i = 0; i < segs.length; i++) { @@ -6331,19 +6431,13 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { var dayDate; var dayRange; - // normalize :( - range = { - start: range.start.clone().stripZone(), - end: range.end.clone().stripZone() - }; - for (dayIndex = 0; dayIndex < this.daysPerRow; dayIndex++) { dayDate = this.dayDates[dayIndex].clone(); // TODO: better API for this? dayRange = { start: dayDate.clone().time(this.minTime), end: dayDate.clone().time(this.maxTime) }; - seg = intersectionToSeg(range, dayRange); // both will be ambig timezone + seg = intersectRanges(range, dayRange); // both will be ambig timezone if (seg) { seg.dayIndex = dayIndex; segs.push(seg); @@ -6362,7 +6456,9 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { this.slatCoordCache.build(); if (isResize) { - this.updateSegVerticals(); + this.updateSegVerticals( + [].concat(this.fgSegs || [], this.bgSegs || [], this.businessSegs || []) + ); } }, @@ -6372,7 +6468,7 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { computeDateTop: function(date, startOfDayDate) { return this.computeTimeTop( moment.duration( - date.clone().stripZone() - startOfDayDate.clone().stripTime() + date - startOfDayDate.clone().stripTime() ) ); }, @@ -6411,19 +6507,21 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { // Renders a visual indication of an event being dragged over the specified date(s). - // dropLocation's end might be null, as well as `seg`. See Grid::renderDrag for more info. // A returned value of `true` signals that a mock "helper" event has been rendered. - renderDrag: function(dropLocation, seg) { + renderDrag: function(eventLocation, seg) { if (seg) { // if there is event information for this drag, render a helper event - this.renderRangeHelper(dropLocation, seg); - this.applyDragOpacity(this.helperEl); + this.renderEventLocationHelper(eventLocation, seg); + + for (var i = 0; i < this.helperSegs.length; i++) { + this.applyDragOpacity(this.helperSegs[i].el); + } return true; // signal that a helper has been rendered } else { // otherwise, just render a highlight - this.renderHighlight(this.eventRangeToSegs(dropLocation)); + this.renderHighlight(this.eventToSpan(eventLocation)); } }, @@ -6440,8 +6538,8 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { // Renders a visual indication of an event being resized - renderEventResize: function(range, seg) { - this.renderRangeHelper(range, seg); + renderEventResize: function(eventLocation, seg) { + this.renderEventLocationHelper(eventLocation, seg); }, @@ -6457,39 +6555,72 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag) renderHelper: function(event, sourceSeg) { - var segs = this.eventsToSegs([ event ]); - var tableEl; - var i, seg; - var sourceEl; + this.renderHelperSegs(this.eventToSegs(event), sourceSeg); + }, - segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered - tableEl = this.renderSegTable(segs); - // Try to make the segment that is in the same row as sourceSeg look the same + // Unrenders any mock helper event + unrenderHelper: function() { + this.unrenderHelperSegs(); + }, + + + /* Business Hours + ------------------------------------------------------------------------------------------------------------------*/ + + + renderBusinessHours: function() { + var events = this.view.calendar.getBusinessHoursEvents(); + var segs = this.eventsToSegs(events); + + this.renderBusinessSegs(segs); + }, + + + unrenderBusinessHours: function() { + this.unrenderBusinessSegs(); + }, + + + /* Now Indicator + ------------------------------------------------------------------------------------------------------------------*/ + + + getNowIndicatorUnit: function() { + return 'minute'; // will refresh on the minute + }, + + + renderNowIndicator: function(date) { + // seg system might be overkill, but it handles scenario where line needs to be rendered + // more than once because of columns with the same date (resources columns for example) + var segs = this.spanToSegs({ start: date, end: date }); + var top = this.computeDateTop(date, date); + var nodes = []; + var i; + + // render lines within the columns for (i = 0; i < segs.length; i++) { - seg = segs[i]; - if (sourceSeg && sourceSeg.col === seg.col) { - sourceEl = sourceSeg.el; - seg.el.css({ - left: sourceEl.css('left'), - right: sourceEl.css('right'), - 'margin-left': sourceEl.css('margin-left'), - 'margin-right': sourceEl.css('margin-right') - }); - } + nodes.push($('<div class="fc-now-indicator fc-now-indicator-line"></div>') + .css('top', top) + .appendTo(this.colContainerEls.eq(segs[i].col))[0]); } - this.helperEl = $('<div class="fc-helper-skeleton"/>') - .append(tableEl) - .appendTo(this.el); + // render an arrow over the axis + if (segs.length > 0) { // is the current time in view? + nodes.push($('<div class="fc-now-indicator fc-now-indicator-arrow"></div>') + .css('top', top) + .appendTo(this.el.find('.fc-content-skeleton'))[0]); + } + + this.nowIndicatorEls = $(nodes); }, - // Unrenders any mock helper event - unrenderHelper: function() { - if (this.helperEl) { - this.helperEl.remove(); - this.helperEl = null; + unrenderNowIndicator: function() { + if (this.nowIndicatorEls) { + this.nowIndicatorEls.remove(); + this.nowIndicatorEls = null; } }, @@ -6499,12 +6630,14 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight. - renderSelection: function(range) { + renderSelection: function(span) { if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered - this.renderRangeHelper(range); + + // normally acceps an eventLocation, span has a start/end, which is good enough + this.renderEventLocationHelper(span); } else { - this.renderHighlight(this.selectionRangeToSegs(range)); + this.renderHighlight(span); } }, @@ -6516,233 +6649,260 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { }, - /* Fill System (highlight, background events, business hours) + /* Highlight ------------------------------------------------------------------------------------------------------------------*/ - // Renders a set of rectangles over the given time segments. - // Only returns segments that successfully rendered. - renderFill: function(type, segs, className) { - var segCols; - var skeletonEl; - var trEl; - var col, colSegs; - var tdEl; - var containerEl; - var dayDate; - var i, seg; - - if (segs.length) { - - segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs - segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg - - className = className || type.toLowerCase(); - skeletonEl = $( - '<div class="fc-' + className + '-skeleton">' + - '<table><tr/></table>' + - '</div>' - ); - trEl = skeletonEl.find('tr'); - - for (col = 0; col < segCols.length; col++) { - colSegs = segCols[col]; - tdEl = $('<td/>').appendTo(trEl); - - if (colSegs.length) { - containerEl = $('<div class="fc-' + className + '-container"/>').appendTo(tdEl); - dayDate = this.getCellDate(0, col); // row=0 - - for (i = 0; i < colSegs.length; i++) { - seg = colSegs[i]; - containerEl.append( - seg.el.css({ - top: this.computeDateTop(seg.start, dayDate), - bottom: -this.computeDateTop(seg.end, dayDate) // the y position of the bottom edge - }) - ); - } - } - } - - this.bookendCells(trEl); + renderHighlight: function(span) { + this.renderHighlightSegs(this.spanToSegs(span)); + }, - this.el.append(skeletonEl); - this.elsByFill[type] = skeletonEl; - } - return segs; + unrenderHighlight: function() { + this.unrenderHighlightSegs(); } }); ;; -/* Event-rendering methods for the TimeGrid class +/* Methods for rendering SEGMENTS, pieces of content that live on the view + ( this file is no longer just for events ) ----------------------------------------------------------------------------------------------------------------------*/ TimeGrid.mixin({ - eventSkeletonEl: null, // has cells with event-containers, which contain absolutely positioned event elements + colContainerEls: null, // containers for each column + // inner-containers for each column where different types of segs live + fgContainerEls: null, + bgContainerEls: null, + helperContainerEls: null, + highlightContainerEls: null, + businessContainerEls: null, + + // arrays of different types of displayed segments + fgSegs: null, + bgSegs: null, + helperSegs: null, + highlightSegs: null, + businessSegs: null, - // Renders the given foreground event segments onto the grid - renderFgSegs: function(segs) { - segs = this.renderFgSegEls(segs); // returns a subset of the segs. segs that were actually rendered - this.el.append( - this.eventSkeletonEl = $('<div class="fc-content-skeleton"/>') - .append(this.renderSegTable(segs)) + // Renders the DOM that the view's content will live in + renderContentSkeleton: function() { + var cellHtml = ''; + var i; + var skeletonEl; + + for (i = 0; i < this.colCnt; i++) { + cellHtml += + '<td>' + + '<div class="fc-content-col">' + + '<div class="fc-event-container fc-helper-container"></div>' + + '<div class="fc-event-container"></div>' + + '<div class="fc-highlight-container"></div>' + + '<div class="fc-bgevent-container"></div>' + + '<div class="fc-business-container"></div>' + + '</div>' + + '</td>'; + } + + skeletonEl = $( + '<div class="fc-content-skeleton">' + + '<table>' + + '<tr>' + cellHtml + '</tr>' + + '</table>' + + '</div>' ); - return segs; // return only the segs that were actually rendered + this.colContainerEls = skeletonEl.find('.fc-content-col'); + this.helperContainerEls = skeletonEl.find('.fc-helper-container'); + this.fgContainerEls = skeletonEl.find('.fc-event-container:not(.fc-helper-container)'); + this.bgContainerEls = skeletonEl.find('.fc-bgevent-container'); + this.highlightContainerEls = skeletonEl.find('.fc-highlight-container'); + this.businessContainerEls = skeletonEl.find('.fc-business-container'); + + this.bookendCells(skeletonEl.find('tr')); // TODO: do this on string level + this.el.append(skeletonEl); }, - // Unrenders all currently rendered foreground event segments - unrenderFgSegs: function(segs) { - if (this.eventSkeletonEl) { - this.eventSkeletonEl.remove(); - this.eventSkeletonEl = null; - } + /* Foreground Events + ------------------------------------------------------------------------------------------------------------------*/ + + + renderFgSegs: function(segs) { + segs = this.renderFgSegsIntoContainers(segs, this.fgContainerEls); + this.fgSegs = segs; + return segs; // needed for Grid::renderEvents }, - // Renders and returns the <table> portion of the event-skeleton. - // Returns an object with properties 'tbodyEl' and 'segs'. - renderSegTable: function(segs) { - var tableEl = $('<table><tr/></table>'); - var trEl = tableEl.find('tr'); - var segCols; + unrenderFgSegs: function() { + this.unrenderNamedSegs('fgSegs'); + }, + + + /* Foreground Helper Events + ------------------------------------------------------------------------------------------------------------------*/ + + + renderHelperSegs: function(segs, sourceSeg) { var i, seg; - var col, colSegs; - var containerEl; + var sourceEl; - segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg + segs = this.renderFgSegsIntoContainers(segs, this.helperContainerEls); - this.computeSegVerticals(segs); // compute and assign top/bottom + // Try to make the segment that is in the same row as sourceSeg look the same + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + if (sourceSeg && sourceSeg.col === seg.col) { + sourceEl = sourceSeg.el; + seg.el.css({ + left: sourceEl.css('left'), + right: sourceEl.css('right'), + 'margin-left': sourceEl.css('margin-left'), + 'margin-right': sourceEl.css('margin-right') + }); + } + } - for (col = 0; col < segCols.length; col++) { // iterate each column grouping - colSegs = segCols[col]; - this.placeSlotSegs(colSegs); // compute horizontal coordinates, z-index's, and reorder the array + this.helperSegs = segs; + }, - containerEl = $('<div class="fc-event-container"/>'); - // assign positioning CSS and insert into container - for (i = 0; i < colSegs.length; i++) { - seg = colSegs[i]; - seg.el.css(this.generateSegPositionCss(seg)); + unrenderHelperSegs: function() { + this.unrenderNamedSegs('helperSegs'); + }, - // if the height is short, add a className for alternate styling - if (seg.bottom - seg.top < 30) { - seg.el.addClass('fc-short'); - } - containerEl.append(seg.el); - } + /* Background Events + ------------------------------------------------------------------------------------------------------------------*/ - trEl.append($('<td/>').append(containerEl)); - } - this.bookendCells(trEl); + renderBgSegs: function(segs) { + segs = this.renderFillSegEls('bgEvent', segs); // TODO: old fill system + this.updateSegVerticals(segs); + this.attachSegsByCol(this.groupSegsByCol(segs), this.bgContainerEls); + this.bgSegs = segs; + return segs; // needed for Grid::renderEvents + }, - return tableEl; + + unrenderBgSegs: function() { + this.unrenderNamedSegs('bgSegs'); }, - // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each. - // NOTE: Also reorders the given array by date! - placeSlotSegs: function(segs) { - var levels; - var level0; - var i; + /* Highlight + ------------------------------------------------------------------------------------------------------------------*/ - this.sortSegs(segs); // order by date - levels = buildSlotSegLevels(segs); - computeForwardSlotSegs(levels); - if ((level0 = levels[0])) { + renderHighlightSegs: function(segs) { + segs = this.renderFillSegEls('highlight', segs); // TODO: old fill system + this.updateSegVerticals(segs); + this.attachSegsByCol(this.groupSegsByCol(segs), this.highlightContainerEls); + this.highlightSegs = segs; + }, - for (i = 0; i < level0.length; i++) { - computeSlotSegPressures(level0[i]); - } - for (i = 0; i < level0.length; i++) { - this.computeSlotSegCoords(level0[i], 0, 0); - } - } + unrenderHighlightSegs: function() { + this.unrenderNamedSegs('highlightSegs'); }, - // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range - // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and - // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left. - // - // The segment might be part of a "series", which means consecutive segments with the same pressure - // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of - // segments behind this one in the current series, and `seriesBackwardCoord` is the starting - // coordinate of the first segment in the series. - computeSlotSegCoords: function(seg, seriesBackwardPressure, seriesBackwardCoord) { - var forwardSegs = seg.forwardSegs; + /* Business Hours + ------------------------------------------------------------------------------------------------------------------*/ + + + renderBusinessSegs: function(segs) { + segs = this.renderFillSegEls('businessHours', segs); // TODO: old fill system + this.updateSegVerticals(segs); + this.attachSegsByCol(this.groupSegsByCol(segs), this.businessContainerEls); + this.businessSegs = segs; + }, + + + unrenderBusinessSegs: function() { + this.unrenderNamedSegs('businessSegs'); + }, + + + /* Seg Rendering Utils + ------------------------------------------------------------------------------------------------------------------*/ + + + // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col + groupSegsByCol: function(segs) { + var segsByCol = []; var i; - if (seg.forwardCoord === undefined) { // not already computed + for (i = 0; i < this.colCnt; i++) { + segsByCol.push([]); + } - if (!forwardSegs.length) { + for (i = 0; i < segs.length; i++) { + segsByCol[segs[i].col].push(segs[i]); + } - // if there are no forward segments, this segment should butt up against the edge - seg.forwardCoord = 1; - } - else { + return segsByCol; + }, - // sort highest pressure first - this.sortForwardSlotSegs(forwardSegs); - // this segment's forwardCoord will be calculated from the backwardCoord of the - // highest-pressure forward segment. - this.computeSlotSegCoords(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord); - seg.forwardCoord = forwardSegs[0].backwardCoord; - } + // Given segments grouped by column, insert the segments' elements into a parallel array of container + // elements, each living within a column. + attachSegsByCol: function(segsByCol, containerEls) { + var col; + var segs; + var i; - // calculate the backwardCoord from the forwardCoord. consider the series - seg.backwardCoord = seg.forwardCoord - - (seg.forwardCoord - seriesBackwardCoord) / // available width for series - (seriesBackwardPressure + 1); // # of segments in the series + for (col = 0; col < this.colCnt; col++) { // iterate each column grouping + segs = segsByCol[col]; - // use this segment's coordinates to computed the coordinates of the less-pressurized - // forward segments - for (i=0; i<forwardSegs.length; i++) { - this.computeSlotSegCoords(forwardSegs[i], 0, seg.forwardCoord); + for (i = 0; i < segs.length; i++) { + containerEls.eq(col).append(segs[i].el); } } }, - // Refreshes the CSS top/bottom coordinates for each segment element. Probably after a window resize/zoom. - // Repositions business hours segs too, so not just for events. Maybe shouldn't be here. - updateSegVerticals: function() { - var allSegs = (this.segs || []).concat(this.businessHourSegs || []); + // Given the name of a property of `this` object, assumed to be an array of segments, + // loops through each segment and removes from DOM. Will null-out the property afterwards. + unrenderNamedSegs: function(propName) { + var segs = this[propName]; var i; - this.computeSegVerticals(allSegs); - - for (i = 0; i < allSegs.length; i++) { - allSegs[i].el.css( - this.generateSegVerticalCss(allSegs[i]) - ); + if (segs) { + for (i = 0; i < segs.length; i++) { + segs[i].el.remove(); + } + this[propName] = null; } }, - // For each segment in an array, computes and assigns its top and bottom properties - computeSegVerticals: function(segs) { - var i, seg; - for (i = 0; i < segs.length; i++) { - seg = segs[i]; - seg.top = this.computeDateTop(seg.start, seg.start); - seg.bottom = this.computeDateTop(seg.end, seg.start); + /* Foreground Event Rendering Utils + ------------------------------------------------------------------------------------------------------------------*/ + + + // Given an array of foreground segments, render a DOM element for each, computes position, + // and attaches to the column inner-container elements. + renderFgSegsIntoContainers: function(segs, containerEls) { + var segsByCol; + var col; + + segs = this.renderFgSegEls(segs); // will call fgSegHtml + segsByCol = this.groupSegsByCol(segs); + + for (col = 0; col < this.colCnt; col++) { + this.updateFgSegCoords(segsByCol[col]); } + + this.attachSegsByCol(segsByCol, containerEls); + + return segs; }, @@ -6754,7 +6914,7 @@ TimeGrid.mixin({ var isResizableFromStart = !disableResizing && seg.isStart && view.isEventResizableFromStart(event); var isResizableFromEnd = !disableResizing && seg.isEnd && view.isEventResizableFromEnd(event); var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); - var skinCss = cssToStr(this.getEventSkinCss(event)); + var skinCss = cssToStr(this.getSegSkinCss(seg)); var timeText; var fullTimeText; // more verbose time text. for the print stylesheet var startTimeText; // just the start time text @@ -6819,40 +6979,39 @@ TimeGrid.mixin({ }, - // Generates an object with CSS properties/values that should be applied to an event segment element. - // Contains important positioning-related properties that should be applied to any event element, customized or not. - generateSegPositionCss: function(seg) { - var shouldOverlap = this.view.opt('slotEventOverlap'); - var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point - var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point - var props = this.generateSegVerticalCss(seg); // get top/bottom first - var left; // amount of space from left edge, a fraction of the total width - var right; // amount of space from right edge, a fraction of the total width + /* Seg Position Utils + ------------------------------------------------------------------------------------------------------------------*/ - if (shouldOverlap) { - // double the width, but don't go beyond the maximum forward coordinate (1.0) - forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2); - } - if (this.isRTL) { - left = 1 - forwardCoord; - right = backwardCoord; - } - else { - left = backwardCoord; - right = 1 - forwardCoord; - } + // Refreshes the CSS top/bottom coordinates for each segment element. + // Works when called after initial render, after a window resize/zoom for example. + updateSegVerticals: function(segs) { + this.computeSegVerticals(segs); + this.assignSegVerticals(segs); + }, - props.zIndex = seg.level + 1; // convert from 0-base to 1-based - props.left = left * 100 + '%'; - props.right = right * 100 + '%'; - if (shouldOverlap && seg.forwardPressure) { - // add padding to the edge so that forward stacked events don't cover the resizer's icon - props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width + // For each segment in an array, computes and assigns its top and bottom properties + computeSegVerticals: function(segs) { + var i, seg; + + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + seg.top = this.computeDateTop(seg.start, seg.start); + seg.bottom = this.computeDateTop(seg.end, seg.start); } + }, - return props; + + // Given segments that already have their top/bottom properties computed, applies those values to + // the segments' elements. + assignSegVerticals: function(segs) { + var i, seg; + + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + seg.el.css(this.generateSegVerticalCss(seg)); + } }, @@ -6865,36 +7024,155 @@ TimeGrid.mixin({ }, - // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col - groupSegCols: function(segs) { - var segCols = []; + /* Foreground Event Positioning Utils + ------------------------------------------------------------------------------------------------------------------*/ + + + // Given segments that are assumed to all live in the *same column*, + // compute their verical/horizontal coordinates and assign to their elements. + updateFgSegCoords: function(segs) { + this.computeSegVerticals(segs); // horizontals relies on this + this.computeFgSegHorizontals(segs); // compute horizontal coordinates, z-index's, and reorder the array + this.assignSegVerticals(segs); + this.assignFgSegHorizontals(segs); + }, + + + // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each. + // NOTE: Also reorders the given array by date! + computeFgSegHorizontals: function(segs) { + var levels; + var level0; var i; - for (i = 0; i < this.colCnt; i++) { - segCols.push([]); - } + this.sortEventSegs(segs); // order by certain criteria + levels = buildSlotSegLevels(segs); + computeForwardSlotSegs(levels); - for (i = 0; i < segs.length; i++) { - segCols[segs[i].col].push(segs[i]); + if ((level0 = levels[0])) { + + for (i = 0; i < level0.length; i++) { + computeSlotSegPressures(level0[i]); + } + + for (i = 0; i < level0.length; i++) { + this.computeFgSegForwardBack(level0[i], 0, 0); + } } + }, + + + // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range + // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and + // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left. + // + // The segment might be part of a "series", which means consecutive segments with the same pressure + // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of + // segments behind this one in the current series, and `seriesBackwardCoord` is the starting + // coordinate of the first segment in the series. + computeFgSegForwardBack: function(seg, seriesBackwardPressure, seriesBackwardCoord) { + var forwardSegs = seg.forwardSegs; + var i; + + if (seg.forwardCoord === undefined) { // not already computed + + if (!forwardSegs.length) { + + // if there are no forward segments, this segment should butt up against the edge + seg.forwardCoord = 1; + } + else { + + // sort highest pressure first + this.sortForwardSegs(forwardSegs); + + // this segment's forwardCoord will be calculated from the backwardCoord of the + // highest-pressure forward segment. + this.computeFgSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord); + seg.forwardCoord = forwardSegs[0].backwardCoord; + } - return segCols; + // calculate the backwardCoord from the forwardCoord. consider the series + seg.backwardCoord = seg.forwardCoord - + (seg.forwardCoord - seriesBackwardCoord) / // available width for series + (seriesBackwardPressure + 1); // # of segments in the series + + // use this segment's coordinates to computed the coordinates of the less-pressurized + // forward segments + for (i=0; i<forwardSegs.length; i++) { + this.computeFgSegForwardBack(forwardSegs[i], 0, seg.forwardCoord); + } + } }, - sortForwardSlotSegs: function(forwardSegs) { - forwardSegs.sort(proxy(this, 'compareForwardSlotSegs')); + sortForwardSegs: function(forwardSegs) { + forwardSegs.sort(proxy(this, 'compareForwardSegs')); }, // A cmp function for determining which forward segment to rely on more when computing coordinates. - compareForwardSlotSegs: function(seg1, seg2) { + compareForwardSegs: function(seg1, seg2) { // put higher-pressure first return seg2.forwardPressure - seg1.forwardPressure || // put segments that are closer to initial edge first (and favor ones with no coords yet) (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) || // do normal sorting... - this.compareSegs(seg1, seg2); + this.compareEventSegs(seg1, seg2); + }, + + + // Given foreground event segments that have already had their position coordinates computed, + // assigns position-related CSS values to their elements. + assignFgSegHorizontals: function(segs) { + var i, seg; + + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + seg.el.css(this.generateFgSegHorizontalCss(seg)); + + // if the height is short, add a className for alternate styling + if (seg.bottom - seg.top < 30) { + seg.el.addClass('fc-short'); + } + } + }, + + + // Generates an object with CSS properties/values that should be applied to an event segment element. + // Contains important positioning-related properties that should be applied to any event element, customized or not. + generateFgSegHorizontalCss: function(seg) { + var shouldOverlap = this.view.opt('slotEventOverlap'); + var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point + var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point + var props = this.generateSegVerticalCss(seg); // get top/bottom first + var left; // amount of space from left edge, a fraction of the total width + var right; // amount of space from right edge, a fraction of the total width + + if (shouldOverlap) { + // double the width, but don't go beyond the maximum forward coordinate (1.0) + forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2); + } + + if (this.isRTL) { + left = 1 - forwardCoord; + right = backwardCoord; + } + else { + left = backwardCoord; + right = 1 - forwardCoord; + } + + props.zIndex = seg.level + 1; // convert from 0-base to 1-based + props.left = left * 100 + '%'; + props.right = right * 100 + '%'; + + if (shouldOverlap && seg.forwardPressure) { + // add padding to the edge so that forward stacked events don't cover the resizer's icon + props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width + } + + return props; } }); @@ -7047,6 +7325,13 @@ var View = FC.View = Class.extend({ // document handlers, bound to `this` object documentMousedownProxy: null, // TODO: doesn't work with touch + // now indicator + isNowIndicatorRendered: null, + initialNowDate: null, // result first getNow call + initialNowQueriedMs: null, // ms time the getNow was called + nowIndicatorTimeoutID: null, // for refresh timing of now indicator + nowIndicatorIntervalID: null, // " + constructor: function(calendar, type, options, intervalDuration) { @@ -7098,21 +7383,20 @@ var View = FC.View = Class.extend({ ------------------------------------------------------------------------------------------------------------------*/ - // Updates all internal dates to center around the given current date + // Updates all internal dates to center around the given current unzoned date. setDate: function(date) { this.setRange(this.computeRange(date)); }, - // Updates all internal dates for displaying the given range. - // Expects all values to be normalized (like what computeRange does). + // Updates all internal dates for displaying the given unzoned range. setRange: function(range) { - $.extend(this, range); + $.extend(this, range); // assigns every property to this object's member variables this.updateTitle(); }, - // Given a single current date, produce information about what range to display. + // Given a single current unzoned date, produce information about what range to display. // Subclasses can override. Must return all properties. computeRange: function(date) { var intervalUnit = computeIntervalUnit(this.intervalDuration); @@ -7127,10 +7411,10 @@ var View = FC.View = Class.extend({ } else { // needs to have a time? if (!intervalStart.hasTime()) { - intervalStart = this.calendar.rezoneDate(intervalStart); // convert to current timezone, with 00:00 + intervalStart = this.calendar.time(0); // give 00:00 time } if (!intervalEnd.hasTime()) { - intervalEnd = this.calendar.rezoneDate(intervalEnd); // convert to current timezone, with 00:00 + intervalEnd = this.calendar.time(0); // give 00:00 time } } @@ -7193,7 +7477,11 @@ var View = FC.View = Class.extend({ // Computes what the title at the top of the calendar should be for this view computeTitle: function() { return this.formatRange( - { start: this.intervalStart, end: this.intervalEnd }, + { + // in case intervalStart/End has a time, make sure timezone is correct + start: this.calendar.applyTimezone(this.intervalStart), + end: this.calendar.applyTimezone(this.intervalEnd) + }, this.opt('titleFormat') || this.computeTitleFormat(), this.opt('titleRangeSeparator') ); @@ -7220,6 +7508,7 @@ var View = FC.View = Class.extend({ // Utility for formatting a range. Accepts a range object, formatting string, and optional separator. // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account. + // The timezones of the dates within `range` will be respected. formatRange: function(range, formatStr, separator) { var end = range.end; @@ -7264,7 +7553,7 @@ var View = FC.View = Class.extend({ }, - // Does everything necessary to display the view centered around the given date. + // 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) { @@ -7275,12 +7564,15 @@ var View = FC.View = Class.extend({ scrollState = this.queryScroll(); } + this.calendar.freezeContentHeight(); + return this.clear().then(function() { // clear the content first (async) return ( _this.displaying = $.when(_this.displayView(date)) // displayView might return a promise .then(function() { _this.forceScroll(_this.computeInitialScroll(scrollState)); + _this.calendar.unfreezeContentHeight(); _this.triggerRender(); }) ); @@ -7316,13 +7608,16 @@ var View = FC.View = Class.extend({ this.renderSkeleton(); this.isSkeletonRendered = true; } - this.setDate(date); + if (date) { + this.setDate(date); + } if (this.render) { this.render(); // TODO: deprecate } this.renderDates(); this.updateSize(); this.renderBusinessHours(); // might need coordinates, so should go after updateSize() + this.startNowIndicator(); }, @@ -7330,6 +7625,7 @@ var View = FC.View = Class.extend({ // Can be asynchronous and return a promise. clearView: function() { this.unselect(); + this.stopNowIndicator(); this.triggerUnrender(); this.unrenderBusinessHours(); this.unrenderDates(); @@ -7364,18 +7660,6 @@ var View = FC.View = Class.extend({ }, - // Renders business-hours onto the view. Assumes updateSize has already been called. - renderBusinessHours: function() { - // subclasses should implement - }, - - - // Unrenders previously-rendered business-hours - unrenderBusinessHours: function() { - // subclasses should implement - }, - - // Signals that the view's content has been rendered triggerRender: function() { this.trigger('viewRender', this, this, this.el); @@ -7410,6 +7694,110 @@ var View = FC.View = Class.extend({ }, + /* Business Hours + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders business-hours onto the view. Assumes updateSize has already been called. + renderBusinessHours: function() { + // subclasses should implement + }, + + + // Unrenders previously-rendered business-hours + unrenderBusinessHours: function() { + // subclasses should implement + }, + + + /* Now Indicator + ------------------------------------------------------------------------------------------------------------------*/ + + + // Immediately render the current time indicator and begins re-rendering it at an interval, + // which is defined by this.getNowIndicatorUnit(). + // TODO: somehow do this for the current whole day's background too + startNowIndicator: function() { + var _this = this; + var unit; + var update; + var delay; // ms wait value + + if (this.opt('nowIndicator')) { + unit = this.getNowIndicatorUnit(); + if (unit) { + update = proxy(this, 'updateNowIndicator'); // bind to `this` + + this.initialNowDate = this.calendar.getNow(); + this.initialNowQueriedMs = +new Date(); + this.renderNowIndicator(this.initialNowDate); + this.isNowIndicatorRendered = true; + + // wait until the beginning of the next interval + delay = this.initialNowDate.clone().startOf(unit).add(1, unit) - this.initialNowDate; + this.nowIndicatorTimeoutID = setTimeout(function() { + _this.nowIndicatorTimeoutID = null; + update(); + delay = +moment.duration(1, unit); + delay = Math.max(100, delay); // prevent too frequent + _this.nowIndicatorIntervalID = setInterval(update, delay); // update every interval + }, delay); + } + } + }, + + + // rerenders the now indicator, computing the new current time from the amount of time that has passed + // since the initial getNow call. + updateNowIndicator: function() { + if (this.isNowIndicatorRendered) { + this.unrenderNowIndicator(); + this.renderNowIndicator( + this.initialNowDate.clone().add(new Date() - this.initialNowQueriedMs) // add ms + ); + } + }, + + + // Immediately unrenders the view's current time indicator and stops any re-rendering timers. + // Won't cause side effects if indicator isn't rendered. + stopNowIndicator: function() { + if (this.isNowIndicatorRendered) { + + if (this.nowIndicatorTimeoutID) { + clearTimeout(this.nowIndicatorTimeoutID); + this.nowIndicatorTimeoutID = null; + } + if (this.nowIndicatorIntervalID) { + clearTimeout(this.nowIndicatorIntervalID); + this.nowIndicatorIntervalID = null; + } + + this.unrenderNowIndicator(); + this.isNowIndicatorRendered = false; + } + }, + + + // Returns a string unit, like 'second' or 'minute' that defined how often the current time indicator + // should be refreshed. If something falsy is returned, no time indicator is rendered at all. + getNowIndicatorUnit: function() { + // subclasses should implement + }, + + + // Renders a current time indicator at the given datetime + renderNowIndicator: function(date) { + // subclasses should implement + }, + + + // Undoes the rendering actions from renderNowIndicator + unrenderNowIndicator: function() { + // subclasses should implement + }, + + /* Dimensions ------------------------------------------------------------------------------------------------------------------*/ @@ -7424,6 +7812,7 @@ var View = FC.View = Class.extend({ this.updateHeight(isResize); this.updateWidth(isResize); + this.updateNowIndicator(); if (isResize) { this.setScroll(scrollState); @@ -7532,12 +7921,19 @@ var View = FC.View = Class.extend({ // Does everything necessary to clear the view's currently-rendered events clearEvents: function() { + var scrollState; + if (this.isEventsRendered) { + + // TODO: optimize: if we know this is part of a displayEvents call, don't queryScroll/setScroll + scrollState = this.queryScroll(); + this.triggerEventUnrender(); if (this.destroyEvents) { this.destroyEvents(); // TODO: deprecate } this.unrenderEvents(); + this.setScroll(scrollState); this.isEventsRendered = false; } }, @@ -7648,7 +8044,7 @@ var View = FC.View = Class.extend({ // Must be called when an event in the view is dropped onto new location. - // `dropLocation` is an object that contains the new start/end/allDay values for the event. + // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event. reportEventDrop: function(event, dropLocation, largeUnit, el, ev) { var calendar = this.calendar; var mutateResult = calendar.mutateEvent(event, dropLocation, largeUnit); @@ -7674,7 +8070,7 @@ var View = FC.View = Class.extend({ // Must be called when an external element, via jQuery UI, has been dropped onto the calendar. // `meta` is the parsed data that has been embedded into the dragging event. - // `dropLocation` is an object that contains the new start/end/allDay values for the event. + // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event. reportExternalDrop: function(meta, dropLocation, el, ev, ui) { var eventProps = meta.eventProps; var eventInput; @@ -7774,31 +8170,37 @@ var View = FC.View = Class.extend({ ------------------------------------------------------------------------------------------------------------------*/ - // Selects a date range on the view. `start` and `end` are both Moments. + // Selects a date span on the view. `start` and `end` are both Moments. // `ev` is the native mouse event that begin the interaction. - select: function(range, ev) { + select: function(span, ev) { this.unselect(ev); - this.renderSelection(range); - this.reportSelection(range, ev); + this.renderSelection(span); + this.reportSelection(span, ev); }, // Renders a visual indication of the selection - renderSelection: function(range) { + renderSelection: function(span) { // subclasses should implement }, // Called when a new selection is made. Updates internal state and triggers handlers. - reportSelection: function(range, ev) { + reportSelection: function(span, ev) { this.isSelected = true; - this.triggerSelect(range, ev); + this.triggerSelect(span, ev); }, // Triggers handlers to 'select' - triggerSelect: function(range, ev) { - this.trigger('select', null, range.start, range.end, ev); + triggerSelect: function(span, ev) { + this.trigger( + 'select', + null, + this.calendar.applyTimezone(span.start), // convert to calendar's tz for external API + this.calendar.applyTimezone(span.end), // " + ev + ); }, @@ -7843,8 +8245,14 @@ var View = FC.View = Class.extend({ // Triggers handlers to 'dayClick' - triggerDayClick: function(date, dayEl, ev) { - this.trigger('dayClick', dayEl, date, ev); + // Span has start/end of the clicked area. Only the start is useful. + triggerDayClick: function(span, dayEl, ev) { + this.trigger( + 'dayClick', + dayEl, + this.calendar.applyTimezone(span.start), // convert to calendar's timezone for external API + ev + ); }, @@ -8179,12 +8587,13 @@ var Calendar = FC.Calendar = Class.extend({ }, - // Given arguments to the select method in the API, returns a range - buildSelectRange: function(start, end) { + // Given arguments to the select method in the API, returns a span (unzoned start/end and other info) + buildSelectSpan: function(zonedStartInput, zonedEndInput) { + var start = this.moment(zonedStartInput).stripZone(); + var end; - start = this.moment(start); - if (end) { - end = this.moment(end); + if (zonedEndInput) { + end = this.moment(zonedEndInput).stripZone(); } else if (start.hasTime()) { end = start.clone().add(this.defaultTimedEventDuration); @@ -8326,21 +8735,36 @@ function Calendar_constructor(element, overrides) { }; - // Returns a copy of the given date in the current timezone of it is ambiguously zoned. - // This will also give the date an unambiguous time. - t.rezoneDate = function(date) { - return t.moment(date.toArray()); + // Returns a copy of the given date in the current timezone. Has no effect on dates without times. + t.applyTimezone = function(date) { + if (!date.hasTime()) { + return date.clone(); + } + + var zonedDate = t.moment(date.toArray()); + var timeAdjust = date.time() - zonedDate.time(); + var adjustedZonedDate; + + // Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396) + if (timeAdjust) { // is the time result different than expected? + adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds + if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now? + zonedDate = adjustedZonedDate; + } + } + + return zonedDate; }; - // Returns a moment for the current date, as defined by the client's computer, - // or overridden by the `now` option. + // 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; if (typeof now === 'function') { now = now(); } - return t.moment(now); + return t.moment(now).stripZone(); }; @@ -8355,9 +8779,10 @@ function Calendar_constructor(element, overrides) { }; - // Given an event's allDay status and start date, return swhat its fallback end date should be. - t.getDefaultEventEnd = function(allDay, start) { // TODO: rename to computeDefaultEventEnd - var end = start.clone(); + // Given an event's allDay status and start date, return what its fallback end date should be. + // TODO: rename to computeDefaultEventEnd + t.getDefaultEventEnd = function(allDay, zonedStart) { + var end = zonedStart.clone(); if (allDay) { end.stripTime().add(t.defaultAllDayEventDuration); @@ -8407,8 +8832,8 @@ function Calendar_constructor(element, overrides) { var suggestedViewHeight; var windowResizeProxy; // wraps the windowResize function var ignoreWindowResize = 0; - var date; var events = []; + var date; // unzoned @@ -8416,11 +8841,12 @@ function Calendar_constructor(element, overrides) { // ----------------------------------------------------------------------------------- + // compute the initial ambig-timezone date if (options.defaultDate != null) { - date = t.moment(options.defaultDate); + date = t.moment(options.defaultDate).stripZone(); } else { - date = t.getNow(); + date = t.getNow(); // getNow already returns unzoned } @@ -8537,8 +8963,7 @@ function Calendar_constructor(element, overrides) { ) { if (elementVisible()) { - freezeContentHeight(); - currentView.display(date); + currentView.display(date); // will call freezeContentHeight unfreezeContentHeight(); // immediately unfreeze regardless of whether display is async // need to do this after View::render, so dates are calculated @@ -8706,9 +9131,10 @@ function Calendar_constructor(element, overrides) { -----------------------------------------------------------------------------*/ - function select(start, end) { + // this public method receives start/end dates in any format, with any timezone + function select(zonedStartInput, zonedEndInput) { currentView.select( - t.buildSelectRange.apply(t, arguments) + t.buildSelectSpan.apply(t, arguments) ); } @@ -8755,8 +9181,8 @@ function Calendar_constructor(element, overrides) { } - function gotoDate(dateInput) { - date = t.moment(dateInput); + function gotoDate(zonedDateInput) { + date = t.moment(zonedDateInput).stripZone(); renderView(); } @@ -8775,13 +9201,14 @@ function Calendar_constructor(element, overrides) { viewType = viewType || 'day'; // day is default zoom spec = t.getViewSpec(viewType) || t.getUnitViewSpec(viewType); - date = newDate; + date = newDate.clone(); renderView(spec ? spec.type : null); } + // for external API function getDate() { - return date.clone(); + return t.applyTimezone(date); // infuse the calendar's timezone } @@ -8790,6 +9217,9 @@ function Calendar_constructor(element, overrides) { -----------------------------------------------------------------------------*/ // TODO: move this into the view + t.freezeContentHeight = freezeContentHeight; + t.unfreezeContentHeight = unfreezeContentHeight; + function freezeContentHeight() { content.css({ @@ -8877,6 +9307,8 @@ Calendar.defaults = { //editable: false, + //nowIndicator: false, + scrollTime: '06:00:00', // event ajax @@ -9443,9 +9875,8 @@ function EventManager(options) { // assumed to be a calendar t.removeEvents = removeEvents; t.clientEvents = clientEvents; t.mutateEvent = mutateEvent; - t.normalizeEventRange = normalizeEventRange; - t.normalizeEventRangeTimes = normalizeEventRangeTimes; - t.ensureVisibleEventRange = ensureVisibleEventRange; + t.normalizeEventDates = normalizeEventDates; + t.normalizeEventTimes = normalizeEventTimes; // imports @@ -9475,13 +9906,12 @@ function EventManager(options) { // assumed to be a calendar /* Fetching -----------------------------------------------------------------------------*/ - - + + + // start and end are assumed to be unzoned function isFetchNeeded(start, end) { return !rangeStart || // nothing has been fetched yet? - // or, a part of the new range is outside of the old range? (after normalizing) - start.clone().stripZone() < rangeStart.clone().stripZone() || - end.clone().stripZone() > rangeEnd.clone().stripZone(); + start < rangeStart || end > rangeEnd; // is part of the new range outside of the old range? } @@ -9932,7 +10362,7 @@ function EventManager(options) { // assumed to be a calendar source ? source.allDayDefault : undefined, options.allDayDefault ); - // still undefined? normalizeEventRange will calculate it + // still undefined? normalizeEventDates will calculate it } assignDatesToEvent(start, end, allDay, out); @@ -9948,76 +10378,56 @@ function EventManager(options) { // assumed to be a calendar event.start = start; event.end = end; event.allDay = allDay; - normalizeEventRange(event); + normalizeEventDates(event); backupEventDates(event); } // Ensures proper values for allDay/start/end. Accepts an Event object, or a plain object with event-ish properties. // NOTE: Will modify the given object. - function normalizeEventRange(props) { + function normalizeEventDates(eventProps) { - normalizeEventRangeTimes(props); + normalizeEventTimes(eventProps); - if (props.end && !props.end.isAfter(props.start)) { - props.end = null; + if (eventProps.end && !eventProps.end.isAfter(eventProps.start)) { + eventProps.end = null; } - if (!props.end) { + if (!eventProps.end) { if (options.forceEventDuration) { - props.end = t.getDefaultEventEnd(props.allDay, props.start); + eventProps.end = t.getDefaultEventEnd(eventProps.allDay, eventProps.start); } else { - props.end = null; + eventProps.end = null; } } } // Ensures the allDay property exists and the timeliness of the start/end dates are consistent - function normalizeEventRangeTimes(range) { - if (range.allDay == null) { - range.allDay = !(range.start.hasTime() || (range.end && range.end.hasTime())); + function normalizeEventTimes(eventProps) { + if (eventProps.allDay == null) { + eventProps.allDay = !(eventProps.start.hasTime() || (eventProps.end && eventProps.end.hasTime())); } - if (range.allDay) { - range.start.stripTime(); - if (range.end) { + if (eventProps.allDay) { + eventProps.start.stripTime(); + if (eventProps.end) { // TODO: consider nextDayThreshold here? If so, will require a lot of testing and adjustment - range.end.stripTime(); + eventProps.end.stripTime(); } } else { - if (!range.start.hasTime()) { - range.start = t.rezoneDate(range.start); // will assign a 00:00 time + if (!eventProps.start.hasTime()) { + eventProps.start = t.applyTimezone(eventProps.start.time(0)); // will assign a 00:00 time } - if (range.end && !range.end.hasTime()) { - range.end = t.rezoneDate(range.end); // will assign a 00:00 time + if (eventProps.end && !eventProps.end.hasTime()) { + eventProps.end = t.applyTimezone(eventProps.end.time(0)); // will assign a 00:00 time } } } - // If `range` is a proper range with a start and end, returns the original object. - // If missing an end, computes a new range with an end, computing it as if it were an event. - // TODO: make this a part of the event -> eventRange system - function ensureVisibleEventRange(range) { - var allDay; - - if (!range.end) { - - allDay = range.allDay; // range might be more event-ish than we think - if (allDay == null) { - allDay = !range.start.hasTime(); - } - - range = $.extend({}, range); // make a copy, copying over other misc properties - range.end = t.getDefaultEventEnd(allDay, range.start); - } - return range; - } - - // If the given event is a recurring event, break it down into an array of individual instances. // If not a recurring event, return an array with the single original event. // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array. @@ -10131,7 +10541,7 @@ function EventManager(options) { // assumed to be a calendar if (newProps.allDay == null) { // is null or undefined? newProps.allDay = event.allDay; } - normalizeEventRange(newProps); + normalizeEventDates(newProps); // create normalized versions of the original props to compare against // need a real end value, for diffing @@ -10140,7 +10550,7 @@ function EventManager(options) { // assumed to be a calendar end: event._end ? event._end.clone() : t.getDefaultEventEnd(event._allDay, event._start), allDay: newProps.allDay // normalize the dates in the same regard as the new properties }; - normalizeEventRange(oldProps); + normalizeEventDates(oldProps); // need to clear the end date if explicitly changed to null clearEnd = event._end !== null && newProps.end === null; @@ -10225,7 +10635,7 @@ function EventManager(options) { // assumed to be a calendar end: event._end, allDay: allDay // normalize the dates in the same regard as the new properties }; - normalizeEventRange(newProps); // massages start/end/allDay + normalizeEventDates(newProps); // massages start/end/allDay // strip or ensure the end date if (clearEnd) { @@ -10326,12 +10736,13 @@ function EventManager(options) { // assumed to be a calendar /* Overlapping / Constraining -----------------------------------------------------------------------------------------*/ - t.isEventRangeAllowed = isEventRangeAllowed; - t.isSelectionRangeAllowed = isSelectionRangeAllowed; - t.isExternalDropRangeAllowed = isExternalDropRangeAllowed; + t.isEventSpanAllowed = isEventSpanAllowed; + t.isExternalSpanAllowed = isExternalSpanAllowed; + t.isSelectionSpanAllowed = isSelectionSpanAllowed; - function isEventRangeAllowed(range, event) { + // 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, @@ -10343,57 +10754,47 @@ function EventManager(options) { // assumed to be a calendar source.overlap, options.eventOverlap ); - - range = ensureVisibleEventRange(range); // ensure a proper range with an end for isRangeAllowed - - return isRangeAllowed(range, constraint, overlap, event); - } - - - function isSelectionRangeAllowed(range) { - return isRangeAllowed(range, options.selectConstraint, options.selectOverlap); + return isSpanAllowed(span, constraint, overlap, event); } - // when `eventProps` is defined, consider this an event. - // `eventProps` can contain misc non-date-related info about the event. - function isExternalDropRangeAllowed(range, eventProps) { + // 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; // note: very similar logic is in View's reportExternalDrop if (eventProps) { - eventInput = $.extend({}, eventProps, range); + eventInput = $.extend({}, eventProps, eventLocation); event = expandEvent(buildEventFromInput(eventInput))[0]; } if (event) { - return isEventRangeAllowed(range, event); + return isEventSpanAllowed(eventSpan, event); } else { // treat it as a selection - range = ensureVisibleEventRange(range); // ensure a proper range with an end for isSelectionRangeAllowed - - return isSelectionRangeAllowed(range); + return isSelectionSpanAllowed(eventSpan); } } - // Returns true if the given range (caused by an event drop/resize or a selection) is allowed to exist + // Determines the given span (unzoned start/end with other misc data) can be selected. + function isSelectionSpanAllowed(span) { + return isSpanAllowed(span, options.selectConstraint, options.selectOverlap); + } + + + // 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 isRangeAllowed(range, constraint, overlap, event) { + function isSpanAllowed(span, constraint, overlap, event) { var constraintEvents; var anyContainment; var peerEvents; var i, peerEvent; var peerOverlap; - // normalize. fyi, we're normalizing in too many places :( - range = $.extend({}, range); // copy all properties in case there are misc non-date properties - range.start = range.start.clone().stripZone(); - range.end = range.end.clone().stripZone(); - // the range must be fully contained by at least one of produced constraint events if (constraint != null) { @@ -10403,7 +10804,7 @@ function EventManager(options) { // assumed to be a calendar anyContainment = false; for (i = 0; i < constraintEvents.length; i++) { - if (eventContainsRange(constraintEvents[i], range)) { + if (eventContainsRange(constraintEvents[i], span)) { anyContainment = true; break; } @@ -10414,13 +10815,13 @@ function EventManager(options) { // assumed to be a calendar } } - peerEvents = t.getPeerEvents(event, range); + peerEvents = t.getPeerEvents(span, event); for (i = 0; i < peerEvents.length; i++) { peerEvent = peerEvents[i]; // there needs to be an actual intersection before disallowing anything - if (eventIntersectsRange(peerEvent, range)) { + if (eventIntersectsRange(peerEvent, span)) { // evaluate overlap for the given range and short-circuit if necessary if (overlap === false) { @@ -10500,8 +10901,8 @@ function EventManager(options) { // assumed to be a calendar // Returns a list of events that the given event should be compared against when being considered for a move to -// the specified range. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar. -Calendar.prototype.getPeerEvents = function(event, range) { +// 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; @@ -10810,8 +11211,8 @@ var BasicView = FC.BasicView = View.extend({ // Renders a visual indication of a selection - renderSelection: function(range) { - this.dayGrid.renderSelection(range); + renderSelection: function(span) { + this.dayGrid.renderSelection(span); }, @@ -11072,15 +11473,6 @@ var AgendaView = FC.AgendaView = View.extend({ }, - renderBusinessHours: function() { - this.timeGrid.renderBusinessHours(); - - if (this.dayGrid) { - this.dayGrid.renderBusinessHours(); - } - }, - - // Builds the HTML skeleton for the view. // The day-grid and time-grid components will render inside containers defined by this HTML. renderSkeletonHtml: function() { @@ -11118,6 +11510,47 @@ var AgendaView = FC.AgendaView = View.extend({ }, + /* Business Hours + ------------------------------------------------------------------------------------------------------------------*/ + + + renderBusinessHours: function() { + this.timeGrid.renderBusinessHours(); + + if (this.dayGrid) { + this.dayGrid.renderBusinessHours(); + } + }, + + + unrenderBusinessHours: function() { + this.timeGrid.unrenderBusinessHours(); + + if (this.dayGrid) { + this.dayGrid.unrenderBusinessHours(); + } + }, + + + /* Now Indicator + ------------------------------------------------------------------------------------------------------------------*/ + + + getNowIndicatorUnit: function() { + return this.timeGrid.getNowIndicatorUnit(); + }, + + + renderNowIndicator: function(date) { + this.timeGrid.renderNowIndicator(date); + }, + + + unrenderNowIndicator: function() { + this.timeGrid.unrenderNowIndicator(); + }, + + /* Dimensions ------------------------------------------------------------------------------------------------------------------*/ @@ -11331,12 +11764,12 @@ var AgendaView = FC.AgendaView = View.extend({ // Renders a visual indication of a selection - renderSelection: function(range) { - if (range.start.hasTime() || range.end.hasTime()) { - this.timeGrid.renderSelection(range); + renderSelection: function(span) { + if (span.start.hasTime() || span.end.hasTime()) { + this.timeGrid.renderSelection(span); } else if (this.dayGrid) { - this.dayGrid.renderSelection(range); + this.dayGrid.renderSelection(span); } }, @@ -11353,6 +11786,7 @@ var AgendaView = FC.AgendaView = View.extend({ // Methods that will customize the rendering behavior of the AgendaView's timeGrid +// TODO: move into TimeGrid var agendaTimeGridMethods = { |