From 0c56fddecff588cb7b267def19d872d218e0ce9a Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Tue, 16 Jun 2015 18:50:47 -0700 Subject: [PATCH] Upgrade FullCalendar and MomentJS Close #469 --- src/UI/Calendar/CalendarView.js | 68 +- src/UI/Content/Overrides/fullcalendar.less | 14 +- src/UI/Content/fullcalendar.css | 1400 +- src/UI/JsLibraries/fullcalendar.js | 15409 ++++++++++------ .../{lang => locale}/placeholder.txt | 0 src/UI/JsLibraries/moment.js | 4753 ++--- src/UI/Shims/moment.js | 4 - 7 files changed, 13211 insertions(+), 8437 deletions(-) rename src/UI/JsLibraries/{lang => locale}/placeholder.txt (100%) delete mode 100644 src/UI/Shims/moment.js diff --git a/src/UI/Calendar/CalendarView.js b/src/UI/Calendar/CalendarView.js index a39e801da..f10eb74ee 100644 --- a/src/UI/Calendar/CalendarView.js +++ b/src/UI/Calendar/CalendarView.js @@ -26,7 +26,7 @@ module.exports = Marionette.ItemView.extend({ }, onShow : function() { - this.$('.fc-button-today').click(); + this.$('.fc-today-button').click(); }, setShowUnmonitored : function (showUnmonitored) { @@ -37,17 +37,6 @@ module.exports = Marionette.ItemView.extend({ }, _viewRender : function(view) { - if ($(window).width() < 768) { - this.$('.fc-header-title').show(); - this.$('.calendar-title').remove(); - - var title = this.$('.fc-header-title').text(); - var titleDiv = '

{0}

'.format(title); - - this.$('.fc-header').before(titleDiv); - this.$('.fc-header-title').hide(); - } - if (Config.getValue(this.storageKey) !== view.name) { Config.setValue(this.storageKey, view.name); } @@ -55,9 +44,22 @@ module.exports = Marionette.ItemView.extend({ this._getEvents(view); }, + _eventAfterAllRender : function () { + if ($(window).width() < 768) { + this.$('.fc-center').show(); + this.$('.calendar-title').remove(); + + var title = this.$('.fc-center').html(); + var titleDiv = '
{0}
'.format(title); + + this.$('.fc-toolbar').before(titleDiv); + this.$('.fc-center').hide(); + } + }, + _eventRender : function(event, element) { - this.$(element).addClass(event.statusLevel); - this.$(element).children('.fc-event-inner').addClass(event.statusLevel); + element.addClass(event.statusLevel); + element.children('.fc-content').addClass(event.statusLevel); if (event.downloading) { var progress = 100 - event.downloading.get('sizeleft') / event.downloading.get('size') * 100; @@ -87,9 +89,9 @@ module.exports = Marionette.ItemView.extend({ } else { - this.$(element).find('.fc-event-time').after(''.format(progress)); + element.find('.fc-time').after(''.format(progress)); - this.$(element).find('.chart').easyPieChart({ + element.find('.chart').easyPieChart({ barColor : '#ffffff', trackColor : false, scaleColor : false, @@ -98,9 +100,9 @@ module.exports = Marionette.ItemView.extend({ animate : false }); - this.$(element).find('.chart').tooltip({ + element.find('.chart').tooltip({ title : 'Episode is downloading - {0}% {1}'.format(progress.toFixed(1), releaseTitle), - container : '.fc-content' + container : '.fc-content-skeleton' }); } } @@ -123,8 +125,11 @@ module.exports = Marionette.ItemView.extend({ }, _setEventData : function(collection) { - var events = []; + if (collection.length === 0) { + return; + } + var events = []; var self = this; collection.each(function(model) { @@ -197,13 +202,14 @@ module.exports = Marionette.ItemView.extend({ _getOptions : function() { var options = { - allDayDefault : false, - weekMode : 'variable', - firstDay : UiSettings.get('firstDayOfWeek'), - timeFormat : 'h(:mm)a', - viewRender : this._viewRender.bind(this), - eventRender : this._eventRender.bind(this), - eventClick : function(event) { + allDayDefault : false, + weekMode : 'variable', + firstDay : UiSettings.get('firstDayOfWeek'), + timeFormat : 'h(:mm)t', + viewRender : this._viewRender.bind(this), + eventRender : this._eventRender.bind(this), + eventAfterAllRender : this._eventAfterAllRender.bind(this), + eventClick : function(event) { vent.trigger(vent.Commands.ShowEpisodeDetails, { episode : event.model }); } }; @@ -240,18 +246,16 @@ module.exports = Marionette.ItemView.extend({ day : 'dddd' }; - options.timeFormat = { - 'default' : UiSettings.get('timeFormat') - }; + options.timeFormat = UiSettings.get('timeFormat'); return options; }, _addStatusIcon : function(element, icon, tooltip) { - this.$(element).find('.fc-event-time').after(''.format(icon)); - this.$(element).find('.status').tooltip({ + element.find('.fc-time').after(''.format(icon)); + element.find('.status').tooltip({ title : tooltip, - container : '.fc-content' + container : '.fc-content-skeleton' }); } }); \ No newline at end of file diff --git a/src/UI/Content/Overrides/fullcalendar.less b/src/UI/Content/Overrides/fullcalendar.less index 9af8602c3..269181bd8 100644 --- a/src/UI/Content/Overrides/fullcalendar.less +++ b/src/UI/Content/Overrides/fullcalendar.less @@ -2,8 +2,12 @@ overflow: visible; } -.fc-event-title { - padding: 0 2px; +.fc-time { + padding: 0 1px; +} + +.fc-title { + padding: 0 1px; display: block; text-overflow: ellipsis; white-space: nowrap; @@ -25,3 +29,9 @@ z-index: 1; } } + +.fc-event-container { + .fc-event { + line-height : inherit; + } +} \ No newline at end of file diff --git a/src/UI/Content/fullcalendar.css b/src/UI/Content/fullcalendar.css index a31ce83da..4e5e4eb61 100644 --- a/src/UI/Content/fullcalendar.css +++ b/src/UI/Content/fullcalendar.css @@ -1,206 +1,207 @@ /*! - * FullCalendar v2.0.2 Stylesheet - * Docs & License: http://arshaw.com/fullcalendar/ - * (c) 2013 Adam Shaw + * FullCalendar v2.3.2 Stylesheet + * Docs & License: http://fullcalendar.io/ + * (c) 2015 Adam Shaw */ .fc { direction: ltr; text-align: left; - } - -.fc table { - border-collapse: collapse; - border-spacing: 0; - } - -html .fc, -.fc table { - font-size: 1em; - } - -.fc td, -.fc th { - padding: 0; - vertical-align: top; - } +} +.fc-rtl { + text-align: right; +} +body .fc { /* extra precedence to overcome jqui */ + font-size: 1em; +} -/* Header -------------------------------------------------------------------------*/ -.fc-header td { - white-space: nowrap; - } +/* Colors +--------------------------------------------------------------------------------------------------*/ -.fc-header-left { - width: 25%; - text-align: left; - } - -.fc-header-center { - text-align: center; - } - -.fc-header-right { - width: 25%; - text-align: right; - } - -.fc-header-title { - display: inline-block; - vertical-align: top; - } - -.fc-header-title h2 { - margin-top: 0; - white-space: nowrap; - } - -.fc .fc-header-space { - padding-left: 10px; - } - -.fc-header .fc-button { - margin-bottom: 1em; - vertical-align: top; - } - -/* buttons edges butting together */ +.fc-unthemed th, +.fc-unthemed td, +.fc-unthemed thead, +.fc-unthemed tbody, +.fc-unthemed .fc-divider, +.fc-unthemed .fc-row, +.fc-unthemed .fc-popover { + border-color: #ddd; +} -.fc-header .fc-button { - margin-right: -1px; - } - -.fc-header .fc-corner-right, /* non-theme */ -.fc-header .ui-corner-right { /* theme */ - margin-right: 0; /* back to normal */ - } - -/* button layering (for border precedence) */ - -.fc-header .fc-state-hover, -.fc-header .ui-state-hover { - z-index: 2; - } - -.fc-header .fc-state-down { - z-index: 3; - } +.fc-unthemed .fc-popover { + background-color: #fff; +} -.fc-header .fc-state-active, -.fc-header .ui-state-active { - z-index: 4; - } - - - -/* Content -------------------------------------------------------------------------*/ - -.fc-content { - position: relative; - z-index: 1; /* scopes all other z-index's to be inside this container */ - clear: both; - zoom: 1; /* for IE7, gives accurate coordinates for [un]freezeContentHeight */ - } - -.fc-view { - position: relative; - width: 100%; - overflow: hidden; - } - - +.fc-unthemed .fc-divider, +.fc-unthemed .fc-popover .fc-header { + background: #eee; +} -/* Cell Styles -------------------------------------------------------------------------*/ +.fc-unthemed .fc-popover .fc-header .fc-close { + color: #666; +} -.fc-widget-header, /* , usually */ -.fc-widget-content { /* , usually */ - border: 1px solid #ddd; - } - -.fc-state-highlight { /* today cell */ /* TODO: add .fc-today to */ +.fc-unthemed .fc-today { background: #fcf8e3; - } - -.fc-cell-overlay { /* semi-transparent rectangle while dragging */ +} + +.fc-highlight { /* when user is selecting cells */ background: #bce8f1; opacity: .3; filter: alpha(opacity=30); /* for IE */ - } - +} +.fc-bgevent { /* default look for background events */ + background: rgb(143, 223, 130); + opacity: .3; + filter: alpha(opacity=30); /* for IE */ +} -/* Buttons -------------------------------------------------------------------------*/ +.fc-nonbusiness { /* default look for non-business-hours areas */ + /* will inherit .fc-bgevent's styles */ + background: #d7d7d7; +} -.fc-button { - position: relative; + +/* Icons (inline elements with styled text that mock arrow icons) +--------------------------------------------------------------------------------------------------*/ + +.fc-icon { display: inline-block; - padding: 0 .6em; + width: 1em; + height: 1em; + line-height: 1em; + font-size: 1em; + text-align: center; overflow: hidden; - height: 1.9em; - line-height: 1.9em; - white-space: nowrap; - cursor: pointer; - } - -.fc-state-default { /* non-theme */ - border: 1px solid; - } - -.fc-state-default.fc-corner-left { /* non-theme */ - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - } + font-family: "Courier New", Courier, monospace; -.fc-state-default.fc-corner-right { /* non-theme */ - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; + /* don't allow browser text-selection */ + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } /* - Our default prev/next buttons use HTML entities like ‹ › « » - and we'll try to make them look good cross-browser. +Acceptable font-family overrides for individual icons: + "Arial", sans-serif + "Times New Roman", serif + +NOTE: use percentage font sizes or else old IE chokes */ -.fc-button .fc-icon { - margin: 0 .1em; - font-size: 2em; - font-family: "Courier New", Courier, monospace; - vertical-align: baseline; /* for IE7 */ - } +.fc-icon:after { + position: relative; + margin: 0 -1em; /* ensures character will be centered, regardless of width */ +} .fc-icon-left-single-arrow:after { content: "\02039"; font-weight: bold; - } + font-size: 200%; + top: -7%; + left: 3%; +} .fc-icon-right-single-arrow:after { content: "\0203A"; font-weight: bold; - } + font-size: 200%; + top: -7%; + left: -3%; +} .fc-icon-left-double-arrow:after { content: "\000AB"; - } + font-size: 160%; + top: -7%; +} .fc-icon-right-double-arrow:after { content: "\000BB"; - } + font-size: 160%; + top: -7%; +} + +.fc-icon-left-triangle:after { + content: "\25C4"; + font-size: 125%; + top: 3%; + left: -2%; +} + +.fc-icon-right-triangle:after { + content: "\25BA"; + font-size: 125%; + top: 3%; + left: 2%; +} + +.fc-icon-down-triangle:after { + content: "\25BC"; + font-size: 125%; + top: 2%; +} + +.fc-icon-x:after { + content: "\000D7"; + font-size: 200%; + top: 6%; +} + + +/* Buttons (styled ' + ) + .click(function() { + // don't process clicks for disabled buttons + if (!button.hasClass(tm + '-state-disabled')) { - for (col=0; col" + - "
" + - "
" + - "
 
