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 | 1390 +- src/UI/JsLibraries/fullcalendar.js | 15599 ++++++++++------ .../{lang => locale}/placeholder.txt | 0 src/UI/JsLibraries/moment.js | 5203 +++--- src/UI/Shims/moment.js | 4 - 7 files changed, 13526 insertions(+), 8752 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; - } +} - - -/* Header -------------------------------------------------------------------------*/ - -.fc-header td { - white-space: nowrap; - } - -.fc-header-left { - width: 25%; - text-align: left; - } - -.fc-header-center { - text-align: center; - } - -.fc-header-right { - width: 25%; +.fc-rtl { 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-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; - } +body .fc { /* extra precedence to overcome jqui */ + font-size: 1em; +} -.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; - } - - -/* Cell Styles -------------------------------------------------------------------------*/ +/* Colors +--------------------------------------------------------------------------------------------------*/ -.fc-widget-header, /* , usually */ -.fc-widget-content { /* , usually */ - border: 1px solid #ddd; - } - -.fc-state-highlight { /* today cell */ /* TODO: add .fc-today to */ +.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-unthemed .fc-popover { + background-color: #fff; +} + +.fc-unthemed .fc-divider, +.fc-unthemed .fc-popover .fc-header { + background: #eee; +} + +.fc-unthemed .fc-popover .fc-header .fc-close { + color: #666; +} + +.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 */ +} + +.fc-nonbusiness { /* default look for non-business-hours areas */ + /* will inherit .fc-bgevent's styles */ + background: #d7d7d7; +} -/* Buttons -------------------------------------------------------------------------*/ +/* Icons (inline elements with styled text that mock arrow icons) +--------------------------------------------------------------------------------------------------*/ -.fc-button { - position: relative; +.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; - } + font-family: "Courier New", Courier, monospace; -.fc-state-default.fc-corner-left { /* non-theme */ - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - } - -.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"; - } - -/* icon (for jquery ui) */ + font-size: 160%; + top: -7%; +} -.fc-button .ui-icon { +.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')) { + + buttonClick(); + + // after the click action, if the button becomes the "active" tab, or disabled, + // it should never have a hover class, so remove it now. + if ( + button.hasClass(tm + '-state-active') || + button.hasClass(tm + '-state-disabled') + ) { + button.removeClass(tm + '-state-hover'); + } + } + }) + .mousedown(function() { + // the *down* effect (mouse pressed in). + // only on buttons that are not the "active" tab, or disabled + button + .not('.' + tm + '-state-active') + .not('.' + tm + '-state-disabled') + .addClass(tm + '-state-down'); + }) + .mouseup(function() { + // undo the *down* effect + button.removeClass(tm + '-state-down'); + }) + .hover( + function() { + // the *hover* effect. + // only on buttons that are not the "active" tab, or disabled + button + .not('.' + tm + '-state-active') + .not('.' + tm + '-state-disabled') + .addClass(tm + '-state-hover'); + }, + function() { + // undo the *hover* effect + button + .removeClass(tm + '-state-hover') + .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup + } + ); + + groupChildren = groupChildren.add(button); + } + } + }); + + if (isOnlyButtons) { + groupChildren + .first().addClass(tm + '-corner-left').end() + .last().addClass(tm + '-corner-right').end(); + } + + 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 + } + }); + } + + return sectionEl; + } + + + function updateTitle(text) { + el.find('h2').text(text); + } + + + 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 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'); + } + + + function getViewsWithButtons() { + return viewsWithButtons; + } + +} + +;; + +fc.sourceNormalizers = []; +fc.sourceFetchers = []; + +var ajaxDefaults = { + dataType: 'json', + cache: false +}; + +var eventGUID = 1; + + +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; + + + // 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 + + + $.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 fetchEvents(start, end) { + rangeStart = start; + rangeEnd = end; + cache = []; + var fetchID = ++currentFetchID; + var len = sources.length; + pendingSourceCnt = len; + for (var i=0; i cell offset -> day offset -> date - // - - // 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; + // Normalizes and assigns the given dates to the given partially-formed event object. + // NOTE: mutates the given start/end moments. does not make a copy. + function assignDatesToEvent(start, end, allDay, event) { + event.start = start; + event.end = end; + event.allDay = allDay; + normalizeEventRange(event); + backupEventDates(event); } - // cell -> cell offset - // Possible arguments: - // - row, col - // - { row:#, col:# } - function cellToCellOffset(row, col) { - var colCnt = t.getColCnt(); - // rtl variables. wish we could pre-populate these. but where? - var dis = isRTL ? -1 : 1; - var dit = isRTL ? colCnt - 1 : 0; + // 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 (typeof row == 'object') { - col = row.col; - row = row.row; + normalizeEventRangeTimes(props); + + if (props.end && !props.end.isAfter(props.start)) { + props.end = null; } - 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); + if (!props.end) { + if (options.forceEventDuration) { + props.end = t.getDefaultEventEnd(props.allDay, props.start); + } + else { + props.end = null; + } + } } - // - // TRANSFORMATIONS: date -> day offset -> cell offset -> cell - // + // 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())); + } - // date -> cell (combines all transformations) - function dateToCell(date) { - var dayOffset = dateToDayOffset(date); - var cellOffset = dayOffsetToCellOffset(dayOffset); - var cell = cellOffsetToCell(cellOffset); - return cell; + if (range.allDay) { + range.start.stripTime(); + if (range.end) { + // TODO: consider nextDayThreshold here? If so, will require a lot of testing and adjustment + range.end.stripTime(); + } + } + else { + if (!range.start.hasTime()) { + range.start = t.rezoneDate(range.start); // will assign a 00:00 time + } + if (range.end && !range.end.hasTime()) { + range.end = t.rezoneDate(range.end); // will assign a 00:00 time + } + } } - // date -> day offset - function dateToDayOffset(date) { - return date.clone().stripTime().diff(t.start, 'days'); + + // 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; } - // 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 + + // 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; + + if (abstractEvent) { + if (abstractEvent._recurring) { + + // 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; + } + } + + // 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); + } + + date.add(1, 'days'); + } + } + else { + events.push(abstractEvent); // return the original event. will be a one-item array + } + } + + return events; } - // cell offset -> cell (object with row & col keys) - function cellOffsetToCell(cellOffset) { - var colCnt = t.getColCnt(); - // rtl variables. wish we could pre-populate these. but where? - var dis = isRTL ? -1 : 1; - var dit = isRTL ? colCnt - 1 : 0; - var row = Math.floor(cellOffset / colCnt); - var col = ((cellOffset % colCnt + colCnt) % colCnt) * dis + dit; // column, adjusted for RTL (dis & dit) + /* Event Modification Math + -----------------------------------------------------------------------------------------*/ + + + // 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); + } + } + + newProps = newProps || {}; + + // 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); + + // 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; + + // compute the delta for moving the start date + startDelta = diffDates(newProps.start, oldProps.start); + + // compute the delta for moving the end date + if (newProps.end) { + endDelta = diffDates(newProps.end, oldProps.end); + durationDelta = endDelta.subtract(startDelta); + } + else { + durationDelta = null; + } + + // gather all non-date-related properties + $.each(newProps, function(name, val) { + if (isMiscEventPropName(name)) { + if (val !== undefined) { + miscProps[name] = val; + } + } + }); + + // 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 { - row: row, - col: col + 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 // - // 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 + // Returns a function that can be called to undo all the operations. // - function rangeToSegments(start, end) { + // 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 = []; - var rowCnt = t.getRowCnt(); - var colCnt = t.getColCnt(); - var segments = []; // array of segments to return + // normalize zero-length deltas to be null + if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; } + if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; } - // 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); + $.each(events, function(i, event) { + var oldProps; + var newProps; - // first and last cell offset for the given date range - // "last" implies inclusivity - var rangeCellOffsetFirst = dayOffsetToCellOffset(rangeDayOffsetStart); - var rangeCellOffsetLast = dayOffsetToCellOffset(rangeDayOffsetEnd) - 1; + // 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]; + }); - // loop through all the rows in the view - for (var row=0; row