/* An abstract class from which other views inherit from ----------------------------------------------------------------------------------------------------------------------*/ // Newer methods should be written as prototype methods, not in the monster `View` function at the bottom. View.prototype = { calendar: null, // owner Calendar object coordMap: null, // a CoordMap object for converting pixel regions to dates el: null, // the view's containing element. set by Calendar // important Moments start: null, // the date of the very first cell end: null, // the date after the very last cell intervalStart: null, // the start of the interval of time the view represents (1st of month for month view) intervalEnd: null, // the exclusive end of the interval of time the view represents // used for cell-to-date and date-to-cell calculations rowCnt: null, // # of weeks colCnt: null, // # of days displayed in a week isSelected: false, // boolean whether cells are user-selected or not // subclasses can optionally use a scroll container scrollerEl: null, // the element that will most likely scroll when content is too tall scrollTop: null, // cached vertical scroll value // classNames styled by jqui themes widgetHeaderClass: null, widgetContentClass: null, highlightStateClass: null, // document handlers, bound to `this` object documentMousedownProxy: null, documentDragStartProxy: null, // Serves as a "constructor" to suppliment the monster `View` constructor below init: function() { var tm = this.opt('theme') ? 'ui' : 'fc'; this.widgetHeaderClass = tm + '-widget-header'; this.widgetContentClass = tm + '-widget-content'; this.highlightStateClass = tm + '-state-highlight'; // save references to `this`-bound handlers this.documentMousedownProxy = $.proxy(this, 'documentMousedown'); this.documentDragStartProxy = $.proxy(this, 'documentDragStart'); }, // Renders the view inside an already-defined `this.el`. // Subclasses should override this and then call the super method afterwards. render: function() { this.updateSize(); this.trigger('viewRender', this, this, this.el); // attach handlers to document. do it here to allow for destroy/rerender $(document) .on('mousedown', this.documentMousedownProxy) .on('dragstart', this.documentDragStartProxy); // jqui drag }, // Clears all view rendering, event elements, and unregisters handlers destroy: function() { this.unselect(); this.trigger('viewDestroy', this, this, this.el); this.destroyEvents(); this.el.empty(); // removes inner contents but leaves the element intact $(document) .off('mousedown', this.documentMousedownProxy) .off('dragstart', this.documentDragStartProxy); }, // Used to determine what happens when the users clicks next/prev. Given -1 for prev, 1 for next. // Should apply the delta to `date` (a Moment) and return it. incrementDate: function(date, delta) { // subclasses should implement }, /* Dimensions ------------------------------------------------------------------------------------------------------------------*/ // Refreshes anything dependant upon sizing of the container element of the grid updateSize: function(isResize) { if (isResize) { this.recordScroll(); } this.updateHeight(); this.updateWidth(); }, // Refreshes the horizontal dimensions of the calendar updateWidth: function() { // subclasses should implement }, // Refreshes the vertical dimensions of the calendar updateHeight: function() { var calendar = this.calendar; // we poll the calendar for height information this.setHeight( calendar.getSuggestedViewHeight(), calendar.isHeightAuto() ); }, // Updates the vertical dimensions of the calendar to the specified height. // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height. setHeight: function(height, isAuto) { // subclasses should implement }, // Given the total height of the view, return the number of pixels that should be used for the scroller. // Utility for subclasses. computeScrollerHeight: function(totalHeight) { var both = this.el.add(this.scrollerEl); var otherHeight; // cumulative height of everything that is not the scrollerEl in the view (header+borders) // fuckin IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked both.css({ position: 'relative', // cause a reflow, which will force fresh dimension recalculation left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll }); otherHeight = this.el.outerHeight() - this.scrollerEl.height(); // grab the dimensions both.css({ position: '', left: '' }); // undo hack return totalHeight - otherHeight; }, // Called for remembering the current scroll value of the scroller. // Should be called before there is a destructive operation (like removing DOM elements) that might inadvertently // change the scroll of the container. recordScroll: function() { if (this.scrollerEl) { this.scrollTop = this.scrollerEl.scrollTop(); } }, // Set the scroll value of the scroller to the previously recorded value. // Should be called after we know the view's dimensions have been restored following some type of destructive // operation (like temporarily removing DOM elements). restoreScroll: function() { if (this.scrollTop !== null) { this.scrollerEl.scrollTop(this.scrollTop); } }, /* Events ------------------------------------------------------------------------------------------------------------------*/ // Renders the events onto the view. // Should be overriden by subclasses. Subclasses should call the super-method afterwards. renderEvents: function(events) { this.segEach(function(seg) { this.trigger('eventAfterRender', seg.event, seg.event, seg.el); }); this.trigger('eventAfterAllRender'); }, // Removes event elements from the view. // Should be overridden by subclasses. Should call this super-method FIRST, then subclass DOM destruction. destroyEvents: function() { this.segEach(function(seg) { this.trigger('eventDestroy', seg.event, seg.event, seg.el); }); }, // Given an event and the default element used for rendering, returns the element that should actually be used. // Basically runs events and elements through the eventRender hook. resolveEventEl: function(event, el) { var custom = this.trigger('eventRender', event, event, el); if (custom === false) { // means don't render at all el = null; } else if (custom && custom !== true) { el = $(custom); } return el; }, // Hides all rendered event segments linked to the given event showEvent: function(event) { this.segEach(function(seg) { seg.el.css('visibility', ''); }, event); }, // Shows all rendered event segments linked to the given event hideEvent: function(event) { this.segEach(function(seg) { seg.el.css('visibility', 'hidden'); }, event); }, // Iterates through event segments. Goes through all by default. // If the optional `event` argument is specified, only iterates through segments linked to that event. // The `this` value of the callback function will be the view. segEach: function(func, event) { var segs = this.getSegs(); var i; for (i = 0; i < segs.length; i++) { if (!event || segs[i].event._id === event._id) { func.call(this, segs[i]); } } }, // Retrieves all the rendered segment objects for the view getSegs: function() { // subclasses must implement }, /* Event Drag Visualization ------------------------------------------------------------------------------------------------------------------*/ // Renders a visual indication of an event hovering over the specified date. // `end` is a Moment and might be null. // `seg` might be null. if specified, it is the segment object of the event being dragged. // otherwise, an external event from outside the calendar is being dragged. renderDrag: function(start, end, seg) { // subclasses should implement }, // Unrenders a visual indication of event hovering destroyDrag: function() { // subclasses should implement }, // Handler for accepting externally dragged events being dropped in the view. // Gets called when jqui's 'dragstart' is fired. documentDragStart: function(ev, ui) { var _this = this; var dropDate = null; var dragListener; if (this.opt('droppable')) { // only listen if this setting is on // listener that tracks mouse movement over date-associated pixel regions dragListener = new DragListener(this.coordMap, { cellOver: function(cell, date) { dropDate = date; _this.renderDrag(date); }, cellOut: function() { dropDate = null; _this.destroyDrag(); } }); // gets called, only once, when jqui drag is finished $(document).one('dragstop', function(ev, ui) { _this.destroyDrag(); if (dropDate) { _this.trigger('drop', ev.target, dropDate, ev, ui); } }); dragListener.startDrag(ev); // start listening immediately } }, /* Selection ------------------------------------------------------------------------------------------------------------------*/ // Selects a date range on the view. `start` and `end` are both Moments. // `ev` is the native mouse event that begin the interaction. select: function(start, end, ev) { this.unselect(ev); this.renderSelection(start, end); this.reportSelection(start, end, ev); }, // Renders a visual indication of the selection renderSelection: function(start, end) { // subclasses should implement }, // Called when a new selection is made. Updates internal state and triggers handlers. reportSelection: function(start, end, ev) { this.isSelected = true; this.trigger('select', null, start, end, ev); }, // Undoes a selection. updates in the internal state and triggers handlers. // `ev` is the native mouse event that began the interaction. unselect: function(ev) { if (this.isSelected) { this.isSelected = false; this.destroySelection(); this.trigger('unselect', null, ev); } }, // Unrenders a visual indication of selection destroySelection: function() { // subclasses should implement }, // Handler for unselecting when the user clicks something and the 'unselectAuto' setting is on documentMousedown: function(ev) { var ignore; // is there a selection, and has the user made a proper left click? if (this.isSelected && this.opt('unselectAuto') && isPrimaryMouseButton(ev)) { // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element ignore = this.opt('unselectCancel'); if (!ignore || !$(ev.target).closest(ignore).length) { this.unselect(ev); } } } }; // We are mixing JavaScript OOP design patterns here by putting methods and member variables in the closed scope of the // constructor. Going forward, methods should be part of the prototype. function View(calendar) { var t = this; // exports t.calendar = calendar; t.opt = opt; t.trigger = trigger; t.isEventDraggable = isEventDraggable; t.isEventResizable = isEventResizable; t.eventDrop = eventDrop; t.eventResize = eventResize; // imports var reportEventChange = calendar.reportEventChange; // locals var options = calendar.options; var nextDayThreshold = moment.duration(options.nextDayThreshold); t.init(); // the "constructor" that concerns the prototype methods function opt(name) { var v = options[name]; if ($.isPlainObject(v) && !isForcedAtomicOption(name)) { return smartProperty(v, t.name); } return v; } function trigger(name, thisObj) { return calendar.trigger.apply( calendar, [name, thisObj || t].concat(Array.prototype.slice.call(arguments, 2), [t]) ); } /* Event Editable Boolean Calculations ------------------------------------------------------------------------------*/ function isEventDraggable(event) { var source = event.source || {}; return firstDefined( event.startEditable, source.startEditable, opt('eventStartEditable'), event.editable, source.editable, opt('editable') ); } function isEventResizable(event) { var source = event.source || {}; return firstDefined( event.durationEditable, source.durationEditable, opt('eventDurationEditable'), event.editable, source.editable, opt('editable') ); } /* Event Elements ------------------------------------------------------------------------------*/ // Compute the text that should be displayed on an event's element. // Based off the settings of the view. Possible signatures: // .getEventTimeText(event, formatStr) // .getEventTimeText(startMoment, endMoment, formatStr) // .getEventTimeText(startMoment, null, formatStr) // `timeFormat` is used but the `formatStr` argument can be used to override. t.getEventTimeText = function(event, formatStr) { var start; var end; if (typeof event === 'object' && typeof formatStr === 'object') { // first two arguments are actually moments (or null). shift arguments. start = event; end = formatStr; formatStr = arguments[2]; } else { // otherwise, an event object was the first argument start = event.start; end = event.end; } formatStr = formatStr || opt('timeFormat'); if (end && opt('displayEventEnd')) { return calendar.formatRange(start, end, formatStr); } else { return calendar.formatDate(start, formatStr); } }; /* Event Modification Reporting ---------------------------------------------------------------------------------*/ function eventDrop(el, event, newStart, ev) { var mutateResult = calendar.mutateEvent(event, newStart, null); trigger( 'eventDrop', el, event, mutateResult.dateDelta, function() { mutateResult.undo(); reportEventChange(); }, ev, {} // jqui dummy ); reportEventChange(); } function eventResize(el, event, newEnd, ev) { var mutateResult = calendar.mutateEvent(event, null, newEnd); trigger( 'eventResize', el, event, mutateResult.durationDelta, function() { mutateResult.undo(); reportEventChange(); }, ev, {} // jqui dummy ); reportEventChange(); } // ==================================================================================================== // 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). // ==================================================================================================== // 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. // 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; t.isMultiDayEvent = isMultiDayEvent; // 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 } // 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++; } } cellsPerWeek = cellIndex; if (!cellsPerWeek) { throw 'invalid hiddenDays'; // all days were hidden? bad. } })(); // 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(); } return isHiddenDayHash[day]; } function getCellsPerWeek() { return cellsPerWeek; } // 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(inc, 'days'); } return out; } // // TRANSFORMATIONS: cell -> 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; } // cell -> cell offset // Possible arguments: // - row, col // - { row:#, col:# } function cellToCellOffset(row, col) { var colCnt = t.colCnt; // 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; } 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(dayOffset, 'days'); } // // TRANSFORMATIONS: date -> day offset -> cell offset -> cell // // date -> cell (combines all transformations) function dateToCell(date) { var dayOffset = dateToDayOffset(date); var cellOffset = dayOffsetToCellOffset(dayOffset); var cell = cellOffsetToCell(cellOffset); return cell; } // date -> day offset function dateToDayOffset(date) { return date.clone().stripTime().diff(t.start, 'days'); } // 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 } // cell offset -> cell (object with row & col keys) function cellOffsetToCell(cellOffset) { var colCnt = t.colCnt; // 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) return { row: row, col: col }; } // // 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.rowCnt; var colCnt = t.colCnt; var segments = []; // array of segments to return // day offset for given date range var dayRange = computeDayRange(start, end); // convert to a whole-day range var rangeDayOffsetStart = dateToDayOffset(dayRange.start); var rangeDayOffsetEnd = dateToDayOffset(dayRange.end); // an exclusive value // 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