" + - "
" + - "
" + - ""; + if (isOnlyButtons) { + groupChildren + .first().addClass(tm + '-corner-left').end() + .last().addClass(tm + '-corner-right').end(); + } - cellsHTML += cellHTML; + if (groupChildren.length > 1) { + groupEl = $('
'); + if (isOnlyButtons) { + groupEl.addClass('fc-button-group'); + } + groupEl.append(groupChildren); + sectionEl.append(groupEl); + } + else { + sectionEl.append(groupChildren); // 1 or 0 children + } + }); } - html += cellsHTML; - html += - " " + - "" + - ""; - - return html; + return sectionEl; } - - - // TODO: data-date on the cells - - /* Dimensions - -----------------------------------------------------------------------*/ - + function updateTitle(text) { + el.find('h2').text(text); + } - function setHeight(height) { - if (height === undefined) { - height = viewHeight; - } - viewHeight = height; - slotTopCache = {}; - var headHeight = dayBody.position().top; - var allDayHeight = slotScroller.position().top; // including divider - var bodyHeight = Math.min( // total body height, including borders - height - headHeight, // when scrollbars - slotTable.height() + allDayHeight + 1 // when no scrollbars. +1 for bottom border - ); - - dayBodyFirstCellStretcher - .height(bodyHeight - vsides(dayBodyFirstCell)); - - slotLayer.css('top', headHeight); - - slotScroller.height(bodyHeight - allDayHeight - 1); - - // the stylesheet guarantees that the first row has no border. - // this allows .height() to work well cross-browser. - var slotHeight0 = slotTable.find('tr:first').height() + 1; // +1 for bottom border - var slotHeight1 = slotTable.find('tr:eq(1)').height(); - // HACK: i forget why we do this, but i think a cross-browser issue - slotHeight = (slotHeight0 + slotHeight1) / 2; - - snapRatio = slotDuration / snapDuration; - snapHeight = slotHeight / snapRatio; + function activateButton(buttonName) { + el.find('.fc-' + buttonName + '-button') + .addClass(tm + '-state-active'); + } + + + function deactivateButton(buttonName) { + el.find('.fc-' + buttonName + '-button') + .removeClass(tm + '-state-active'); } - function setWidth(width) { - viewWidth = width; - colPositions.clear(); - colContentPositions.clear(); - - var axisFirstCells = dayHead.find('th:first'); - if (allDayTable) { - axisFirstCells = axisFirstCells.add(allDayTable.find('th:first')); - } - axisFirstCells = axisFirstCells.add(slotTable.find('th:first')); - - axisWidth = 0; - setOuterWidth( - axisFirstCells - .width('') - .each(function(i, _cell) { - axisWidth = Math.max(axisWidth, $(_cell).outerWidth()); - }), - axisWidth - ); - - var gutterCells = dayTable.find('.fc-agenda-gutter'); - if (allDayTable) { - gutterCells = gutterCells.add(allDayTable.find('th.fc-agenda-gutter')); - } - - var slotTableWidth = slotScroller[0].clientWidth; // needs to be done after axisWidth (for IE7) - - gutterWidth = slotScroller.width() - slotTableWidth; - if (gutterWidth) { - setOuterWidth(gutterCells, gutterWidth); - gutterCells - .show() - .prev() - .removeClass('fc-last'); - }else{ - gutterCells - .hide() - .prev() - .addClass('fc-last'); - } - - colWidth = Math.floor((slotTableWidth - axisWidth) / colCnt); - setOuterWidth(dayHeadCells.slice(0, -1), colWidth); + function disableButton(buttonName) { + el.find('.fc-' + buttonName + '-button') + .attr('disabled', 'disabled') + .addClass(tm + '-state-disabled'); } + + function enableButton(buttonName) { + el.find('.fc-' + buttonName + '-button') + .removeAttr('disabled') + .removeClass(tm + '-state-disabled'); + } - /* Scrolling - -----------------------------------------------------------------------*/ + function getViewsWithButtons() { + return viewsWithButtons; + } +} - function resetScroll() { - var top = computeTimeTop( - moment.duration(opt('scrollTime')) - ) + 1; // +1 for the border +;; - function scroll() { - slotScroller.scrollTop(top); - } +fc.sourceNormalizers = []; +fc.sourceFetchers = []; - scroll(); - setTimeout(scroll, 0); // overrides any previous scroll state made by the browser - } +var ajaxDefaults = { + dataType: 'json', + cache: false +}; + +var eventGUID = 1; - function afterRender() { // after the view has been freshly rendered and sized - resetScroll(); - } +function EventManager(options) { // assumed to be a calendar + var t = this; + // exports + t.isFetchNeeded = isFetchNeeded; + t.fetchEvents = fetchEvents; + t.addEventSource = addEventSource; + t.removeEventSource = removeEventSource; + t.updateEvent = updateEvent; + t.renderEvent = renderEvent; + t.removeEvents = removeEvents; + t.clientEvents = clientEvents; + t.mutateEvent = mutateEvent; + t.normalizeEventRange = normalizeEventRange; + t.normalizeEventRangeTimes = normalizeEventRangeTimes; + t.ensureVisibleEventRange = ensureVisibleEventRange; - /* Slot/Day clicking and binding - -----------------------------------------------------------------------*/ - - function dayBind(cells) { - cells.click(slotClick) - .mousedown(daySelectionMousedown); - } + // imports + var reportEvents = t.reportEvents; + + + // locals + var stickySource = { events: [] }; + var sources = [ stickySource ]; + var rangeStart, rangeEnd; + var currentFetchID = 0; + var pendingSourceCnt = 0; + var cache = []; // holds events that have already been expanded - function slotBind(cells) { - cells.click(slotClick) - .mousedown(slotSelectionMousedown); + $.each( + (options.events ? [ options.events ] : []).concat(options.eventSources || []), + function(i, sourceInput) { + var source = buildEventSource(sourceInput); + if (source) { + sources.push(source); + } + } + ); + + + + /* Fetching + -----------------------------------------------------------------------------*/ + + + 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(); } - function slotClick(ev) { - if (!opt('selectable')) { // if selectable, SelectionManager will worry about dayClick - var col = Math.min(colCnt-1, Math.floor((ev.pageX - dayTable.offset().left - axisWidth) / colWidth)); - var date = cellToDate(0, col); - var match = this.parentNode.className.match(/fc-slot(\d+)/); // TODO: maybe use data - if (match) { - var slotIndex = parseInt(match[1], 10); - date.add(minTime + slotIndex * slotDuration); - date = calendar.rezoneDate(date); - trigger( - 'dayClick', - dayBodyCells[col], - date, - ev - ); - }else{ - trigger( - 'dayClick', - dayBodyCells[col], - date, - ev - ); - } + function fetchEvents(start, end) { + rangeStart = start; + rangeEnd = end; + cache = []; + var fetchID = ++currentFetchID; + var len = sources.length; + pendingSourceCnt = len; + for (var i=0; i= 0) { - date.time(moment.duration(minTime + snapIndex * snapDuration)); - date = calendar.rezoneDate(date); + else { + event.end = null; } - return date; + mutateEvent(event, getMiscEventProps(event)); // will handle start/end/allDay normalization + reportEvents(cache); // reports event modifications (so we can redraw) } - function computeDateTop(date, startOfDayDate) { - return computeTimeTop( - moment.duration( - date.clone().stripZone() - startOfDayDate.clone().stripTime() - ) - ); - } + // Returns a hash of misc event properties that should be copied over to related events. + function getMiscEventProps(event) { + var props = {}; + $.each(event, function(name, val) { + if (isMiscEventPropName(name)) { + if (val !== undefined && isAtomic(val)) { // a defined non-object + props[name] = val; + } + } + }); - function computeTimeTop(time) { // time is a duration + return props; + } - if (time < minTime) { - return 0; - } - if (time >= maxTime) { - return slotTable.height(); - } + // non-date-related, non-id-related, non-secret + function isMiscEventPropName(name) { + return !/^_|^(id|allDay|start|end)$/.test(name); + } - var slots = (time - minTime) / slotDuration; - var slotIndex = Math.floor(slots); - var slotPartial = slots - slotIndex; - var slotTop = slotTopCache[slotIndex]; + + // returns the expanded events that were created + function renderEvent(eventInput, stick) { + var abstractEvent = buildEventFromInput(eventInput); + var events; + var i, event; - // find the position of the corresponding - // need to use this tecnhique because not all rows are rendered at same height sometimes. - if (slotTop === undefined) { - slotTop = slotTopCache[slotIndex] = - slotTable.find('tr').eq(slotIndex).find('td div')[0].offsetTop; - // .eq() is faster than ":eq()" selector - // [0].offsetTop is faster than .position().top (do we really need this optimization?) - // a better optimization would be to cache all these divs - } + if (abstractEvent) { // not false (a valid input) + events = expandEvent(abstractEvent); - var top = - slotTop - 1 + // because first row doesn't have a top border - slotPartial * slotHeight; // part-way through the row + for (i = 0; i < events.length; i++) { + event = events[i]; - top = Math.max(top, 0); + if (!event.source) { + if (stick) { + stickySource.events.push(event); + event.source = stickySource; + } + cache.push(event); + } + } - return top; - } - - - - /* Selection - ---------------------------------------------------------------------------------*/ + reportEvents(cache); - - function defaultSelectionEnd(start) { - if (start.hasTime()) { - return start.clone().add(slotDuration); - } - else { - return start.clone().add('days', 1); + return events; } + + return []; } - function renderSelection(start, end) { - if (start.hasTime() || end.hasTime()) { - renderSlotSelection(start, end); + function removeEvents(filter) { + var eventID; + var i; + + if (filter == null) { // null or undefined. remove all events + filter = function() { return true; }; // will always match } - else if (opt('allDaySlot')) { - renderDayOverlay(start, end, true); // true for refreshing coordinate grid + else if (!$.isFunction(filter)) { // an event ID + eventID = filter + ''; + filter = function(event) { + return event._id == eventID; + }; } - } - - - function renderSlotSelection(startDate, endDate) { - var helperOption = opt('selectHelper'); - coordinateGrid.build(); - if (helperOption) { - var col = dateToCell(startDate).col; - if (col >= 0 && col < colCnt) { // only works when times are on same day - var rect = coordinateGrid.rect(0, col, 0, col, slotContainer); // only for horizontal coords - var top = computeDateTop(startDate, startDate); - var bottom = computeDateTop(endDate, startDate); - if (bottom > top) { // protect against selections that are entirely before or after visible range - rect.top = top; - rect.height = bottom - top; - rect.left += 2; - rect.width -= 5; - if ($.isFunction(helperOption)) { - var helperRes = helperOption(startDate, endDate); - if (helperRes) { - rect.position = 'absolute'; - selectionHelper = $(helperRes) - .css(rect) - .appendTo(slotContainer); - } - }else{ - rect.isStart = true; // conside rect a "seg" now - rect.isEnd = true; // - selectionHelper = $(slotSegHtml( - { - title: '', - start: startDate, - end: endDate, - className: ['fc-select-helper'], - editable: false - }, - rect - )); - selectionHelper.css('opacity', opt('dragOpacity')); - } - if (selectionHelper) { - slotBind(selectionHelper); - slotContainer.append(selectionHelper); - setOuterWidth(selectionHelper, rect.width, true); // needs to be after appended - setOuterHeight(selectionHelper, rect.height, true); - } - } + + // Purge event(s) from our local cache + cache = $.grep(cache, filter, true); // inverse=true + + // Remove events from array sources. + // This works because they have been converted to official Event Objects up front. + // (and as a result, event._id has been calculated). + for (i=0; i rangeStart && eventStart < rangeEnd) { - if (eventStart < rangeStart) { - segStart = rangeStart.clone(); - isStart = false; - } - else { - segStart = eventStart; - isStart = true; - } + // 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) { - if (eventEnd > rangeEnd) { - segEnd = rangeEnd.clone(); - isEnd = false; - } - else { - segEnd = eventEnd; - isEnd = true; - } + normalizeEventRangeTimes(props); - segs.push({ - event: event, - start: segStart, - end: segEnd, - isStart: isStart, - isEnd: isEnd - }); - } + if (props.end && !props.end.isAfter(props.start)) { + props.end = null; } - return segs.sort(compareSlotSegs); - } - - - // renders events in the 'time slots' at the bottom - // TODO: when we refactor this, when user returns `false` eventRender, don't have empty space - // TODO: refactor will include using pixels to detect collisions instead of dates (handy for seg cmp) - - function renderSlotSegs(segs, modifiedEventId) { - - var i, segCnt=segs.length, seg, - event, - top, - bottom, - columnLeft, - columnRight, - columnWidth, - width, - left, - right, - html = '', - eventElements, - eventElement, - triggerRes, - titleElement, - height, - slotSegmentContainer = getSlotSegmentContainer(), - isRTL = opt('isRTL'); - - // calculate position/dimensions, create html - for (i=0; i" + - "
" + - "
" + - htmlEscape(t.getEventTimeText(event)) + - "
" + - "
" + - htmlEscape(event.title || '') + - "
" + - "
" + - "
"; - - if (seg.isEnd && isEventResizable(event)) { - html += - "
=
"; - } - html += - ""; - return html; } - - - function bindSlotSeg(event, eventElement, seg) { - var timeElement = eventElement.find('div.fc-event-time'); - if (isEventDraggable(event)) { - draggableSlotEvent(event, eventElement, timeElement); - } - if (seg.isEnd && isEventResizable(event)) { - resizableSlotEvent(event, eventElement, timeElement); + + + // 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); } - eventElementHandlers(event, eventElement); + return range; } - - - - /* Dragging - -----------------------------------------------------------------------------------*/ - - - // when event starts out FULL-DAY - // overrides DayEventRenderer's version because it needs to account for dragging elements - // to and from the slot area. - - function draggableDayEvent(event, eventElement, seg) { - var isStart = seg.isStart; - var origWidth; - var revert; - var allDay = true; - var dayDelta; - - var hoverListener = getHoverListener(); - var colWidth = getColWidth(); - var minTime = getMinTime(); - var slotDuration = getSlotDuration(); - var slotHeight = getSlotHeight(); - var snapDuration = getSnapDuration(); - var snapHeight = getSnapHeight(); - - eventElement.draggable({ - opacity: opt('dragOpacity', 'month'), // use whatever the month view was using - revertDuration: opt('dragRevertDuration'), - start: function(ev, ui) { - - trigger('eventDragStart', eventElement[0], event, ev, ui); - hideEvents(event, eventElement); - origWidth = eventElement.width(); - - hoverListener.start(function(cell, origCell) { - clearOverlays(); - if (cell) { - revert = false; - - var origDate = cellToDate(0, origCell.col); - var date = cellToDate(0, cell.col); - dayDelta = date.diff(origDate, 'days'); - - if (!cell.row) { // on full-days - - renderDayOverlay( - event.start.clone().add('days', dayDelta), - getEventEnd(event).add('days', dayDelta) - ); - resetElement(); - } - else { // mouse is over bottom slots - - if (isStart) { - if (allDay) { - // convert event to temporary slot-event - eventElement.width(colWidth - 10); // don't use entire width - setOuterHeight(eventElement, calendar.defaultTimedEventDuration / slotDuration * slotHeight); // the default height - eventElement.draggable('option', 'grid', [ colWidth, 1 ]); - allDay = false; - } - } - else { - revert = true; - } - } - revert = revert || (allDay && !dayDelta); - } - else { - resetElement(); - revert = true; - } + // 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. + // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours). + function expandEvent(abstractEvent, _rangeStart, _rangeEnd) { + var events = []; + var dowHash; + var dow; + var i; + var date; + var startTime, endTime; + var start, end; + var event; + + _rangeStart = _rangeStart || rangeStart; + _rangeEnd = _rangeEnd || rangeEnd; - eventElement.draggable('option', 'revert', revert); + if (abstractEvent) { + if (abstractEvent._recurring) { - }, ev, 'drag'); - }, - stop: function(ev, ui) { - hoverListener.stop(); - clearOverlays(); - trigger('eventDragStop', eventElement[0], event, ev, ui); - - if (revert) { // hasn't moved or is out of bounds (draggable has already reverted) - - resetElement(); - eventElement.css('filter', ''); // clear IE opacity side-effects - showEvents(event, eventElement); + // make a boolean hash as to whether the event occurs on each day-of-week + if ((dow = abstractEvent.dow)) { + dowHash = {}; + for (i = 0; i < dow.length; i++) { + dowHash[dow[i]] = true; + } } - else { // changed! - - var eventStart = event.start.clone().add('days', dayDelta); // already assumed to have a stripped time - var snapTime; - var snapIndex; - if (!allDay) { - snapIndex = Math.round((eventElement.offset().top - getSlotContainer().offset().top) / snapHeight); // why not use ui.offset.top? - snapTime = moment.duration(minTime + snapIndex * snapDuration); - eventStart = calendar.rezoneDate(eventStart.clone().time(snapTime)); + + // iterate through every day in the current range + date = _rangeStart.clone().stripTime(); // holds the date of the current day + while (date.isBefore(_rangeEnd)) { + + if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week + + startTime = abstractEvent.start; // the stored start and end properties are times (Durations) + endTime = abstractEvent.end; // " + start = date.clone(); + end = null; + + if (startTime) { + start = start.time(startTime); + } + if (endTime) { + end = date.clone().time(endTime); + } + + event = $.extend({}, abstractEvent); // make a copy of the original + assignDatesToEvent( + start, end, + !startTime && !endTime, // allDay? + event + ); + events.push(event); } - eventDrop( - eventElement[0], - event, - eventStart, - ev, - ui - ); + date.add(1, 'days'); } } - }); - function resetElement() { - if (!allDay) { - eventElement - .width(origWidth) - .height('') - .draggable('option', 'grid', null); - allDay = true; + else { + events.push(abstractEvent); // return the original event. will be a one-item array } } + + return events; } - - - // when event starts out IN TIMESLOTS - - function draggableSlotEvent(event, eventElement, timeElement) { - var coordinateGrid = t.getCoordinateGrid(); - var colCnt = getColCnt(); - var colWidth = getColWidth(); - var snapHeight = getSnapHeight(); - var snapDuration = getSnapDuration(); - - // states - var origPosition; // original position of the element, not the mouse - var origCell; - var isInBounds, prevIsInBounds; - var isAllDay, prevIsAllDay; - var colDelta, prevColDelta; - var dayDelta; // derived from colDelta - var snapDelta, prevSnapDelta; // the number of snaps away from the original position - - // newly computed - var eventStart, eventEnd; - eventElement.draggable({ - scroll: false, - grid: [ colWidth, snapHeight ], - axis: colCnt==1 ? 'y' : false, - opacity: opt('dragOpacity'), - revertDuration: opt('dragRevertDuration'), - start: function(ev, ui) { - - trigger('eventDragStart', eventElement[0], event, ev, ui); - hideEvents(event, eventElement); - - coordinateGrid.build(); - - // initialize states - origPosition = eventElement.position(); - origCell = coordinateGrid.cell(ev.pageX, ev.pageY); - isInBounds = prevIsInBounds = true; - isAllDay = prevIsAllDay = getIsCellAllDay(origCell); - colDelta = prevColDelta = 0; - dayDelta = 0; - snapDelta = prevSnapDelta = 0; - - eventStart = null; - eventEnd = null; - }, - drag: function(ev, ui) { - - // NOTE: this `cell` value is only useful for determining in-bounds and all-day. - // Bad for anything else due to the discrepancy between the mouse position and the - // element position while snapping. (problem revealed in PR #55) - // - // PS- the problem exists for draggableDayEvent() when dragging an all-day event to a slot event. - // We should overhaul the dragging system and stop relying on jQuery UI. - var cell = coordinateGrid.cell(ev.pageX, ev.pageY); - - // update states - isInBounds = !!cell; - if (isInBounds) { - isAllDay = getIsCellAllDay(cell); - - // calculate column delta - colDelta = Math.round((ui.position.left - origPosition.left) / colWidth); - if (colDelta != prevColDelta) { - // calculate the day delta based off of the original clicked column and the column delta - var origDate = cellToDate(0, origCell.col); - var col = origCell.col + colDelta; - col = Math.max(0, col); - col = Math.min(colCnt-1, col); - var date = cellToDate(0, col); - dayDelta = date.diff(origDate, 'days'); - } - // calculate minute delta (only if over slots) - if (!isAllDay) { - snapDelta = Math.round((ui.position.top - origPosition.top) / snapHeight); - } - } - // any state changes? - if ( - isInBounds != prevIsInBounds || - isAllDay != prevIsAllDay || - colDelta != prevColDelta || - snapDelta != prevSnapDelta - ) { - - // compute new dates - if (isAllDay) { - eventStart = event.start.clone().stripTime().add('days', dayDelta); - eventEnd = eventStart.clone().add(calendar.defaultAllDayEventDuration); - } - else { - eventStart = event.start.clone().add(snapDelta * snapDuration).add('days', dayDelta); - eventEnd = getEventEnd(event).add(snapDelta * snapDuration).add('days', dayDelta); - } + /* Event Modification Math + -----------------------------------------------------------------------------------------*/ - updateUI(); - // update previous states for next time - prevIsInBounds = isInBounds; - prevIsAllDay = isAllDay; - prevColDelta = colDelta; - prevSnapDelta = snapDelta; - } + // Modifies an event and all related events by applying the given properties. + // Special date-diffing logic is used for manipulation of dates. + // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end. + // All date comparisons are done against the event's pristine _start and _end dates. + // Returns an object with delta information and a function to undo all operations. + // For making computations in a granularity greater than day/time, specify largeUnit. + // NOTE: The given `newProps` might be mutated for normalization purposes. + function mutateEvent(event, newProps, largeUnit) { + var miscProps = {}; + var oldProps; + var clearEnd; + var startDelta; + var endDelta; + var durationDelta; + var undoFunc; + + // diffs the dates in the appropriate way, returning a duration + function diffDates(date1, date0) { // date1 - date0 + if (largeUnit) { + return diffByUnit(date1, date0, largeUnit); + } + else if (newProps.allDay) { + return diffDay(date1, date0); + } + else { + return diffDayTime(date1, date0); + } + } - // if out-of-bounds, revert when done, and vice versa. - eventElement.draggable('option', 'revert', !isInBounds); + newProps = newProps || {}; - }, - stop: function(ev, ui) { - - clearOverlays(); - trigger('eventDragStop', eventElement[0], event, ev, ui); - - if (isInBounds && (isAllDay || dayDelta || snapDelta)) { // changed! - eventDrop( - eventElement[0], - event, - eventStart, - ev, - ui - ); - } - else { // either no change or out-of-bounds (draggable has already reverted) + // normalize new date-related properties + if (!newProps.start) { + newProps.start = event.start.clone(); + } + if (newProps.end === undefined) { + newProps.end = event.end ? event.end.clone() : null; + } + if (newProps.allDay == null) { // is null or undefined? + newProps.allDay = event.allDay; + } + normalizeEventRange(newProps); - // reset states for next time, and for updateUI() - isInBounds = true; - isAllDay = false; - colDelta = 0; - dayDelta = 0; - snapDelta = 0; + // create normalized versions of the original props to compare against + // need a real end value, for diffing + oldProps = { + start: event._start.clone(), + 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); + + // need to clear the end date if explicitly changed to null + clearEnd = event._end !== null && newProps.end === null; - updateUI(); - eventElement.css('filter', ''); // clear IE opacity side-effects + // compute the delta for moving the start date + startDelta = diffDates(newProps.start, oldProps.start); - // sometimes fast drags make event revert to wrong position, so reset. - // also, if we dragged the element out of the area because of snapping, - // but the *mouse* is still in bounds, we need to reset the position. - eventElement.css(origPosition); + // compute the delta for moving the end date + if (newProps.end) { + endDelta = diffDates(newProps.end, oldProps.end); + durationDelta = endDelta.subtract(startDelta); + } + else { + durationDelta = null; + } - showEvents(event, eventElement); + // gather all non-date-related properties + $.each(newProps, function(name, val) { + if (isMiscEventPropName(name)) { + if (val !== undefined) { + miscProps[name] = val; } } }); - function updateUI() { - clearOverlays(); - if (isInBounds) { - if (isAllDay) { - timeElement.hide(); - eventElement.draggable('option', 'grid', null); // disable grid snapping - renderDayOverlay(eventStart, eventEnd); - } - else { - updateTimeText(); - timeElement.css('display', ''); // show() was causing display=inline - eventElement.draggable('option', 'grid', [colWidth, snapHeight]); // re-enable grid snapping + // apply the operations to the event and all related events + undoFunc = mutateEvents( + clientEvents(event._id), // get events with this ID + clearEnd, + newProps.allDay, + startDelta, + durationDelta, + miscProps + ); + + return { + dateDelta: startDelta, + durationDelta: durationDelta, + undo: undoFunc + }; + } + + + // Modifies an array of events in the following ways (operations are in order): + // - clear the event's `end` + // - convert the event to allDay + // - add `dateDelta` to the start and end + // - add `durationDelta` to the event's duration + // - assign `miscProps` to the event + // + // Returns a function that can be called to undo all the operations. + // + // TODO: don't use so many closures. possible memory issues when lots of events with same ID. + // + function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) { + var isAmbigTimezone = t.getIsAmbigTimezone(); + var undoFunctions = []; + + // normalize zero-length deltas to be null + if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; } + if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; } + + $.each(events, function(i, event) { + var oldProps; + var newProps; + + // build an object holding all the old values, both date-related and misc. + // for the undo function. + oldProps = { + start: event.start.clone(), + end: event.end ? event.end.clone() : null, + allDay: event.allDay + }; + $.each(miscProps, function(name) { + oldProps[name] = event[name]; + }); + + // new date-related properties. work off the original date snapshot. + // ok to use references because they will be thrown away when backupEventDates is called. + newProps = { + start: event._start, + end: event._end, + allDay: allDay // normalize the dates in the same regard as the new properties + }; + normalizeEventRange(newProps); // massages start/end/allDay + + // strip or ensure the end date + if (clearEnd) { + newProps.end = null; + } + else if (durationDelta && !newProps.end) { // the duration translation requires an end date + newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start); + } + + if (dateDelta) { + newProps.start.add(dateDelta); + if (newProps.end) { + newProps.end.add(dateDelta); } } - } - function updateTimeText() { - if (eventStart) { // must of had a state change - timeElement.text( - t.getEventTimeText(eventStart, event.end ? eventEnd : null) - // ^ - // only display the new end if there was an old end - ); + if (durationDelta) { + newProps.end.add(durationDelta); // end already ensured above } - } - } - - - - /* Resizing - --------------------------------------------------------------------------------------*/ - - - function resizableSlotEvent(event, eventElement, timeElement) { - var snapDelta, prevSnapDelta; - var snapHeight = getSnapHeight(); - var snapDuration = getSnapDuration(); - var eventEnd; - - eventElement.resizable({ - handles: { - s: '.ui-resizable-handle' - }, - grid: snapHeight, - start: function(ev, ui) { - snapDelta = prevSnapDelta = 0; - hideEvents(event, eventElement); - trigger('eventResizeStart', eventElement[0], event, ev, ui); - }, - resize: function(ev, ui) { - // don't rely on ui.size.height, doesn't take grid into account - snapDelta = Math.round((Math.max(snapHeight, eventElement.height()) - ui.originalSize.height) / snapHeight); - if (snapDelta != prevSnapDelta) { - eventEnd = getEventEnd(event).add(snapDuration * snapDelta); - var text; - if (snapDelta) { // has there been a change? - text = t.getEventTimeText(event.start, eventEnd); - } - else { - text = t.getEventTimeText(event); // the original time text - } - timeElement.text(text); - prevSnapDelta = snapDelta; - } - }, - stop: function(ev, ui) { - trigger('eventResizeStop', eventElement[0], event, ev, ui); - if (snapDelta) { - eventResize( - eventElement[0], - event, - eventEnd, - ev, - ui - ); - } - else { - showEvents(event, eventElement); - // BUG: if event was really short, need to put title back in span + // if the dates have changed, and we know it is impossible to recompute the + // timezone offsets, strip the zone. + if ( + isAmbigTimezone && + !newProps.allDay && + (dateDelta || durationDelta) + ) { + newProps.start.stripZone(); + if (newProps.end) { + newProps.end.stripZone(); } } + + $.extend(event, miscProps, newProps); // copy over misc props, then date-related props + backupEventDates(event); // regenerate internal _start/_end/_allDay + + undoFunctions.push(function() { + $.extend(event, oldProps); + backupEventDates(event); // regenerate internal _start/_end/_allDay + }); }); - } - -} + return function() { + for (var i = 0; i < undoFunctions.length; i++) { + undoFunctions[i](); + } + }; + } + /* Business Hours + -----------------------------------------------------------------------------------------*/ -/* Agenda Event Segment Utilities ------------------------------------------------------------------------------*/ + t.getBusinessHoursEvents = getBusinessHoursEvents; -// Sets the seg.backwardCoord and seg.forwardCoord on each segment and returns a new -// list in the order they should be placed into the DOM (an implicit z-index). -function placeSlotSegs(segs) { - var levels = buildSlotSegLevels(segs); - var level0 = levels[0]; - var i; + // Returns an array of events as to when the business hours occur in the given view. + // Abuse of our event system :( + function getBusinessHoursEvents(wholeDay) { + var optionVal = options.businessHours; + var defaultVal = { + className: 'fc-nonbusiness', + start: '09:00', + end: '17:00', + dow: [ 1, 2, 3, 4, 5 ], // monday - friday + rendering: 'inverse-background' + }; + var view = t.getView(); + var eventInput; + + if (optionVal) { // `true` (which means "use the defaults") or an override object + eventInput = $.extend( + {}, // copy to a new object in either case + defaultVal, + typeof optionVal === 'object' ? optionVal : {} // override the defaults + ); + } - computeForwardSlotSegs(levels); + if (eventInput) { - if (level0) { + // if a whole-day series is requested, clear the start/end times + if (wholeDay) { + eventInput.start = null; + eventInput.end = null; + } - for (i=0; i= eventStart && range.end <= eventEnd; + } - for (j=0; j eventStart; } - return segs; + + t.getEventCache = function() { + return cache; + }; + } -// Find all the segments in `otherSegs` that vertically collide with `seg`. -// Append into an optionally-supplied `results` array and return. -function computeSlotSegCollisions(seg, otherSegs, results) { - results = results || []; +// 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) { + var cache = this.getEventCache(); + var peerEvents = []; + var i, otherEvent; - for (var i=0; i seg2.start && seg1.start < seg2.end; +// updates the "backup" properties, which are preserved in order to compute diffs later on. +function backupEventDates(event) { + event._allDay = event.allDay; + event._start = event.start.clone(); + event._end = event.end ? event.end.clone() : null; } +;; -// A cmp function for determining which forward segment to rely on more when computing coordinates. -function compareForwardSlotSegs(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... - compareSlotSegs(seg1, seg2); -} - +/* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells. +----------------------------------------------------------------------------------------------------------------------*/ +// It is a manager for a DayGrid subcomponent, which does most of the heavy lifting. +// It is responsible for managing width/height. -// A cmp function for determining which segment should be closer to the initial edge -// (the left edge on a left-to-right calendar). -function compareSlotSegs(seg1, seg2) { - return seg1.start - seg2.start || // earlier start time goes first - (seg2.end - seg2.start) - (seg1.end - seg1.start) || // tie? longer-duration goes first - (seg1.event.title || '').localeCompare(seg2.event.title); // tie? alphabetically by title -} +var BasicView = View.extend({ + dayGrid: null, // the main subcomponent that does most of the heavy lifting -;; + dayNumbersVisible: false, // display day numbers on each day cell? + weekNumbersVisible: false, // display week numbers along the side? + weekNumberWidth: null, // width of all the week-number cells running down the side -function View(element, calendar, viewName) { - var t = this; - - - // exports - t.element = element; - t.calendar = calendar; - t.name = viewName; - t.opt = opt; - t.trigger = trigger; - t.isEventDraggable = isEventDraggable; - t.isEventResizable = isEventResizable; - t.clearEventData = clearEventData; - t.reportEventElement = reportEventElement; - t.triggerEventDestroy = triggerEventDestroy; - t.eventElementHandlers = eventElementHandlers; - t.showEvents = showEvents; - t.hideEvents = hideEvents; - t.eventDrop = eventDrop; - t.eventResize = eventResize; - // t.start, t.end // moments with ambiguous-time - // t.intervalStart, t.intervalEnd // moments with ambiguous-time - - - // imports - var reportEventChange = calendar.reportEventChange; - - - // locals - var eventElementsByID = {}; // eventID mapped to array of jQuery elements - var eventElementCouples = []; // array of objects, { event, element } // TODO: unify with segment system - var options = calendar.options; - var nextDayThreshold = moment.duration(options.nextDayThreshold); + headRowEl: null, // the fake row element of the day-of-week header - - - - function opt(name, viewNameOverride) { - var v = options[name]; - if ($.isPlainObject(v) && !isForcedAtomicOption(name)) { - return smartProperty(v, viewNameOverride || viewName); - } - return v; - } - - function trigger(name, thisObj) { - return calendar.trigger.apply( - calendar, - [name, thisObj || t].concat(Array.prototype.slice.call(arguments, 2), [t]) - ); - } - + initialize: function() { + this.dayGrid = new DayGrid(this); + this.coordMap = this.dayGrid.coordMap; // the view's date-to-cell mapping is identical to the subcomponent's + }, - /* Event Editable Boolean Calculations - ------------------------------------------------------------------------------*/ + // Sets the display range and computes all necessary dates + setRange: function(range) { + View.prototype.setRange.call(this, range); // call the super-method - - function isEventDraggable(event) { - var source = event.source || {}; - return firstDefined( - event.startEditable, - source.startEditable, - opt('eventStartEditable'), - event.editable, - source.editable, - opt('editable') - ); - } - - - function isEventResizable(event) { // but also need to make sure the seg.isEnd == true - var source = event.source || {}; - return firstDefined( - event.durationEditable, - source.durationEditable, - opt('eventDurationEditable'), - event.editable, - source.editable, - opt('editable') - ); - } - - - - /* Event Data - ------------------------------------------------------------------------------*/ + this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange + this.dayGrid.setRange(range); + }, - function clearEventData() { - eventElementsByID = {}; - eventElementCouples = []; - } - - - - /* Event Elements - ------------------------------------------------------------------------------*/ - - - // report when view creates an element for an event - function reportEventElement(event, element) { - eventElementCouples.push({ event: event, element: element }); - if (eventElementsByID[event._id]) { - eventElementsByID[event._id].push(element); - }else{ - eventElementsByID[event._id] = [element]; - } - } + // Compute the value to feed into setRange. Overrides superclass. + computeRange: function(date) { + var range = View.prototype.computeRange.call(this, date); // get value from the super-method + // year and month views should be aligned with weeks. this is already done for week + if (/year|month/.test(range.intervalUnit)) { + range.start.startOf('week'); + range.start = this.skipHiddenDays(range.start); - function triggerEventDestroy() { - $.each(eventElementCouples, function(i, couple) { - t.trigger('eventDestroy', couple.event, couple.event, couple.element); - }); - } - - - // attaches eventClick, eventMouseover, eventMouseout - function eventElementHandlers(event, eventElement) { - eventElement - .click(function(ev) { - if (!eventElement.hasClass('ui-draggable-dragging') && - !eventElement.hasClass('ui-resizable-resizing')) { - return trigger('eventClick', this, event, ev); - } - }) - .hover( - function(ev) { - trigger('eventMouseover', this, event, ev); - }, - function(ev) { - trigger('eventMouseout', this, event, ev); - } - ); - // TODO: don't fire eventMouseover/eventMouseout *while* dragging is occuring (on subject element) - // TODO: same for resizing - } - - - function showEvents(event, exceptElement) { - eachEventElement(event, exceptElement, 'show'); - } - - - function hideEvents(event, exceptElement) { - eachEventElement(event, exceptElement, 'hide'); - } - - - function eachEventElement(event, exceptElement, funcName) { - // NOTE: there may be multiple events per ID (repeating events) - // and multiple segments per event - var elements = eventElementsByID[event._id], - i, len = elements.length; - for (i=0; i 1; // TODO: make grid responsible + this.weekNumbersVisible = this.opt('weekNumbers'); + this.dayGrid.numbersVisible = this.dayNumbersVisible || this.weekNumbersVisible; - - - /* Event Modification Reporting - ---------------------------------------------------------------------------------*/ + this.el.addClass('fc-basic-view').html(this.renderHtml()); - - function eventDrop(el, event, newStart, ev, ui) { - var mutateResult = calendar.mutateEvent(event, newStart, null); - - trigger( - 'eventDrop', - el, - event, - mutateResult.dateDelta, - function() { - mutateResult.undo(); - reportEventChange(event._id); - }, - ev, - ui - ); + this.headRowEl = this.el.find('thead .fc-row'); - reportEventChange(event._id); - } + this.scrollerEl = this.el.find('.fc-day-grid-container'); + this.dayGrid.coordMap.containerEl = this.scrollerEl; // constrain clicks/etc to the dimensions of the scroller + this.dayGrid.setElement(this.el.find('.fc-day-grid')); + this.dayGrid.renderDates(this.hasRigidRows()); + }, - function eventResize(el, event, newEnd, ev, ui) { - var mutateResult = calendar.mutateEvent(event, null, newEnd); - trigger( - 'eventResize', - el, - event, - mutateResult.durationDelta, - function() { - mutateResult.undo(); - reportEventChange(event._id); - }, - ev, - ui - ); + // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, + // always completely kill the dayGrid's rendering. + unrenderDates: function() { + this.dayGrid.unrenderDates(); + this.dayGrid.removeElement(); + }, - reportEventChange(event._id); - } + renderBusinessHours: function() { + this.dayGrid.renderBusinessHours(); + }, - // ==================================================================================================== - // Utilities for day "cells" - // ==================================================================================================== - // The "basic" views are completely made up of day cells. - // The "agenda" views have day cells at the top "all day" slot. - // This was the obvious common place to put these utilities, but they should be abstracted out into - // a more meaningful class (like DayEventRenderer). - // ==================================================================================================== + // Builds the HTML skeleton for the view. + // The day-grid component will render inside of a container defined by this HTML. + renderHtml: function() { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + this.dayGrid.headHtml() + // render the day-of-week headers + '
' + + '
' + + '
' + + '
' + + '
'; + }, - // For determining how a given "cell" translates into a "date": - // - // 1. Convert the "cell" (row and column) into a "cell offset" (the # of the cell, cronologically from the first). - // Keep in mind that column indices are inverted with isRTL. This is taken into account. - // - // 2. Convert the "cell offset" to a "day offset" (the # of days since the first visible day in the view). - // - // 3. Convert the "day offset" into a "date" (a Moment). - // - // The reverse transformation happens when transforming a date into a cell. + + // Generates the HTML that will go before the day-of week header cells. + // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL. + headIntroHtml: function() { + if (this.weekNumbersVisible) { + return '' + + '' + + '' + // needed for matchCellWidths + htmlEscape(this.opt('weekNumberTitle')) + + '' + + ''; + } + }, - // exports - t.isHiddenDay = isHiddenDay; - t.skipHiddenDays = skipHiddenDays; - t.getCellsPerWeek = getCellsPerWeek; - t.dateToCell = dateToCell; - t.dateToDayOffset = dateToDayOffset; - t.dayOffsetToCellOffset = dayOffsetToCellOffset; - t.cellOffsetToCell = cellOffsetToCell; - t.cellToDate = cellToDate; - t.cellToCellOffset = cellToCellOffset; - t.cellOffsetToDayOffset = cellOffsetToDayOffset; - t.dayOffsetToDate = dayOffsetToDate; - t.rangeToSegments = rangeToSegments; - - - // internals - var hiddenDays = opt('hiddenDays') || []; // array of day-of-week indices that are hidden - var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool) - var cellsPerWeek; - var dayToCellMap = []; // hash from dayIndex -> cellIndex, for one week - var cellToDayMap = []; // hash from cellIndex -> dayIndex, for one week - var isRTL = opt('isRTL'); - - - // initialize important internal variables - (function() { - - if (opt('weekends') === false) { - hiddenDays.push(0, 6); // 0=sunday, 6=saturday + // Generates the HTML that will go before content-skeleton cells that display the day/week numbers. + // Queried by the DayGrid subcomponent. Ordering depends on isRTL. + numberIntroHtml: function(row) { + if (this.weekNumbersVisible) { + return '' + + '' + + '' + // needed for matchCellWidths + this.dayGrid.getCell(row, 0).start.format('w') + + '' + + ''; } + }, - // Loop through a hypothetical week and determine which - // days-of-week are hidden. Record in both hashes (one is the reverse of the other). - for (var dayIndex=0, cellIndex=0; dayIndex<7; dayIndex++) { - dayToCellMap[dayIndex] = cellIndex; - isHiddenDayHash[dayIndex] = $.inArray(dayIndex, hiddenDays) != -1; - if (!isHiddenDayHash[dayIndex]) { - cellToDayMap[cellIndex] = dayIndex; - cellIndex++; - } + + // Generates the HTML that goes before the day bg cells for each day-row. + // Queried by the DayGrid subcomponent. Ordering depends on isRTL. + dayIntroHtml: function() { + if (this.weekNumbersVisible) { + return ''; } + }, - cellsPerWeek = cellIndex; - if (!cellsPerWeek) { - throw 'invalid hiddenDays'; // all days were hidden? bad. + + // Generates the HTML that goes before every other type of row generated by DayGrid. Ordering depends on isRTL. + // Affects helper-skeleton and highlight-skeleton rows. + introHtml: function() { + if (this.weekNumbersVisible) { + return ''; } + }, - })(); + // Generates the HTML for the s of the "number" row in the DayGrid's content skeleton. + // The number row will only exist if either day numbers or week numbers are turned on. + numberCellHtml: function(cell) { + var date = cell.start; + var classes; - // Is the current day hidden? - // `day` is a day-of-week index (0-6), or a Moment - function isHiddenDay(day) { - if (moment.isMoment(day)) { - day = day.day(); + if (!this.dayNumbersVisible) { // if there are week numbers but not day numbers + return ''; // will create an empty space above events :( } - return isHiddenDayHash[day]; - } + classes = this.dayGrid.getDayClasses(date); + classes.unshift('fc-day-number'); - function getCellsPerWeek() { - return cellsPerWeek; - } + return '' + + '' + + date.date() + + ''; + }, - // Incrementing the current day until it is no longer a hidden day, returning a copy. - // If the initial value of `date` is not a hidden day, don't do anything. - // Pass `isExclusive` as `true` if you are dealing with an end date. - // `inc` defaults to `1` (increment one day forward each time) - function skipHiddenDays(date, inc, isExclusive) { - var out = date.clone(); - inc = inc || 1; - while ( - isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7] - ) { - out.add('days', inc); + // Generates an HTML attribute string for setting the width of the week number column, if it is known + weekNumberStyleAttr: function() { + if (this.weekNumberWidth !== null) { + return 'style="width:' + this.weekNumberWidth + 'px"'; } - return out; - } + return ''; + }, - // - // TRANSFORMATIONS: cell -> cell offset -> day offset -> date - // + // Determines whether each row should have a constant height + hasRigidRows: function() { + var eventLimit = this.opt('eventLimit'); + return eventLimit && typeof eventLimit !== 'number'; + }, - // cell -> date (combines all transformations) - // Possible arguments: - // - row, col - // - { row:#, col: # } - function cellToDate() { - var cellOffset = cellToCellOffset.apply(null, arguments); - var dayOffset = cellOffsetToDayOffset(cellOffset); - var date = dayOffsetToDate(dayOffset); - return date; - } - // cell -> cell offset - // Possible arguments: - // - row, col - // - { row:#, col:# } - function cellToCellOffset(row, col) { - var colCnt = t.getColCnt(); + /* Dimensions + ------------------------------------------------------------------------------------------------------------------*/ - // rtl variables. wish we could pre-populate these. but where? - var dis = isRTL ? -1 : 1; - var dit = isRTL ? colCnt - 1 : 0; - if (typeof row == 'object') { - col = row.col; - row = row.row; + // Refreshes the horizontal dimensions of the view + updateWidth: function() { + if (this.weekNumbersVisible) { + // Make sure all week number cells running down the side have the same width. + // Record the width for cells created later. + this.weekNumberWidth = matchCellWidths( + this.el.find('.fc-week-number') + ); } - var cellOffset = row * colCnt + (col * dis + dit); // column, adjusted for RTL (dis & dit) - - return cellOffset; - } + }, - // cell offset -> day offset - function cellOffsetToDayOffset(cellOffset) { - var day0 = t.start.day(); // first date's day of week - cellOffset += dayToCellMap[day0]; // normlize cellOffset to beginning-of-week - return Math.floor(cellOffset / cellsPerWeek) * 7 + // # of days from full weeks - cellToDayMap[ // # of days from partial last week - (cellOffset % cellsPerWeek + cellsPerWeek) % cellsPerWeek // crazy math to handle negative cellOffsets - ] - - day0; // adjustment for beginning-of-week normalization - } - // day offset -> date - function dayOffsetToDate(dayOffset) { - return t.start.clone().add('days', dayOffset); - } + // Adjusts the vertical dimensions of the view to the specified values + setHeight: function(totalHeight, isAuto) { + var eventLimit = this.opt('eventLimit'); + var scrollerHeight; + // reset all heights to be natural + unsetScroller(this.scrollerEl); + uncompensateScroll(this.headRowEl); - // - // TRANSFORMATIONS: date -> day offset -> cell offset -> cell - // + this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed - // date -> cell (combines all transformations) - function dateToCell(date) { - var dayOffset = dateToDayOffset(date); - var cellOffset = dayOffsetToCellOffset(dayOffset); - var cell = cellOffsetToCell(cellOffset); - return cell; - } + // is the event limit a constant level number? + if (eventLimit && typeof eventLimit === 'number') { + this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after + } - // date -> day offset - function dateToDayOffset(date) { - return date.clone().stripTime().diff(t.start, 'days'); - } + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.setGridHeight(scrollerHeight, isAuto); - // day offset -> cell offset - function dayOffsetToCellOffset(dayOffset) { - var day0 = t.start.day(); // first date's day of week - dayOffset += day0; // normalize dayOffset to beginning-of-week - return Math.floor(dayOffset / 7) * cellsPerWeek + // # of cells from full weeks - dayToCellMap[ // # of cells from partial last week - (dayOffset % 7 + 7) % 7 // crazy math to handle negative dayOffsets - ] - - dayToCellMap[day0]; // adjustment for beginning-of-week normalization - } + // is the event limit dynamically calculated? + if (eventLimit && typeof eventLimit !== 'number') { + this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set + } - // cell offset -> cell (object with row & col keys) - function cellOffsetToCell(cellOffset) { - var colCnt = t.getColCnt(); + if (!isAuto && setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars? - // rtl variables. wish we could pre-populate these. but where? - var dis = isRTL ? -1 : 1; - var dit = isRTL ? colCnt - 1 : 0; + compensateScroll(this.headRowEl, getScrollbarWidths(this.scrollerEl)); - var row = Math.floor(cellOffset / colCnt); - var col = ((cellOffset % colCnt + colCnt) % colCnt) * dis + dit; // column, adjusted for RTL (dis & dit) - return { - row: row, - col: col - }; - } + // doing the scrollbar compensation might have created text overflow which created more height. redo + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.scrollerEl.height(scrollerHeight); + } + }, - // - // Converts a date range into an array of segment objects. - // "Segments" are horizontal stretches of time, sliced up by row. - // A segment object has the following properties: - // - row - // - cols - // - isStart - // - isEnd - // - function rangeToSegments(start, end) { - - var rowCnt = t.getRowCnt(); - var colCnt = t.getColCnt(); - var segments = []; // array of segments to return - - // day offset for given date range - var rangeDayOffsetStart = dateToDayOffset(start); - var rangeDayOffsetEnd = dateToDayOffset(end); // an exclusive value - var endTimeMS = +end.time(); - if (endTimeMS && endTimeMS >= nextDayThreshold) { - rangeDayOffsetEnd++; - } - rangeDayOffsetEnd = Math.max(rangeDayOffsetEnd, rangeDayOffsetStart + 1); - - // first and last cell offset for the given date range - // "last" implies inclusivity - var rangeCellOffsetFirst = dayOffsetToCellOffset(rangeDayOffsetStart); - var rangeCellOffsetLast = dayOffsetToCellOffset(rangeDayOffsetEnd) - 1; - - // loop through all the rows in the view - for (var row=0; row