diff --git a/build/karma.conf.js b/build/karma.conf.js index 74343d2..417c295 100644 --- a/build/karma.conf.js +++ b/build/karma.conf.js @@ -22,7 +22,6 @@ module.exports = function(config) { '../lib/moment/moment.js', '../lib/jquery/dist/jquery.js', - '../lib/jquery-ui/ui/jquery-ui.js', // for jquery simulate '../lib/jquery-simulate-ext/libs/bililiteRange.js', diff --git a/fullcalendar.jquery.json b/fullcalendar.jquery.json index 4433dc0..bea056f 100644 --- a/fullcalendar.jquery.json +++ b/fullcalendar.jquery.json @@ -10,9 +10,6 @@ "jquery": ">=1.7.1", "moment": ">=2.5.0" }, - "optionalDependencies": { - "jquery-ui": ">=1.8.17" - }, "title": "FullCalendar", "demo": "http://arshaw.com/fullcalendar/", diff --git a/lumbar.json b/lumbar.json index 0699fad..b0a36e9 100644 --- a/lumbar.json +++ b/lumbar.json @@ -22,27 +22,29 @@ "src/util.js", "src/moment-ext.js", "src/date-formatting.js", + "src/common/CoordMap.js", + "src/common/DragListener.js", + "src/common/MouseFollower.js", + "src/common/RowRenderer.js", + "src/common/Grid.js", + "src/common/Grid.events.js", + "src/common/DayGrid.js", + "src/common/DayGrid.events.js", + "src/common/TimeGrid.js", + "src/common/TimeGrid.events.js", + "src/common/View.js", + "src/basic/BasicView.js", "src/basic/MonthView.js", "src/basic/BasicWeekView.js", "src/basic/BasicDayView.js", - "src/basic/BasicView.js", - "src/basic/BasicEventRenderer.js", + "src/agenda/AgendaView.js", "src/agenda/AgendaWeekView.js", "src/agenda/AgendaDayView.js", - "src/agenda/AgendaView.js", - "src/agenda/AgendaEventRenderer.js", - "src/common/View.js", - "src/common/DayEventRenderer.js", - "src/common/SelectionManager.js", - "src/common/OverlayManager.js", - "src/common/CoordinateGrid.js", - "src/common/HoverListener.js", - "src/common/HorizontalPositionCache.js", "src/outro.js" ], "styles": [ - "src/main.css", "src/common/common.css", + "src/main.css", "src/basic/basic.css", "src/agenda/agenda.css" ] diff --git a/src/Calendar.js b/src/Calendar.js index a875653..5dea1cd 100644 --- a/src/Calendar.js +++ b/src/Calendar.js @@ -41,7 +41,7 @@ function Calendar(element, instanceOptions) { t.refetchEvents = refetchEvents; t.reportEvents = reportEvents; t.reportEventChange = reportEventChange; - t.rerenderEvents = rerenderEvents; + t.rerenderEvents = renderEvents; // `renderEvents` serves as a rerender. an API method t.changeView = changeView; t.select = select; t.unselect = unselect; @@ -244,13 +244,11 @@ function Calendar(element, instanceOptions) { var content; var tm; // for making theme classes var currentView; - var elementOuterWidth; var suggestedViewHeight; var resizeUID = 0; var ignoreWindowResize = 0; var date; var events = []; - var _dragElement; @@ -273,7 +271,7 @@ function Calendar(element, instanceOptions) { else if (elementVisible()) { // mainly for the public API calcSize(); - _renderView(inc); + renderView(inc); } } @@ -281,18 +279,22 @@ function Calendar(element, instanceOptions) { function initialRender() { tm = options.theme ? 'ui' : 'fc'; element.addClass('fc'); + if (options.isRTL) { element.addClass('fc-rtl'); } else { element.addClass('fc-ltr'); } + if (options.theme) { element.addClass('ui-widget'); } + else { + element.addClass('fc-unthemed'); + } - content = $("
") - .prependTo(element); + content = $("
").prependTo(element); header = new Header(t, options); headerElement = header.render(); @@ -305,47 +307,20 @@ function Calendar(element, instanceOptions) { if (options.handleWindowResize) { $(window).resize(windowResize); } - - // needed for IE in a 0x0 iframe, b/c when it is resized, never triggers a windowResize - if (!bodyVisible()) { - lateRender(); - } - } - - - // called when we know the calendar couldn't be rendered when it was initialized, - // but we think it's ready now - function lateRender() { - setTimeout(function() { // IE7 needs this so dimensions are calculated correctly - if (!currentView.start && bodyVisible()) { // !currentView.start makes sure this never happens more than once - renderView(); - } - },0); } function destroy() { if (currentView) { - trigger('viewDestroy', currentView, currentView, currentView.element); - currentView.triggerEventDestroy(); - } - - $(window).unbind('resize', windowResize); - - if (options.droppable) { - $(document) - .off('dragstart', droppableDragStart) - .off('dragstop', droppableDragStop); - } - - if (currentView.selectionManagerDestroy) { - currentView.selectionManagerDestroy(); + currentView.destroy(); } header.destroy(); content.remove(); - element.removeClass('fc fc-ltr fc-rtl ui-widget'); + element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget'); + + $(window).unbind('resize', windowResize); } @@ -354,114 +329,122 @@ function Calendar(element, instanceOptions) { } - function bodyVisible() { - return $('body').is(':visible'); - } - - // View Rendering // ----------------------------------------------------------------------------------- - - function changeView(newViewName) { - if (!currentView || newViewName != currentView.name) { - _changeView(newViewName); - } + + function changeView(viewName) { + renderView(0, viewName); } - function _changeView(newViewName) { + // Renders a view because of a date change, view-type change, or for the first time + function renderView(delta, viewName) { ignoreWindowResize++; + // if viewName is changing, destroy the old view + if (currentView && viewName && currentView.name !== viewName) { + header.deactivateButton(currentView.name); + freezeContentHeight(); // prevent a scroll jump when view element is removed + if (currentView.start) { // rendered before? + currentView.destroy(); + } + currentView.el.remove(); + currentView = null; + } + + // if viewName changed, or the view was never created, create a fresh view + if (!currentView && viewName) { + currentView = new fcViews[viewName](t); + currentView.el = $("
").appendTo(content); + header.activateButton(viewName); + } + if (currentView) { - trigger('viewDestroy', currentView, currentView, currentView.element); - unselect(); - currentView.triggerEventDestroy(); // trigger 'eventDestroy' for each event - freezeContentHeight(); - currentView.element.remove(); - header.deactivateButton(currentView.name); - } - header.activateButton(newViewName); + // let the view determine what the delta means + if (delta) { + date = currentView.incrementDate(date, delta); + } - currentView = new fcViews[newViewName]( - $("
") - .appendTo(content), - t // the calendar object - ); + // render or rerender the view + if ( + !currentView.start || // never rendered before + delta || // explicit date window change + !date.isWithin(currentView.intervalStart, currentView.intervalEnd) // implicit date window change + ) { + if (elementVisible()) { - renderView(); - unfreezeContentHeight(); + freezeContentHeight(); + if (currentView.start) { // rendered before? + currentView.destroy(); + } + currentView.render(date); + unfreezeContentHeight(); - ignoreWindowResize--; - } + // need to do this after View::render, so dates are calculated + updateTitle(); + updateTodayButton(); - - function renderView(inc) { - if ( - !currentView.start || // never rendered before - inc || // explicit date window change - !date.isWithin(currentView.intervalStart, currentView.intervalEnd) // implicit date window change - ) { - if (elementVisible()) { - _renderView(inc); + getAndRenderEvents(); + } } } - } - - - function _renderView(inc) { // assumes elementVisible - ignoreWindowResize++; - - if (currentView.start) { // already been rendered? - trigger('viewDestroy', currentView, currentView, currentView.element); - unselect(); - clearEvents(); - } - - freezeContentHeight(); - if (inc) { - date = currentView.incrementDate(date, inc); - } - currentView.render(date.clone()); // the view's render method ONLY renders the skeleton, nothing else - setSize(); - unfreezeContentHeight(); - (currentView.afterRender || noop)(); - - updateTitle(); - updateTodayButton(); - - trigger('viewRender', currentView, currentView, currentView.element); + unfreezeContentHeight(); // undo any lone freezeContentHeight calls ignoreWindowResize--; - - getAndRenderEvents(); } // Resizing // ----------------------------------------------------------------------------------- - - - function updateSize() { - if (elementVisible()) { - unselect(); - clearEvents(); + + + t.getSuggestedViewHeight = function() { + if (suggestedViewHeight === undefined) { calcSize(); - setSize(); - renderEvents(); + } + return suggestedViewHeight; + }; + + + t.isHeightAuto = function() { + return options.contentHeight === 'auto' || options.height === 'auto'; + }; + + + function updateSize(shouldRecalc) { + if (elementVisible()) { + + if (shouldRecalc) { + _calcSize(); + } + + ignoreWindowResize++; + currentView.updateHeight(); // will poll getSuggestedViewHeight() and isHeightAuto() + currentView.updateWidth(); + ignoreWindowResize--; + + return true; // signal success + } + } + + + function calcSize() { + if (elementVisible()) { + _calcSize(); } } - function calcSize() { // assumes elementVisible - if (options.contentHeight) { + function _calcSize() { // assumes elementVisible + if (typeof options.contentHeight === 'number') { // exists and not 'auto' suggestedViewHeight = options.contentHeight; } - else if (options.height) { - suggestedViewHeight = options.height - (headerElement ? headerElement.height() : 0) - vsides(content); + else if (typeof options.height === 'number') { // exists and not 'auto' + suggestedViewHeight = options.height - (headerElement ? headerElement.outerHeight(true) : 0); } else { suggestedViewHeight = Math.round(content.width() / Math.max(options.aspectRatio, .5)); @@ -469,44 +452,20 @@ function Calendar(element, instanceOptions) { } - function setSize() { // assumes elementVisible - - if (suggestedViewHeight === undefined) { - calcSize(); // for first time - // NOTE: we don't want to recalculate on every renderView because - // it could result in oscillating heights due to scrollbars. - } - - ignoreWindowResize++; - currentView.setHeight(suggestedViewHeight); - currentView.setWidth(content.width()); - ignoreWindowResize--; - - elementOuterWidth = element.outerWidth(); - } - - function windowResize(ev) { if ( !ignoreWindowResize && - ev.target === window // so we don't process jqui "resize" events that have bubbled up + ev.target === window && // so we don't process jqui "resize" events that have bubbled up + currentView.start // view has already been rendered ) { - if (currentView.start) { // view has already been rendered - var uid = ++resizeUID; - setTimeout(function() { // add a delay - if (uid == resizeUID && !ignoreWindowResize && elementVisible()) { - if (elementOuterWidth != (elementOuterWidth = element.outerWidth())) { - ignoreWindowResize++; // in case the windowResize callback changes the height - updateSize(); - currentView.trigger('windowResize', _element); - ignoreWindowResize--; - } + var uid = ++resizeUID; + setTimeout(function() { // add a delay + if (uid == resizeUID && !ignoreWindowResize) { + if (updateSize(true)) { + currentView.trigger('windowResize', _element); } - }, options.windowResizeDelay); - }else{ - // calendar must have been initialized in a 0x0 iframe that has just been resized - lateRender(); - } + } + }, options.windowResizeDelay); } } @@ -518,29 +477,25 @@ function Calendar(element, instanceOptions) { function refetchEvents() { // can be called as an API method - clearEvents(); + destroyEvents(); // so that events are cleared before user starts waiting for AJAX fetchAndRenderEvents(); } - function rerenderEvents(modifiedEventID) { // can be called as an API method - clearEvents(); - renderEvents(modifiedEventID); - } - - - function renderEvents(modifiedEventID) { // TODO: remove modifiedEventID hack + function renderEvents() { // destroys old events if previously rendered if (elementVisible()) { - currentView.renderEvents(events, modifiedEventID); // actually render the DOM elements - currentView.trigger('eventAfterAllRender'); + freezeContentHeight(); + currentView.destroyEvents(); // no performance cost if never rendered + currentView.renderEvents(events); + unfreezeContentHeight(); } } - function clearEvents() { - currentView.triggerEventDestroy(); // trigger 'eventDestroy' for each event - currentView.clearEvents(); // actually remove the DOM elements - currentView.clearEventData(); // for View.js, TODO: unify with clearEvents + function destroyEvents() { + freezeContentHeight(); + currentView.destroyEvents(); + unfreezeContentHeight(); } @@ -569,8 +524,8 @@ function Calendar(element, instanceOptions) { // called when a single event's data has been changed - function reportEventChange(eventID) { - rerenderEvents(eventID); + function reportEventChange() { + renderEvents(); } @@ -601,6 +556,18 @@ function Calendar(element, instanceOptions) { function select(start, end) { + + start = t.moment(start); + if (end) { + end = t.moment(end); + } + else if (start.hasTime()) { + end = start.clone().add(t.defaultTimedEventDuration); + } + else { + end = start.clone().add(t.defaultAllDayEventDuration); + } + currentView.select(start, end); } @@ -706,7 +673,7 @@ function Calendar(element, instanceOptions) { } if (name == 'height' || name == 'contentHeight' || name == 'aspectRatio') { options[name] = value; - updateSize(); + updateSize(true); // true = allow recalculation of height } } @@ -719,38 +686,5 @@ function Calendar(element, instanceOptions) { ); } } - - - - /* External Dragging - ------------------------------------------------------------------------*/ - - if (options.droppable) { - // TODO: unbind on destroy - $(document) - .on('dragstart', droppableDragStart) - .on('dragstop', droppableDragStop); - // this is undone in destroy - } - - function droppableDragStart(ev, ui) { - var _e = ev.target; - var e = $(_e); - if (!e.parents('.fc').length) { // not already inside a calendar - var accept = options.dropAccept; - if ($.isFunction(accept) ? accept.call(_e, e) : e.is(accept)) { - _dragElement = _e; - currentView.dragStart(_dragElement, ev, ui); - } - } - } - - function droppableDragStop(ev, ui) { - if (_dragElement) { - currentView.dragStop(_dragElement, ev, ui); - _dragElement = null; - } - } - } diff --git a/src/EventManager.js b/src/EventManager.js index d7c821e..43cb035 100644 --- a/src/EventManager.js +++ b/src/EventManager.js @@ -250,15 +250,21 @@ function EventManager(options) { // assumed to be a calendar } else if (typeof sourceInput === 'object') { source = $.extend({}, sourceInput); // shallow copy - - if (typeof source.className === 'string') { - // TODO: repeat code, same code for event classNames - source.className = source.className.split(/\s+/); - } } if (source) { + // TODO: repeat code, same code for event classNames + if (source.className) { + if (typeof source.className === 'string') { + source.className = source.className.split(/\s+/); + } + // otherwise, assumed to be an array + } + else { + source.className = []; + } + // for array sources, we convert to standard Event Objects up front if ($.isArray(source.events)) { source.events = $.map(source.events, function(eventInput) { diff --git a/src/Header.js b/src/Header.js index 1537f83..114c4ee 100644 --- a/src/Header.js +++ b/src/Header.js @@ -1,8 +1,11 @@ +/* Top toolbar area with buttons and title +----------------------------------------------------------------------------------------------------------------------*/ +// TODO: rename all header-related things to "toolbar" + function Header(calendar, options) { var t = this; - // exports t.render = render; t.destroy = destroy; @@ -12,54 +15,61 @@ function Header(calendar, options) { t.disableButton = disableButton; t.enableButton = enableButton; - // locals - var element = $([]); + var el = $(); var tm; - function render() { - tm = options.theme ? 'ui' : 'fc'; var sections = options.header; + + tm = options.theme ? 'ui' : 'fc'; + if (sections) { - element = $("") - .append( - $("") - .append(renderSection('left')) - .append(renderSection('center')) - .append(renderSection('right')) - ); - return element; + el = $("
") + .append(renderSection('left')) + .append(renderSection('right')) + .append(renderSection('center')) + .append('
'); + + return el; } } function destroy() { - element.remove(); + el.remove(); } function renderSection(position) { - var e = $("
" + - ""; + if (this.opt('weekNumbers')) { + date = this.cellToDate(0, 0); + weekNumber = this.calendar.calculateWeekNumber(date); + weekTitle = this.opt('weekNumberTitle'); - if (opt('weekNumbers')) { - date = cellToDate(0, 0); - weekText = calculateWeekNumber(date); - if (rtl) { - weekText += opt('weekNumberTitle'); + if (this.opt('isRTL')) { + weekText = weekNumber + weekTitle; } else { - weekText = opt('weekNumberTitle') + weekText; + weekText = weekTitle + weekNumber; } - html += - ""; + + return '' + + ''; } else { - html += ""; + return ''; } + }, - for (col=0; col" + - htmlEscape(formatDate(date, colFormat)) + - ""; + + // Generates the HTML that goes before the all-day cells. + // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL. + dayIntroHtml: function() { + return '' + + ''; + }, + + + // Generates the HTML that goes before all other types of cells. + // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. + // Queried by the TimeGrid and DayGrid subcomponents when generating rows. Ordering depends on isRTL. + introHtml: function() { + return ''; + }, + + + /* Dimensions + ------------------------------------------------------------------------------------------------------------------*/ + + + // Refreshes the horizontal dimensions of the view + updateWidth: function() { + // make all axis cells line up, and record the width so newly created axis cells will have it + this.axisWidth = matchCellWidths(this.el.find('.fc-axis')); + }, + + + // Adjusts the vertical dimensions of the view to the specified values + setHeight: function(totalHeight, isAuto) { + var scrollerHeight; + var timeGridHeight; + var extraHeight; // # of pixels the time-grid element needs to expand to fill the scroller + + if (this.bottomRuleHeight === null) { + // calculate the height of the rule the very first time + this.bottomRuleHeight = this.bottomRuleEl.outerHeight(); } + this.bottomRuleEl.hide(); // .show() will be called later if this
is necessary - html += - "" + - "" + - ""; + // reset all dimensions back to the original state + this.scrollerEl.height('').removeClass('fc-scroller'); + uncompensateScroll(this.noScrollRowEls); - return html; - } + if (!isAuto) { // should we force dimensions of the scroll container, or let the contents be natural height? + scrollerHeight = this.computeScrollerHeight(totalHeight); + timeGridHeight = this.timeGrid.el.height(); + this.scrollerEl.height(scrollerHeight); - function buildDayTableBodyHTML() { - var headerClass = tm + "-widget-header"; // TODO: make these when updateOptions() called - var contentClass = tm + "-widget-content"; - var date; - var today = calendar.getNow().stripTime(); - var col; - var cellsHTML; - var cellHTML; - var classNames; - var html = ''; + if (timeGridHeight > scrollerHeight) { // do we need scrollbars? - html += - "" + - "" + - ""; + // force scrollbars and make the all-day and header rows lines up + this.scrollerEl.addClass('fc-scroller'); + compensateScroll(this.noScrollRowEls, getScrollbarWidths(this.scrollerEl)); - cellsHTML = ''; + // the scrollbar compensation might have changed text flow, which might affect height, so recalculate + // and reapply the desired height to the scroller. + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.scrollerEl.height(scrollerHeight); - for (col=0; col if there is enough extra space + extraHeight = scrollerHeight - timeGridHeight; + if (extraHeight > this.bottomRuleHeight + 5) { + this.bottomRuleEl.show(); + } } - - cellHTML = - ""; - - cellsHTML += cellHTML; } - - html += cellsHTML; - html += - "" + - "" + - ""; - - return html; - } + }, - // TODO: data-date on the cells + // Sets the scroll value of the scroller to the intial pre-configured state prior to allowing the user to change it. + resetScroll: function() { + var _this = this; + var scrollTime = moment.duration(this.opt('scrollTime')); + var top = this.timeGrid.computeTimeTop(scrollTime); - - - /* Dimensions - -----------------------------------------------------------------------*/ + // zoom can give weird floating-point values. rather scroll a little bit further + top = Math.ceil(top); - - function setHeight(height) { - if (height === undefined) { - height = viewHeight; + if (top) { + top++; // to overcome top border that slots beyond the first have. looks better } - 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 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); - } - - - - /* Scrolling - -----------------------------------------------------------------------*/ - - - function resetScroll() { - var top = computeTimeTop( - moment.duration(opt('scrollTime')) - ) + 1; // +1 for the border function scroll() { - slotScroller.scrollTop(top); + _this.scrollerEl.scrollTop(top); } scroll(); setTimeout(scroll, 0); // overrides any previous scroll state made by the browser - } + }, - function afterRender() { // after the view has been freshly rendered and sized - resetScroll(); - } - - - - /* Slot/Day clicking and binding - -----------------------------------------------------------------------*/ - - - function dayBind(cells) { - cells.click(slotClick) - .mousedown(daySelectionMousedown); - } + /* Events + ------------------------------------------------------------------------------------------------------------------*/ - function slotBind(cells) { - cells.click(slotClick) - .mousedown(slotSelectionMousedown); - } - - - 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 - ); + // Renders events onto the view and populates the View's segment array + renderEvents: function(events) { + var dayEvents = []; + var timedEvents = []; + var daySegs = []; + var timedSegs; + var i; + + // separate the events into all-day and timed + for (i = 0; i < events.length; i++) { + if (events[i].allDay) { + dayEvents.push(events[i]); + } + else { + timedEvents.push(events[i]); } } - } - - - - /* Semi-transparent Overlay Helpers - -----------------------------------------------------*/ - // TODO: should be consolidated with BasicView's methods - - function renderDayOverlay(overlayStart, overlayEnd, refreshCoordinateGrid) { // overlayEnd is exclusive - - if (refreshCoordinateGrid) { - coordinateGrid.build(); + // render the events in the subcomponents + timedSegs = this.timeGrid.renderEvents(timedEvents); + if (this.dayGrid) { + daySegs = this.dayGrid.renderEvents(dayEvents); } - var segments = rangeToSegments(overlayStart, overlayEnd); + // the all-day area is flexible and might have a lot of events, so shift the height + this.updateHeight(); - for (var i=0; i= 0) { - date.time(moment.duration(minTime + snapIndex * snapDuration)); - date = calendar.rezoneDate(date); - } + this.updateHeight(); - return date; - } + View.prototype.destroyEvents.call(this); // call the super-method. will kill `this.segs` + }, - function computeDateTop(date, startOfDayDate) { - return computeTimeTop( - moment.duration( - date.clone().stripZone() - startOfDayDate.clone().stripTime() - ) - ); - } + /* Event Dragging + ------------------------------------------------------------------------------------------------------------------*/ - function computeTimeTop(time) { // time is a duration - - if (time < minTime) { - return 0; - } - if (time >= maxTime) { - return slotTable.height(); - } - - var slots = (time - minTime) / slotDuration; - var slotIndex = Math.floor(slots); - var slotPartial = slots - slotIndex; - var slotTop = slotTopCache[slotIndex]; - - // 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 - } - - var top = - slotTop - 1 + // because first row doesn't have a top border - slotPartial * slotHeight; // part-way through the row - - top = Math.max(top, 0); - - return top; - } - - - - /* Selection - ---------------------------------------------------------------------------------*/ - - - function defaultSelectionEnd(start) { + // Renders a visual indication of an event being dragged over the view. + // A returned value of `true` signals that a mock "helper" event has been rendered. + renderDrag: function(start, end, seg) { if (start.hasTime()) { - return start.clone().add(slotDuration); + return this.timeGrid.renderDrag(start, end, seg); } - else { - return start.clone().add('days', 1); + else if (this.dayGrid) { + return this.dayGrid.renderDrag(start, end, seg); } - } - - - function renderSelection(start, end) { + }, + + + // Unrenders a visual indications of an event being dragged over the view + destroyDrag: function() { + this.timeGrid.destroyDrag(); + if (this.dayGrid) { + this.dayGrid.destroyDrag(); + } + }, + + + /* Selection + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of a selection + renderSelection: function(start, end) { if (start.hasTime() || end.hasTime()) { - renderSlotSelection(start, end); + this.timeGrid.renderSelection(start, end); } - else if (opt('allDaySlot')) { - renderDayOverlay(start, end, true); // true for refreshing coordinate grid + else if (this.dayGrid) { + this.dayGrid.renderSelection(start, end); } - } - - - 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); - } - } - } - }else{ - renderSlotOverlay(startDate, endDate); - } - } - - - function clearSelection() { - clearOverlays(); - if (selectionHelper) { - selectionHelper.remove(); - selectionHelper = null; - } - } - - - function slotSelectionMousedown(ev) { - if (ev.which == 1 && opt('selectable')) { // ev.which==1 means left mouse button - unselect(ev); - var dates; - hoverListener.start(function(cell, origCell) { - clearSelection(); - if (cell && cell.col == origCell.col && !getIsCellAllDay(cell)) { - var d1 = realCellToDate(origCell); - var d2 = realCellToDate(cell); - dates = [ - d1, - d1.clone().add(snapDuration), // calculate minutes depending on selection slot minutes - d2, - d2.clone().add(snapDuration) - ].sort(dateCompare); - renderSlotSelection(dates[0], dates[3]); - }else{ - dates = null; - } - }, ev); - $(document).one('mouseup', function(ev) { - hoverListener.stop(); - if (dates) { - if (+dates[0] == +dates[1]) { - reportDayClick(dates[0], ev); - } - reportSelection(dates[0], dates[3], ev); - } - }); + }, + + + // Unrenders a visual indications of a selection + destroySelection: function() { + this.timeGrid.destroySelection(); + if (this.dayGrid) { + this.dayGrid.destroySelection(); } } - - function reportDayClick(date, ev) { - trigger('dayClick', dayBodyCells[dateToCell(date).col], date, ev); - } - - - - /* External Dragging - --------------------------------------------------------------------------------*/ - - - function dragStart(_dragElement, ev, ui) { - hoverListener.start(function(cell) { - clearOverlays(); - if (cell) { - var d1 = realCellToDate(cell); - var d2 = d1.clone(); - if (d1.hasTime()) { - d2.add(calendar.defaultTimedEventDuration); - renderSlotOverlay(d1, d2); - } - else { - d2.add(calendar.defaultAllDayEventDuration); - renderDayOverlay(d1, d2); - } - } - }, ev); - } - - - function dragStop(_dragElement, ev, ui) { - var cell = hoverListener.stop(); - clearOverlays(); - if (cell) { - trigger( - 'drop', - _dragElement, - realCellToDate(cell), - ev, - ui - ); - } - } - - -} +}); diff --git a/src/agenda/AgendaWeekView.js b/src/agenda/AgendaWeekView.js index 4b101c8..9d8052c 100644 --- a/src/agenda/AgendaWeekView.js +++ b/src/agenda/AgendaWeekView.js @@ -1,41 +1,42 @@ -fcViews.agendaWeek = AgendaWeekView; +/* A week view with an all-day cell area at the top, and a time grid below +----------------------------------------------------------------------------------------------------------------------*/ +// TODO: a WeekView mixin for calculating dates and titles -function AgendaWeekView(element, calendar) { // TODO: do a WeekView mixin - var t = this; - - - // exports - t.incrementDate = incrementDate; - t.render = render; - - - // imports - AgendaView.call(t, element, calendar, 'agendaWeek'); +fcViews.agendaWeek = AgendaWeekView; // register the view + +function AgendaWeekView(calendar) { + AgendaView.call(this, calendar); // call the super-constructor +} - function incrementDate(date, delta) { +AgendaWeekView.prototype = createObject(AgendaView.prototype); // define the super-class +$.extend(AgendaWeekView.prototype, { + + name: 'agendaWeek', + + + incrementDate: function(date, delta) { return date.clone().stripTime().add('weeks', delta).startOf('week'); - } + }, - function render(date) { + render: function(date) { - t.intervalStart = date.clone().stripTime().startOf('week'); - t.intervalEnd = t.intervalStart.clone().add('weeks', 1); + this.intervalStart = date.clone().stripTime().startOf('week'); + this.intervalEnd = this.intervalStart.clone().add('weeks', 1); - t.start = t.skipHiddenDays(t.intervalStart); - t.end = t.skipHiddenDays(t.intervalEnd, -1, true); + this.start = this.skipHiddenDays(this.intervalStart); + this.end = this.skipHiddenDays(this.intervalEnd, -1, true); - t.title = calendar.formatRange( - t.start, - t.end.clone().subtract(1), // make inclusive by subtracting 1 ms - t.opt('titleFormat'), + this.title = this.calendar.formatRange( + this.start, + this.end.clone().subtract(1), // make inclusive by subtracting 1 ms + this.opt('titleFormat'), ' \u2014 ' // emphasized dash ); - t.renderAgenda(t.getCellsPerWeek()); + AgendaView.prototype.render.call(this, this.getCellsPerWeek()); // call the super-method } - -} +}); diff --git a/src/agenda/agenda.css b/src/agenda/agenda.css index fe85498..6aadf84 100644 --- a/src/agenda/agenda.css +++ b/src/agenda/agenda.css @@ -1,160 +1,197 @@ -/* Agenda Week View, Agenda Day View -------------------------------------------------------------------------*/ +/* AgendaView all-day area +--------------------------------------------------------------------------------------------------*/ -.fc-agenda table { - border-collapse: separate; - } - -.fc-agenda-days th { - text-align: center; - } - -.fc-agenda .fc-agenda-axis { - width: 50px; - padding: 0 4px; +.fc-agenda-view tbody .fc-row { + min-height: 3em; /* all-day section will never get shorter than this */ +} + +.fc-agenda-view tbody .fc-row .fc-content-skeleton { + padding-bottom: 1em; /* give space underneath events for clicking/selecting days */ +} + + +/* TimeGrid axis running down the side (for both the all-day area and the slot area) +--------------------------------------------------------------------------------------------------*/ + +.fc .fc-axis { /* .fc to overcome default cell styles */ vertical-align: middle; + padding: 0 4px; + white-space: nowrap; +} + +.fc-ltr .fc-axis { text-align: right; - font-weight: normal; - } +} -.fc-agenda-slots .fc-agenda-axis { - white-space: nowrap; - } +.fc-rtl .fc-axis { + text-align: left; +} -.fc-agenda .fc-week-number { - font-weight: bold; - } - -.fc-agenda .fc-day-content { - padding: 2px 2px 1px; - } - -/* make axis border take precedence */ - -.fc-agenda-days .fc-agenda-axis { - border-right-width: 1px; - } - -.fc-agenda-days .fc-col0 { - border-left-width: 0; - } - -/* all-day area */ - -.fc-agenda-allday th { - border-width: 0 1px; - } - -.fc-agenda-allday .fc-day-content { - min-height: 34px; /* TODO: doesnt work well in quirksmode */ - _height: 34px; - } - -/* divider (between all-day and slots) */ - -.fc-agenda-divider-inner { - height: 2px; - overflow: hidden; - } - -.fc-widget-header .fc-agenda-divider-inner { - background: #eee; - } - -/* slot rows */ - -.fc-agenda-slots th { - border-width: 1px 1px 0; - } - -.fc-agenda-slots td { - border-width: 1px 0 0; - background: none; - } - -.fc-agenda-slots td div { - height: 20px; - } - -.fc-agenda-slots tr.fc-slot0 th, -.fc-agenda-slots tr.fc-slot0 td { - border-top-width: 0; - } - -.fc-agenda-slots tr.fc-minor th, -.fc-agenda-slots tr.fc-minor td { - border-top-style: dotted; - } - -.fc-agenda-slots tr.fc-minor th.ui-widget-header { - *border-top-style: solid; /* doesn't work with background in IE6/7 */ - } - +.ui-widget td.fc-axis { + font-weight: normal; /* overcome jqui theme making it bold */ +} -/* Vertical Events -------------------------------------------------------------------------*/ +/* TimeGrid Structure +--------------------------------------------------------------------------------------------------*/ -.fc-event-vert { - border-width: 0 1px; - } +.fc-time-grid { + position: relative; /* so slats/bg/content/etc positions get scoped within here */ + min-height: 100%; /* so if height setting is 'auto', .fc-bg stretches to fill height */ +} -.fc-event-vert.fc-event-start { - border-top-width: 1px; - border-top-left-radius: 3px; - border-top-right-radius: 3px; - } +.fc-time-grid table { /* don't put outer borders on slats/bg/content/etc */ + border: 0 hidden transparent; +} -.fc-event-vert.fc-event-end { - border-bottom-width: 1px; - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; - } - -.fc-event-vert .fc-event-time { - white-space: nowrap; - font-size: 10px; - } +.fc-time-grid > .fc-bg { + z-index: 1; +} -.fc-event-vert .fc-event-inner { +.fc-time-grid .fc-slats, +.fc-time-grid > hr { /* the
AgendaView injects when grid is shorter than scroller */ position: relative; z-index: 2; - } - -.fc-event-vert .fc-event-bg { /* makes the event lighter w/ a semi-transparent overlay */ +} + +.fc-time-grid .fc-highlight-skeleton { + z-index: 3; +} + +.fc-time-grid .fc-content-skeleton { position: absolute; - z-index: 1; + z-index: 4; top: 0; left: 0; - width: 100%; - height: 100%; + right: 0; +} + +.fc-time-grid > .fc-helper-skeleton { + z-index: 5; +} + + +/* TimeGrid Slats (lines that run horizontally) +--------------------------------------------------------------------------------------------------*/ + +.fc-slats td { + height: 1.5em; + border-bottom: 0; /* each cell is responsible for its top border */ +} + +.fc-slats .fc-minor td { + border-top-style: dotted; +} + +.fc-slats .ui-widget-content { /* for jqui theme */ + background: none; /* see through to fc-bg */ +} + + +/* TimeGrid Highlighting Slots +--------------------------------------------------------------------------------------------------*/ + +.fc-time-grid .fc-highlight-container { /* a div within a cell within the fc-highlight-skeleton */ + position: relative; /* scopes the left/right of the fc-highlight to be in the column */ +} + +.fc-time-grid .fc-highlight { + position: absolute; + left: 0; + right: 0; + /* top and bottom will be in by JS */ +} + + +/* TimeGrid Event Containment +--------------------------------------------------------------------------------------------------*/ + +.fc-time-grid .fc-event-container { /* a div within a cell within the fc-content-skeleton */ + position: relative; +} + +.fc-ltr .fc-time-grid .fc-event-container { /* space on the sides of events for LTR (default) */ + margin: 0 2.5% 0 2px; +} + +.fc-rtl .fc-time-grid .fc-event-container { /* space on the sides of events for RTL */ + margin: 0 2px 0 2.5%; +} + +.fc-time-grid .fc-event { + position: absolute; + z-index: 1; /* scope inner z-index's */ +} + + +/* TimeGrid Event Styling +--------------------------------------------------------------------------------------------------*/ + +.fc-time-grid .fc-event.fc-not-start { /* events that are continuing from another day */ + /* replace space made by the top border with padding */ + border-top-width: 0; + padding-top: 1px; + + /* remove top rounded corners */ + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.fc-time-grid .fc-event.fc-not-end { + /* replace space made by the top border with padding */ + border-bottom-width: 0; + padding-bottom: 1px; + + /* remove bottom rounded corners */ + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +/* +The above event styles will not apply to events that are being dragged. Dragged events are attached +to an outer parent not part of the .fc-view, thus we need the className "fc-time-grid-event". +The below styles WILL be applied to dragged events. +*/ + +.fc-time-grid-event { + overflow: hidden; /* don't let the bg flow over rounded corners */ +} + +.fc-time-grid-event > .fc-content { /* contains the time and title, but no bg and resizer */ + position: relative; + z-index: 2; /* above the bg */ +} + +.fc-time-grid-event .fc-time { + font-size: .85em; + white-space: nowrap; +} + +.fc-time-grid-event .fc-bg { + z-index: 1; background: #fff; opacity: .25; - filter: alpha(opacity=25); - } - -.fc .ui-draggable-dragging .fc-event-bg, /* TODO: something nicer like .fc-opacity */ -.fc-select-helper .fc-event-bg { - display: none\9; /* for IE6/7/8. nested opacity filters while dragging don't work */ - } - -/* resizable */ - -.fc-event-vert .ui-resizable-s { - bottom: 0 !important; /* importants override pre jquery ui 1.7 styles */ - width: 100% !important; - height: 8px !important; - overflow: hidden !important; - line-height: 8px !important; - font-size: 11px !important; + filter: alpha(opacity=25); /* for IE */ +} + +/* resizer */ + +.fc-time-grid-event .fc-resizer { + position: absolute; + z-index: 3; /* above content */ + left: 0; + right: 0; + bottom: 0; + height: 8px; + overflow: hidden; + line-height: 8px; + font-size: 11px; font-family: monospace; text-align: center; cursor: s-resize; - } - -.fc-agenda .ui-resizable-resizing { /* TODO: better selector */ - _overflow: hidden; - } - - +} + +.fc-time-grid-event .fc-resizer:after { + content: "="; +} diff --git a/src/basic/BasicDayView.js b/src/basic/BasicDayView.js index 00feab8..c8ac265 100644 --- a/src/basic/BasicDayView.js +++ b/src/basic/BasicDayView.js @@ -1,35 +1,35 @@ -fcViews.basicDay = BasicDayView; +/* A view with a single simple day cell +----------------------------------------------------------------------------------------------------------------------*/ -function BasicDayView(element, calendar) { // TODO: make a DayView mixin - var t = this; - - - // exports - t.incrementDate = incrementDate; - t.render = render; - - - // imports - BasicView.call(t, element, calendar, 'basicDay'); +fcViews.basicDay = BasicDayView; // register this view - - function incrementDate(date, delta) { - var out = date.clone().stripTime().add('days', delta); - out = t.skipHiddenDays(out, delta < 0 ? -1 : 1); - return out; - } - - - function render(date) { - - t.start = t.intervalStart = date.clone().stripTime(); - t.end = t.intervalEnd = t.start.clone().add('days', 1); - - t.title = calendar.formatDate(t.start, t.opt('titleFormat')); - - t.renderBasic(1, 1, false); - } - - +function BasicDayView(calendar) { + BasicView.call(this, calendar); // call the super-constructor } + + +BasicDayView.prototype = createObject(BasicView.prototype); // define the super-class +$.extend(BasicDayView.prototype, { + + name: 'basicDay', + + + incrementDate: function(date, delta) { + var out = date.clone().stripTime().add('days', delta); + out = this.skipHiddenDays(out, delta < 0 ? -1 : 1); + return out; + }, + + + render: function(date) { + + this.start = this.intervalStart = date.clone().stripTime(); + this.end = this.intervalEnd = this.start.clone().add('days', 1); + + this.title = this.calendar.formatDate(this.start, this.opt('titleFormat')); + + BasicView.prototype.render.call(this, 1, 1, false); // call the super-method + } + +}); \ No newline at end of file diff --git a/src/basic/BasicEventRenderer.js b/src/basic/BasicEventRenderer.js deleted file mode 100644 index 5fc5ec8..0000000 --- a/src/basic/BasicEventRenderer.js +++ /dev/null @@ -1,27 +0,0 @@ - -function BasicEventRenderer() { - var t = this; - - - // exports - t.renderEvents = renderEvents; - t.clearEvents = clearEvents; - - - // imports - DayEventRenderer.call(t); - - - function renderEvents(events, modifiedEventId) { - t.renderDayEvents(events, modifiedEventId); - } - - - function clearEvents() { - t.getDaySegmentContainer().empty(); - } - - - // TODO: have this class (and AgendaEventRenderer) be responsible for creating the event container div - -} diff --git a/src/basic/BasicView.js b/src/basic/BasicView.js index 538de6c..52b005a 100644 --- a/src/basic/BasicView.js +++ b/src/basic/BasicView.js @@ -1,511 +1,262 @@ -setDefaults({ - weekMode: 'fixed' -}); +/* 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. + +function BasicView(calendar) { + View.call(this, calendar); // call the super-constructor + this.dayGrid = new DayGrid(this); + this.coordMap = this.dayGrid.coordMap; // the view's date-to-cell mapping is identical to the subcomponent's +} -function BasicView(element, calendar, viewName) { - var t = this; - - - // exports - t.renderBasic = renderBasic; - t.setHeight = setHeight; - t.setWidth = setWidth; - t.renderDayOverlay = renderDayOverlay; - t.defaultSelectionEnd = defaultSelectionEnd; - t.renderSelection = renderSelection; - t.clearSelection = clearSelection; - t.reportDayClick = reportDayClick; // for selection (kinda hacky) - t.dragStart = dragStart; - t.dragStop = dragStop; - t.getHoverListener = function() { return hoverListener; }; - t.colLeft = colLeft; - t.colRight = colRight; - t.colContentLeft = colContentLeft; - t.colContentRight = colContentRight; - t.getIsCellAllDay = function() { return true; }; - t.allDayRow = allDayRow; - t.getRowCnt = function() { return rowCnt; }; - t.getColCnt = function() { return colCnt; }; - t.getColWidth = function() { return colWidth; }; - t.getDaySegmentContainer = function() { return daySegmentContainer; }; - - - // imports - View.call(t, element, calendar, viewName); - OverlayManager.call(t); - SelectionManager.call(t); - BasicEventRenderer.call(t); - var opt = t.opt; - var trigger = t.trigger; - var renderOverlay = t.renderOverlay; - var clearOverlays = t.clearOverlays; - var daySelectionMousedown = t.daySelectionMousedown; - var cellToDate = t.cellToDate; - var dateToCell = t.dateToCell; - var rangeToSegments = t.rangeToSegments; - var formatDate = calendar.formatDate; - var calculateWeekNumber = calendar.calculateWeekNumber; - - - // locals - - var table; - var head; - var headCells; - var body; - var bodyRows; - var bodyCells; - var bodyFirstCells; - var firstRowCellInners; - var firstRowCellContentInners; - var daySegmentContainer; - - var viewWidth; - var viewHeight; - var colWidth; - var weekNumberWidth; - - var rowCnt, colCnt; - var showNumbers; - var coordinateGrid; - var hoverListener; - var colPositions; - var colContentPositions; - - var tm; - var colFormat; - var showWeekNumbers; - - - - /* Rendering - ------------------------------------------------------------*/ - - - disableTextSelection(element.addClass('fc-grid')); - - - function renderBasic(_rowCnt, _colCnt, _showNumbers) { - rowCnt = _rowCnt; - colCnt = _colCnt; - showNumbers = _showNumbers; - updateOptions(); +BasicView.prototype = createObject(View.prototype); // define the super-class +$.extend(BasicView.prototype, { - if (!body) { - buildEventContainer(); + 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 + + headRowEl: null, // the fake row element of the day-of-week header + + + // Renders the view into `this.el`, which should already be assigned. + // rowCnt, colCnt, and dayNumbersVisible have been calculated by a subclass and passed here. + render: function(rowCnt, colCnt, dayNumbersVisible) { + + // needed for cell-to-date and date-to-cell calculations in View + this.rowCnt = rowCnt; + this.colCnt = colCnt; + + this.dayNumbersVisible = dayNumbersVisible; + this.weekNumbersVisible = this.opt('weekNumbers'); + this.dayGrid.numbersVisible = this.dayNumbersVisible || this.weekNumbersVisible; + + this.el.addClass('fc-basic-view').html(this.renderHtml()); + + this.headRowEl = this.el.find('thead .fc-row'); + + 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.el = this.el.find('.fc-day-grid'); + this.dayGrid.render(); + + View.prototype.render.call(this); // call the super-method + }, + + + // Builds the HTML skeleton for the view. + // The day-grid component will render inside of a container defined by this HTML. + renderHtml: function() { + return '' + + '
"); + var sectionEl = $('
'); var buttonStr = options.header[position]; + if (buttonStr) { $.each(buttonStr.split(' '), function(i) { - if (i > 0) { - e.append(""); - } - var prevButton; + var groupChildren = $(); + var isOnlyButtons = true; + var groupEl; + $.each(this.split(','), function(j, buttonName) { + var buttonClick; + var themeIcon; + var normalIcon; + var defaultText; + var customText; + var innerHtml; + var classes; + var button; + if (buttonName == 'title') { - e.append("

 

"); - if (prevButton) { - prevButton.addClass(tm + '-corner-right'); - } - prevButton = null; - }else{ - var buttonClick; + groupChildren = groupChildren.add($('

 

')); // we always want it to take up height + isOnlyButtons = false; + } + else { if (calendar[buttonName]) { buttonClick = calendar[buttonName]; // calendar method + // NOTE: won't work when we move away from parasitic inheritance } else if (fcViews[buttonName]) { buttonClick = function() { @@ -70,29 +80,34 @@ function Header(calendar, options) { if (buttonClick) { // smartProperty allows different text per view button (ex: "Agenda Week" vs "Basic Week") - var themeIcon = smartProperty(options.themeButtonIcons, buttonName); - var normalIcon = smartProperty(options.buttonIcons, buttonName); - var defaultText = smartProperty(options.defaultButtonText, buttonName); - var customText = smartProperty(options.buttonText, buttonName); - var html; + themeIcon = smartProperty(options.themeButtonIcons, buttonName); + normalIcon = smartProperty(options.buttonIcons, buttonName); + defaultText = smartProperty(options.defaultButtonText, buttonName); + customText = smartProperty(options.buttonText, buttonName); if (customText) { - html = htmlEscape(customText); + innerHtml = htmlEscape(customText); } else if (themeIcon && options.theme) { - html = ""; + innerHtml = ""; } else if (normalIcon && !options.theme) { - html = ""; + innerHtml = ""; } else { - html = htmlEscape(defaultText || buttonName); + innerHtml = htmlEscape(defaultText || buttonName); } - var button = $( - "" + - html + - "" + classes = [ + 'fc-' + buttonName + '-button', + tm + '-button', + tm + '-state-default' + ]; + + button = $( + '' ) .click(function() { if (!button.hasClass(tm + '-state-disabled')) { @@ -120,53 +135,65 @@ function Header(calendar, options) { .removeClass(tm + '-state-hover') .removeClass(tm + '-state-down'); } - ) - .appendTo(e); - disableTextSelection(button); - if (!prevButton) { - button.addClass(tm + '-corner-left'); - } - prevButton = button; + ); + + groupChildren = groupChildren.add(button); } } }); - if (prevButton) { - prevButton.addClass(tm + '-corner-right'); + + 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 e; + + return sectionEl; } - function updateTitle(html) { - element.find('h2') - .html(html); + function updateTitle(text) { + el.find('h2').text(text); } function activateButton(buttonName) { - element.find('span.fc-button-' + buttonName) + el.find('.fc-' + buttonName + '-button') .addClass(tm + '-state-active'); } function deactivateButton(buttonName) { - element.find('span.fc-button-' + buttonName) + el.find('.fc-' + buttonName + '-button') .removeClass(tm + '-state-active'); } function disableButton(buttonName) { - element.find('span.fc-button-' + buttonName) + el.find('.fc-' + buttonName + '-button') + .attr('disabled', 'disabled') .addClass(tm + '-state-disabled'); } function enableButton(buttonName) { - element.find('span.fc-button-' + buttonName) + el.find('.fc-' + buttonName + '-button') + .removeAttr('disabled') .removeClass(tm + '-state-disabled'); } - } diff --git a/src/agenda/AgendaDayView.js b/src/agenda/AgendaDayView.js index fdbaf8f..7dda747 100644 --- a/src/agenda/AgendaDayView.js +++ b/src/agenda/AgendaDayView.js @@ -1,35 +1,35 @@ -fcViews.agendaDay = AgendaDayView; +/* A day view with an all-day cell area at the top, and a time grid below +----------------------------------------------------------------------------------------------------------------------*/ -function AgendaDayView(element, calendar) { // TODO: make a DayView mixin - var t = this; - - - // exports - t.incrementDate = incrementDate; - t.render = render; - - - // imports - AgendaView.call(t, element, calendar, 'agendaDay'); - - - function incrementDate(date, delta) { - var out = date.clone().stripTime().add('days', delta); - out = t.skipHiddenDays(out, delta < 0 ? -1 : 1); - return out; - } - - - function render(date) { - - t.start = t.intervalStart = date.clone().stripTime(); - t.end = t.intervalEnd = t.start.clone().add('days', 1); - - t.title = calendar.formatDate(t.start, t.opt('titleFormat')); - - t.renderAgenda(1); - } - +fcViews.agendaDay = AgendaDayView; // register the view +function AgendaDayView(calendar) { + AgendaView.call(this, calendar); // call the super-constructor } + + +AgendaDayView.prototype = createObject(AgendaView.prototype); // define the super-class +$.extend(AgendaDayView.prototype, { + + name: 'agendaDay', + + + incrementDate: function(date, delta) { + var out = date.clone().stripTime().add('days', delta); + out = this.skipHiddenDays(out, delta < 0 ? -1 : 1); + return out; + }, + + + render: function(date) { + + this.start = this.intervalStart = date.clone().stripTime(); + this.end = this.intervalEnd = this.start.clone().add('days', 1); + + this.title = this.calendar.formatDate(this.start, this.opt('titleFormat')); + + AgendaView.prototype.render.call(this, 1); // call the super-method + } + +}); diff --git a/src/agenda/AgendaEventRenderer.js b/src/agenda/AgendaEventRenderer.js deleted file mode 100644 index 5649290..0000000 --- a/src/agenda/AgendaEventRenderer.js +++ /dev/null @@ -1,942 +0,0 @@ - -function AgendaEventRenderer() { - var t = this; - - - // exports - t.renderEvents = renderEvents; - t.clearEvents = clearEvents; - t.slotSegHtml = slotSegHtml; - - - // imports - DayEventRenderer.call(t); - var opt = t.opt; - var trigger = t.trigger; - var isEventDraggable = t.isEventDraggable; - var isEventResizable = t.isEventResizable; - var eventElementHandlers = t.eventElementHandlers; - var setHeight = t.setHeight; - var getDaySegmentContainer = t.getDaySegmentContainer; - var getSlotSegmentContainer = t.getSlotSegmentContainer; - var getHoverListener = t.getHoverListener; - var computeDateTop = t.computeDateTop; - var getIsCellAllDay = t.getIsCellAllDay; - var colContentLeft = t.colContentLeft; - var colContentRight = t.colContentRight; - var cellToDate = t.cellToDate; - var getColCnt = t.getColCnt; - var getColWidth = t.getColWidth; - var getSnapHeight = t.getSnapHeight; - var getSnapDuration = t.getSnapDuration; - var getSlotHeight = t.getSlotHeight; - var getSlotDuration = t.getSlotDuration; - var getSlotContainer = t.getSlotContainer; - var reportEventElement = t.reportEventElement; - var showEvents = t.showEvents; - var hideEvents = t.hideEvents; - var eventDrop = t.eventDrop; - var eventResize = t.eventResize; - var renderDayOverlay = t.renderDayOverlay; - var clearOverlays = t.clearOverlays; - var renderDayEvents = t.renderDayEvents; - var getMinTime = t.getMinTime; - var getMaxTime = t.getMaxTime; - var calendar = t.calendar; - var formatDate = calendar.formatDate; - var getEventEnd = calendar.getEventEnd; - - - // overrides - t.draggableDayEvent = draggableDayEvent; - - - - /* Rendering - ----------------------------------------------------------------------------*/ - - - function renderEvents(events, modifiedEventId) { - var i, len=events.length, - dayEvents=[], - slotEvents=[]; - for (i=0; i rangeStart && eventStart < rangeEnd) { - - if (eventStart < rangeStart) { - segStart = rangeStart.clone(); - isStart = false; - } - else { - segStart = eventStart; - isStart = true; - } - - if (eventEnd > rangeEnd) { - segEnd = rangeEnd.clone(); - isEnd = false; - } - else { - segEnd = eventEnd; - isEnd = true; - } - - segs.push({ - event: event, - start: segStart, - end: segEnd, - isStart: isStart, - isEnd: isEnd - }); - } - } - - 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); - } - eventElementHandlers(event, eventElement); - } - - - - /* 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; - } - - eventElement.draggable('option', 'revert', revert); - - }, 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); - } - 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)); - } - - eventDrop( - eventElement[0], - event, - eventStart, - ev, - ui - ); - } - } - }); - function resetElement() { - if (!allDay) { - eventElement - .width(origWidth) - .height('') - .draggable('option', 'grid', null); - allDay = true; - } - } - } - - - // 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); - } - - updateUI(); - - // update previous states for next time - prevIsInBounds = isInBounds; - prevIsAllDay = isAllDay; - prevColDelta = colDelta; - prevSnapDelta = snapDelta; - } - - // if out-of-bounds, revert when done, and vice versa. - eventElement.draggable('option', 'revert', !isInBounds); - - }, - 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) - - // reset states for next time, and for updateUI() - isInBounds = true; - isAllDay = false; - colDelta = 0; - dayDelta = 0; - snapDelta = 0; - - updateUI(); - eventElement.css('filter', ''); // clear IE opacity side-effects - - // 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); - - showEvents(event, eventElement); - } - } - }); - - 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 - } - } - } - - 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 - ); - } - } - - } - - - - /* 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 - } - } - }); - } - - -} - - - -/* Agenda Event Segment Utilities ------------------------------------------------------------------------------*/ - - -// 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; - - computeForwardSlotSegs(levels); - - if (level0) { - - for (i=0; i seg2.start && seg1.start < seg2.end; -} - - -// 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); -} - - -// 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 -} - diff --git a/src/agenda/AgendaView.js b/src/agenda/AgendaView.js index 8a73c27..50ff0ca 100644 --- a/src/agenda/AgendaView.js +++ b/src/agenda/AgendaView.js @@ -1,4 +1,9 @@ +/* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically. +----------------------------------------------------------------------------------------------------------------------*/ +// Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on). +// Responsible for managing width/height. + setDefaults({ allDaySlot: true, allDayText: 'all-day', @@ -12,9 +17,6 @@ setDefaults({ agenda: generateAgendaTimeFormat }, - dragOpacity: { - agenda: .5 - }, minTime: '00:00:00', maxTime: '24:00:00', slotEventOverlap: true @@ -35,923 +37,358 @@ function generateAgendaTimeFormat(options, langData) { } -// TODO: make it work in quirks mode (event corners, all-day height) -// TODO: test liquid width, especially in IE6 +function AgendaView(calendar) { + View.call(this, calendar); // call the super-constructor + + this.timeGrid = new TimeGrid(this); + + if (this.opt('allDaySlot')) { // should we display the "all-day" area? + this.dayGrid = new DayGrid(this); // the all-day subcomponent of this view + + // the coordinate grid will be a combination of both subcomponents' grids + this.coordMap = new ComboCoordMap([ + this.dayGrid.coordMap, + this.timeGrid.coordMap + ]); + } + else { + this.coordMap = this.timeGrid.coordMap; + } +} -function AgendaView(element, calendar, viewName) { - var t = this; - - - // exports - t.renderAgenda = renderAgenda; - t.setWidth = setWidth; - t.setHeight = setHeight; - t.afterRender = afterRender; - t.computeDateTop = computeDateTop; - t.getIsCellAllDay = getIsCellAllDay; - t.allDayRow = function() { return allDayRow; }; // badly named - t.getCoordinateGrid = function() { return coordinateGrid; }; // specifically for AgendaEventRenderer - t.getHoverListener = function() { return hoverListener; }; - t.colLeft = colLeft; - t.colRight = colRight; - t.colContentLeft = colContentLeft; - t.colContentRight = colContentRight; - t.getDaySegmentContainer = function() { return daySegmentContainer; }; - t.getSlotSegmentContainer = function() { return slotSegmentContainer; }; - t.getSlotContainer = function() { return slotContainer; }; - t.getRowCnt = function() { return 1; }; - t.getColCnt = function() { return colCnt; }; - t.getColWidth = function() { return colWidth; }; - t.getSnapHeight = function() { return snapHeight; }; - t.getSnapDuration = function() { return snapDuration; }; - t.getSlotHeight = function() { return slotHeight; }; - t.getSlotDuration = function() { return slotDuration; }; - t.getMinTime = function() { return minTime; }; - t.getMaxTime = function() { return maxTime; }; - t.defaultSelectionEnd = defaultSelectionEnd; - t.renderDayOverlay = renderDayOverlay; - t.renderSelection = renderSelection; - t.clearSelection = clearSelection; - t.reportDayClick = reportDayClick; // selection mousedown hack - t.dragStart = dragStart; - t.dragStop = dragStop; - - - // imports - View.call(t, element, calendar, viewName); - OverlayManager.call(t); - SelectionManager.call(t); - AgendaEventRenderer.call(t); - var opt = t.opt; - var trigger = t.trigger; - var renderOverlay = t.renderOverlay; - var clearOverlays = t.clearOverlays; - var reportSelection = t.reportSelection; - var unselect = t.unselect; - var daySelectionMousedown = t.daySelectionMousedown; - var slotSegHtml = t.slotSegHtml; - var cellToDate = t.cellToDate; - var dateToCell = t.dateToCell; - var rangeToSegments = t.rangeToSegments; - var formatDate = calendar.formatDate; - var calculateWeekNumber = calendar.calculateWeekNumber; - - - // locals - - var dayTable; - var dayHead; - var dayHeadCells; - var dayBody; - var dayBodyCells; - var dayBodyCellInners; - var dayBodyCellContentInners; - var dayBodyFirstCell; - var dayBodyFirstCellStretcher; - var slotLayer; - var daySegmentContainer; - var allDayTable; - var allDayRow; - var slotScroller; - var slotContainer; - var slotSegmentContainer; - var slotTable; - var selectionHelper; - - var viewWidth; - var viewHeight; - var axisWidth; - var colWidth; - var gutterWidth; +AgendaView.prototype = createObject(View.prototype); // define the super-class +$.extend(AgendaView.prototype, { - var slotDuration; - var slotHeight; // TODO: what if slotHeight changes? (see issue 650) + timeGrid: null, // the main time-grid subcomponent of this view + dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null + + axisWidth: null, // the width of the time axis running down the side + + noScrollRowEls: null, // set of fake row elements that must compensate when scrollerEl has scrollbars + + // when the time-grid isn't tall enough to occupy the given height, we render an
underneath + bottomRuleEl: null, + bottomRuleHeight: null, - var snapDuration; - var snapRatio; // ratio of number of "selection" slots to normal slots. (ex: 1, 2, 4) - var snapHeight; // holds the pixel hight of a "selection" slot - - var colCnt; - var slotCnt; - var coordinateGrid; - var hoverListener; - var colPositions; - var colContentPositions; - var slotTopCache = {}; - - var tm; - var rtl; - var minTime; - var maxTime; - var colFormat; - - /* Rendering - -----------------------------------------------------------------------------*/ - - - disableTextSelection(element.addClass('fc-agenda')); - - - function renderAgenda(c) { - colCnt = c; - updateOptions(); - - if (!dayTable) { // first time rendering? - buildSkeleton(); // builds day table, slot area, events containers - } - else { - buildDayTable(); // rebuilds day table - } - } - - - function updateOptions() { - - tm = opt('theme') ? 'ui' : 'fc'; - rtl = opt('isRTL'); - colFormat = opt('columnFormat'); - - minTime = moment.duration(opt('minTime')); - maxTime = moment.duration(opt('maxTime')); - - slotDuration = moment.duration(opt('slotDuration')); - snapDuration = opt('snapDuration'); - snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration; - } + ------------------------------------------------------------------------------------------------------------------*/ + // Renders the view into `this.el`, which has already been assigned. + // `colCnt` has been calculated by a subclass and passed here. + render: function(colCnt) { - /* Build DOM - -----------------------------------------------------------------------*/ + // needed for cell-to-date and date-to-cell calculations in View + this.rowCnt = 1; + this.colCnt = colCnt; + this.el.addClass('fc-agenda-view').html(this.renderHtml()); - function buildSkeleton() { - var s; - var headerClass = tm + "-widget-header"; - var contentClass = tm + "-widget-content"; - var slotTime; - var slotDate; - var minutes; - var slotNormal = slotDuration.asMinutes() % 15 === 0; - - buildDayTable(); - - slotLayer = - $("
") - .appendTo(element); - - if (opt('allDaySlot')) { - - daySegmentContainer = - $("
") - .appendTo(slotLayer); - - s = - "" + - "" + - "" + - "" + - "" + - "" + - "
" + - ( - opt('allDayHTML') || - htmlEscape(opt('allDayText')) - ) + - "" + - "
" + - "
 
"; - allDayTable = $(s).appendTo(slotLayer); - allDayRow = allDayTable.find('tr'); - - dayBind(allDayRow.find('td')); - - slotLayer.append( - "
" + - "
" + - "
" - ); - - }else{ - - daySegmentContainer = $([]); // in jQuery 1.4, we can just do $() - - } - - slotScroller = - $("
") - .appendTo(slotLayer); - - slotContainer = - $("
") - .appendTo(slotScroller); - - slotSegmentContainer = - $("
") - .appendTo(slotContainer); - - s = - "" + - ""; + // the element that wraps the time-grid that will probably scroll + this.scrollerEl = this.el.find('.fc-time-grid-container'); + this.timeGrid.coordMap.containerEl = this.scrollerEl; // don't accept clicks/etc outside of this - slotTime = moment.duration(+minTime); // i wish there was .clone() for durations - slotCnt = 0; - while (slotTime < maxTime) { - slotDate = t.start.clone().time(slotTime); // will be in UTC but that's good. to avoid DST issues - minutes = slotDate.minutes(); - s += - "" + - "" + - "" + - ""; - slotTime.add(slotDuration); - slotCnt++; + this.timeGrid.el = this.el.find('.fc-time-grid'); + this.timeGrid.render(); + + // the
that sometimes displays under the time-grid + this.bottomRuleEl = $('
') + .appendTo(this.timeGrid.el); // inject it into the time-grid + + if (this.dayGrid) { + this.dayRowThemeClass = this.widgetHeaderClass; // forces this class on each day-row + + this.dayGrid.el = this.el.find('.fc-day-grid'); + this.dayGrid.render(); + + // have the day-grid extend it's coordinate area over the
dividing the two grids + this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight(); } - s += - "" + - "
" + - ((!slotNormal || !minutes) ? - htmlEscape(formatDate(slotDate, opt('axisFormat'))) : - ' ' - ) + - "" + - "
 
" + - "
"; + this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller - slotTable = $(s).appendTo(slotContainer); - - slotBind(slotTable.find('td')); - } + View.prototype.render.call(this); // call the super-method + + this.resetScroll(); // do this after sizes have been set + }, - - /* Build Day Table - -----------------------------------------------------------------------*/ + // Builds the HTML skeleton for the view. + // The day-grid and time-grid components will render inside containers defined by this HTML. + renderHtml: function() { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + this.timeGrid.headHtml() + // render the day-of-week headers + '
' + + (this.dayGrid ? + '
' + + '
' : + '' + ) + + '
' + + '
' + + '
' + + '
'; + }, - function buildDayTable() { - var html = buildDayTableHTML(); - - if (dayTable) { - dayTable.remove(); - } - dayTable = $(html).appendTo(element); - - dayHead = dayTable.find('thead'); - dayHeadCells = dayHead.find('th').slice(1, -1); // exclude gutter - dayBody = dayTable.find('tbody'); - dayBodyCells = dayBody.find('td').slice(0, -1); // exclude gutter - dayBodyCellInners = dayBodyCells.find('> div'); - dayBodyCellContentInners = dayBodyCells.find('.fc-day-content > div'); - - dayBodyFirstCell = dayBodyCells.eq(0); - dayBodyFirstCellStretcher = dayBodyCellInners.eq(0); - - markFirstLast(dayHead.add(dayHead.find('tr'))); - markFirstLast(dayBody.add(dayBody.find('tr'))); - - // TODO: now that we rebuild the cells every time, we should call dayRender - } - - - function buildDayTableHTML() { - var html = - "" + - buildDayTableHeadHTML() + - buildDayTableBodyHTML() + - "
"; - - return html; - } - - - function buildDayTableHeadHTML() { - var headerClass = tm + "-widget-header"; + // Generates the HTML that will go before the day-of week header cells. + // Queried by the TimeGrid subcomponent when generating rows. Ordering depends on isRTL. + headIntroHtml: function() { var date; - var html = ''; + var weekNumber; + var weekTitle; var weekText; - var col; - html += - "
" + - htmlEscape(weekText) + - "' + + '' + // needed for matchCellWidths + htmlEscape(weekText) + + '' + + ' ' + + '' + // needed for matchCellWidths + (this.opt('allDayHTML') || htmlEscape(this.opt('allDayText'))) + + '' + + ' 
 " + - "
" + - "
" + - "
 
" + - "
" + - "
" + - "
 
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + this.dayGrid.headHtml() + // render the day-of-week headers + '
' + + '
' + + '
' + + '
' + + '
'; + }, + + + // 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')) + + '' + + ''; + } + }, + + + // 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.calendar.calculateWeekNumber(this.cellToDate(row, 0)) + + '' + + ''; + } + }, + + + // 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 ''; + } + }, + + + // 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(row, col, date) { + var classes; + + if (!this.dayNumbersVisible) { // if there are week numbers but not day numbers + return ''; // will create an empty space above events :( } - buildTable(); - } - - - function updateOptions() { - tm = opt('theme') ? 'ui' : 'fc'; - colFormat = opt('columnFormat'); - showWeekNumbers = opt('weekNumbers'); - } - - - function buildEventContainer() { - daySegmentContainer = - $("
") - .appendTo(element); - } - - - function buildTable() { - var html = buildTableHTML(); - - if (table) { - table.remove(); - } - table = $(html).appendTo(element); - - head = table.find('thead'); - headCells = head.find('.fc-day-header'); - body = table.find('tbody'); - bodyRows = body.find('tr'); - bodyCells = body.find('.fc-day'); - bodyFirstCells = bodyRows.find('td:first-child'); - - firstRowCellInners = bodyRows.eq(0).find('.fc-day > div'); - firstRowCellContentInners = bodyRows.eq(0).find('.fc-day-content > div'); - - markFirstLast(head.add(head.find('tr'))); // marks first+last tr/th's - markFirstLast(bodyRows); // marks first+last td's - bodyRows.eq(0).addClass('fc-first'); - bodyRows.filter(':last').addClass('fc-last'); - - bodyCells.each(function(i, _cell) { - var date = cellToDate( - Math.floor(i / colCnt), - i % colCnt - ); - trigger('dayRender', t, date, $(_cell)); - }); - - dayBind(bodyCells); - } - - - - /* HTML Building - -----------------------------------------------------------*/ - - - function buildTableHTML() { - var html = - "" + - buildHeadHTML() + - buildBodyHTML() + - "
"; - - return html; - } - - - function buildHeadHTML() { - var headerClass = tm + "-widget-header"; - var html = ''; - var col; - var date; - - html += ""; - - if (showWeekNumbers) { - html += - "" + - htmlEscape(opt('weekNumberTitle')) + - ""; - } - - for (col=0; col" + - htmlEscape(formatDate(date, colFormat)) + - ""; - } - - html += ""; - - return html; - } - - - function buildBodyHTML() { - var contentClass = tm + "-widget-content"; - var html = ''; - var row; - var col; - var date; - - html += ""; - - for (row=0; row" + - "
" + - htmlEscape(calculateWeekNumber(date)) + - "
" + - ""; - } - - for (col=0; col" + - "
"; - - if (showNumbers) { - html += "
" + date.date() + "
"; - } - - html += - "
" + - "
 
" + - "
" + - "
" + - ""; - - return html; - } + classes = this.dayGrid.getDayClasses(date); + classes.unshift('fc-day-number'); + return '' + + '' + + date.date() + + ''; + }, /* Dimensions - -----------------------------------------------------------*/ - - - function setHeight(height) { - viewHeight = height; - - var bodyHeight = Math.max(viewHeight - head.height(), 0); - var rowHeight; - var rowHeightLast; - var cell; - - if (opt('weekMode') == 'variable') { - rowHeight = rowHeightLast = Math.floor(bodyHeight / (rowCnt==1 ? 2 : 6)); - }else{ - rowHeight = Math.floor(bodyHeight / rowCnt); - rowHeightLast = bodyHeight - rowHeight * (rowCnt-1); - } - - bodyFirstCells.each(function(i, _cell) { - if (i < rowCnt) { - cell = $(_cell); - cell.find('> div').css( - 'min-height', - (i==rowCnt-1 ? rowHeightLast : rowHeight) - vsides(cell) - ); - } - }); - - } - - - function setWidth(width) { - viewWidth = width; - colPositions.clear(); - colContentPositions.clear(); - - weekNumberWidth = 0; - if (showWeekNumbers) { - weekNumberWidth = head.find('th.fc-week-number').outerWidth(); - } - - colWidth = Math.floor((viewWidth - weekNumberWidth) / colCnt); - setOuterWidth(headCells.slice(0, -1), colWidth); - } - - - - /* Day clicking and binding - -----------------------------------------------------------*/ - - - function dayBind(days) { - days.click(dayClick) - .mousedown(daySelectionMousedown); - } - - - function dayClick(ev) { - if (!opt('selectable')) { // if selectable, SelectionManager will worry about dayClick - var date = calendar.moment($(this).data('date')); - trigger('dayClick', this, date, ev); - } - } - - - - /* Semi-transparent Overlay Helpers - ------------------------------------------------------*/ - // TODO: should be consolidated with AgendaView's methods + ------------------------------------------------------------------------------------------------------------------*/ - function renderDayOverlay(overlayStart, overlayEnd, refreshCoordinateGrid) { // overlayEnd is exclusive - - if (refreshCoordinateGrid) { - coordinateGrid.build(); - } - - var segments = rangeToSegments(overlayStart, overlayEnd); - - for (var i=0; i scrollerHeight) { // should we show scrollbars? + + this.scrollerEl.height(scrollerHeight).addClass('fc-scroller'); + compensateScroll(this.headRowEl, getScrollbarWidths(this.scrollerEl)); + + // doing the scrollbar compensation might have created text overflow which created more height. redo + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.scrollerEl.height(scrollerHeight); + + this.restoreScroll(); + } + }, + + + // Sets the height of just the DayGrid component in this view + setGridHeight: function(height, isAuto) { + if (isAuto) { + undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding + } + else { + distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows + } + }, + + + /* Events + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders the given events onto the view and populates the segments array + renderEvents: function(events) { + this.segs = this.dayGrid.renderEvents(events); + + this.updateHeight(); // must compensate for events that overflow the row + + View.prototype.renderEvents.call(this, events); // call the super-method + }, + + + // Unrenders all event elements and clears internal segment data + destroyEvents: function() { + this.recordScroll(); // removing events will reduce height and mess with the scroll, so record beforehand + this.dayGrid.destroyEvents(); + + this.updateHeight(); + + View.prototype.destroyEvents.call(this); // call the super-method + }, + + + /* Event Dragging + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of an event being dragged over the view. + // A returned value of `true` signals that a mock "helper" event has been rendered. + renderDrag: function(start, end, seg) { + return this.dayGrid.renderDrag(start, end, seg); + }, + + + // Unrenders the visual indication of an event being dragged over the view + destroyDrag: function() { + this.dayGrid.destroyDrag(); + }, + - - function renderCellOverlay(row0, col0, row1, col1) { // row1,col1 is inclusive - var rect = coordinateGrid.rect(row0, col0, row1, col1, element); - return renderOverlay(rect, element); - } - - - /* Selection - -----------------------------------------------------------------------*/ - - - function defaultSelectionEnd(start) { - return start.clone().stripTime().add('days', 1); - } - - - function renderSelection(start, end) { // end is exclusive - renderDayOverlay(start, end, true); // true = rebuild every time - } - - - function clearSelection() { - clearOverlays(); - } - - - function reportDayClick(date, ev) { - var cell = dateToCell(date); - var _element = bodyCells[cell.row*colCnt + cell.col]; - trigger('dayClick', _element, date, ev); - } - - - - /* External Dragging - -----------------------------------------------------------------------*/ - - - function dragStart(_dragElement, ev, ui) { - hoverListener.start(function(cell) { - clearOverlays(); - if (cell) { - var d1 = cellToDate(cell); - var d2 = d1.clone().add(calendar.defaultAllDayEventDuration); - renderDayOverlay(d1, d2); - } - }, ev); - } - - - function dragStop(_dragElement, ev, ui) { - var cell = hoverListener.stop(); - clearOverlays(); - if (cell) { - trigger( - 'drop', - _dragElement, - cellToDate(cell), - ev, - ui - ); - } - } - - - - /* Utilities - --------------------------------------------------------*/ - - - coordinateGrid = new CoordinateGrid(function(rows, cols) { - var e, n, p; - headCells.each(function(i, _e) { - e = $(_e); - n = e.offset().left; - if (i) { - p[1] = n; - } - p = [n]; - cols[i] = p; - }); - p[1] = n + e.outerWidth(); - bodyRows.each(function(i, _e) { - if (i < rowCnt) { - e = $(_e); - n = e.offset().top; - if (i) { - p[1] = n; - } - p = [n]; - rows[i] = p; - } - }); - p[1] = n + e.outerHeight(); - }); - - - hoverListener = new HoverListener(coordinateGrid); - - colPositions = new HorizontalPositionCache(function(col) { - return firstRowCellInners.eq(col); - }); - - colContentPositions = new HorizontalPositionCache(function(col) { - return firstRowCellContentInners.eq(col); - }); + ------------------------------------------------------------------------------------------------------------------*/ - function colLeft(col) { - return colPositions.left(col); - } + // Renders a visual indication of a selection + renderSelection: function(start, end) { + this.dayGrid.renderSelection(start, end); + }, - function colRight(col) { - return colPositions.right(col); + // Unrenders a visual indications of a selection + destroySelection: function() { + this.dayGrid.destroySelection(); } - - - function colContentLeft(col) { - return colContentPositions.left(col); - } - - - function colContentRight(col) { - return colContentPositions.right(col); - } - - - function allDayRow(i) { - return bodyRows.eq(i); - } - -} + +}); diff --git a/src/basic/BasicWeekView.js b/src/basic/BasicWeekView.js index 39b3b84..00b3c1c 100644 --- a/src/basic/BasicWeekView.js +++ b/src/basic/BasicWeekView.js @@ -1,41 +1,42 @@ -fcViews.basicWeek = BasicWeekView; +/* A week view with simple day cells running horizontally +----------------------------------------------------------------------------------------------------------------------*/ +// TODO: a WeekView mixin for calculating dates and titles -function BasicWeekView(element, calendar) { // TODO: do a WeekView mixin - var t = this; - - - // exports - t.incrementDate = incrementDate; - t.render = render; - - - // imports - BasicView.call(t, element, calendar, 'basicWeek'); +fcViews.basicWeek = BasicWeekView; // register this view + +function BasicWeekView(calendar) { + BasicView.call(this, calendar); // call the super-constructor +} - function incrementDate(date, delta) { +BasicWeekView.prototype = createObject(BasicView.prototype); // define the super-class +$.extend(BasicWeekView.prototype, { + + name: 'basicWeek', + + + incrementDate: function(date, delta) { return date.clone().stripTime().add('weeks', delta).startOf('week'); - } + }, - function render(date) { + render: function(date) { - t.intervalStart = date.clone().stripTime().startOf('week'); - t.intervalEnd = t.intervalStart.clone().add('weeks', 1); + this.intervalStart = date.clone().stripTime().startOf('week'); + this.intervalEnd = this.intervalStart.clone().add('weeks', 1); - t.start = t.skipHiddenDays(t.intervalStart); - t.end = t.skipHiddenDays(t.intervalEnd, -1, true); + this.start = this.skipHiddenDays(this.intervalStart); + this.end = this.skipHiddenDays(this.intervalEnd, -1, true); - t.title = calendar.formatRange( - t.start, - t.end.clone().subtract(1), // make inclusive by subtracting 1 ms - t.opt('titleFormat'), + this.title = this.calendar.formatRange( + this.start, + this.end.clone().subtract(1), // make inclusive by subtracting 1 ms + this.opt('titleFormat'), ' \u2014 ' // emphasized dash ); - t.renderBasic(1, t.getCellsPerWeek(), false); + BasicView.prototype.render.call(this, 1, this.getCellsPerWeek(), false); // call the super-method } - -} +}); \ No newline at end of file diff --git a/src/basic/MonthView.js b/src/basic/MonthView.js index fb330fa..9f0157f 100644 --- a/src/basic/MonthView.js +++ b/src/basic/MonthView.js @@ -1,51 +1,79 @@ -fcViews.month = MonthView; +/* A month view with day cells running in rows (one-per-week) and columns +----------------------------------------------------------------------------------------------------------------------*/ -function MonthView(element, calendar) { - var t = this; - - - // exports - t.incrementDate = incrementDate; - t.render = render; - - - // imports - BasicView.call(t, element, calendar, 'month'); +setDefaults({ + fixedWeekCount: true +}); + +fcViews.month = MonthView; // register the view + +function MonthView(calendar) { + BasicView.call(this, calendar); // call the super-constructor +} - function incrementDate(date, delta) { +MonthView.prototype = createObject(BasicView.prototype); // define the super-class +$.extend(MonthView.prototype, { + + name: 'month', + + + incrementDate: function(date, delta) { return date.clone().stripTime().add('months', delta).startOf('month'); - } + }, - function render(date) { + render: function(date) { + var rowCnt; - t.intervalStart = date.clone().stripTime().startOf('month'); - t.intervalEnd = t.intervalStart.clone().add('months', 1); + this.intervalStart = date.clone().stripTime().startOf('month'); + this.intervalEnd = this.intervalStart.clone().add('months', 1); - t.start = t.intervalStart.clone(); - t.start = t.skipHiddenDays(t.start); // move past the first week if no visible days - t.start.startOf('week'); - t.start = t.skipHiddenDays(t.start); // move past the first invisible days of the week + this.start = this.intervalStart.clone(); + this.start = this.skipHiddenDays(this.start); // move past the first week if no visible days + this.start.startOf('week'); + this.start = this.skipHiddenDays(this.start); // move past the first invisible days of the week - t.end = t.intervalEnd.clone(); - t.end = t.skipHiddenDays(t.end, -1, true); // move in from the last week if no visible days - t.end.add('days', (7 - t.end.weekday()) % 7); // move to end of week if not already - t.end = t.skipHiddenDays(t.end, -1, true); // move in from the last invisible days of the week + this.end = this.intervalEnd.clone(); + this.end = this.skipHiddenDays(this.end, -1, true); // move in from the last week if no visible days + this.end.add('days', (7 - this.end.weekday()) % 7); // move to end of week if not already + this.end = this.skipHiddenDays(this.end, -1, true); // move in from the last invisible days of the week - var rowCnt = Math.ceil( // need to ceil in case there are hidden days - t.end.diff(t.start, 'weeks', true) // returnfloat=true + rowCnt = Math.ceil( // need to ceil in case there are hidden days + this.end.diff(this.start, 'weeks', true) // returnfloat=true ); - if (t.opt('weekMode') == 'fixed') { - t.end.add('weeks', 6 - rowCnt); + if (this.isFixedWeeks()) { + this.end.add('weeks', 6 - rowCnt); rowCnt = 6; } - t.title = calendar.formatDate(t.intervalStart, t.opt('titleFormat')); + this.title = this.calendar.formatDate(this.intervalStart, this.opt('titleFormat')); - t.renderBasic(rowCnt, t.getCellsPerWeek(), true); + BasicView.prototype.render.call(this, rowCnt, this.getCellsPerWeek(), true); // call the super-method + }, + + + // Overrides the default BasicView behavior to have special multi-week auto-height logic + setGridHeight: function(height, isAuto) { + isAuto = isAuto || this.opt('weekMode') === 'variable'; // LEGACY: weekMode is deprecated + + // if auto, make the height of each row the height that it would be if there were 6 weeks + if (isAuto) { + height *= this.rowCnt / 6; + } + + distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows + }, + + + isFixedWeeks: function() { + var weekMode = this.opt('weekMode'); // LEGACY: weekMode is deprecated + if (weekMode) { + return weekMode === 'fixed'; // if any other type of weekMode, assume NOT fixed + } + + return this.opt('fixedWeekCount'); } - - -} + +}); diff --git a/src/basic/basic.css b/src/basic/basic.css index d006a99..0b91aec 100644 --- a/src/basic/basic.css +++ b/src/basic/basic.css @@ -1,52 +1,47 @@ -/* Month View, Basic Week View, Basic Day View -------------------------------------------------------------------------*/ +/* BasicView +--------------------------------------------------------------------------------------------------*/ -.fc-grid th { - text-align: center; - } +/* day row structure */ -.fc .fc-week-number { - width: 22px; - text-align: center; - } +.fc-basicWeek-view .fc-content-skeleton, +.fc-basicDay-view .fc-content-skeleton { + /* in basicWeek and basicDay views, where we are sure there are no day numbers, ensure + a space at the bottom of the cell to allow for day selecting/clicking */ + padding-bottom: 1em; +} -.fc .fc-week-number div { +.fc-basic-view tbody .fc-row { + min-height: 4em; /* ensure that all rows are at least this tall */ +} + +/* week and day number styling */ + +.fc-basic-view .fc-week-number, +.fc-basic-view .fc-day-number { padding: 0 2px; - } - -.fc-grid .fc-day-number { - float: right; - padding: 0 2px; - } - -.fc-grid .fc-other-month .fc-day-number { - opacity: 0.3; - filter: alpha(opacity=30); /* for IE */ - /* opacity with small font can sometimes look too faded - might want to set the 'color' property instead - making day-numbers bold also fixes the problem */ - } - -.fc-grid .fc-day-content { - clear: both; - padding: 2px 2px 1px; /* distance between events and day edges */ - } - -/* event styles */ - -.fc-grid .fc-event-time { - font-weight: bold; - } - -/* right-to-left */ - -.fc-rtl .fc-grid .fc-day-number { - float: left; - } - -.fc-rtl .fc-grid .fc-event-time { - float: right; - } - - +} + +.fc-basic-view td.fc-week-number span, +.fc-basic-view td.fc-day-number { + padding-top: 2px; + padding-bottom: 2px; +} + +.fc-basic-view .fc-week-number { + text-align: center; +} + +.fc-basic-view .fc-week-number span { + /* work around the way we do column resizing and ensure a minimum width */ + display: inline-block; + min-width: 1.25em; +} + +.fc-ltr .fc-basic-view .fc-day-number { + text-align: right; +} + +.fc-rtl .fc-basic-view .fc-day-number { + text-align: left; +} diff --git a/src/common/CoordMap.js b/src/common/CoordMap.js new file mode 100644 index 0000000..82a218d --- /dev/null +++ b/src/common/CoordMap.js @@ -0,0 +1,142 @@ + +/* A "coordinate map" converts pixel coordinates into an associated cell, which has an associated date +------------------------------------------------------------------------------------------------------------------------ +Common interface: + + CoordMap.prototype = { + build: function() {}, + getCell: function(x, y) {} + }; + +*/ + +/* Coordinate map for a grid component +----------------------------------------------------------------------------------------------------------------------*/ + +function GridCoordMap(grid) { + this.grid = grid; +} + + +GridCoordMap.prototype = { + + grid: null, // reference to the Grid + rows: null, // the top-to-bottom y coordinates. including the bottom of the last item + cols: null, // the left-to-right x coordinates. including the right of the last item + + containerEl: null, // container element that all coordinates are constrained to. optionally assigned + minX: null, + maxX: null, // exclusive + minY: null, + maxY: null, // exclusive + + + // Queries the grid for the coordinates of all the cells + build: function() { + this.grid.buildCoords( + this.rows = [], + this.cols = [] + ); + this.computeBounds(); + }, + + + // Given a coordinate of the document, gets the associated cell. If no cell is underneath, returns null + getCell: function(x, y) { + var cell = null; + var rows = this.rows; + var cols = this.cols; + var r = -1; + var c = -1; + var i; + + if (this.inBounds(x, y)) { + + for (i = 0; i < rows.length; i++) { + if (y >= rows[i][0] && y < rows[i][1]) { + r = i; + break; + } + } + + for (i = 0; i < cols.length; i++) { + if (x >= cols[i][0] && x < cols[i][1]) { + c = i; + break; + } + } + + if (r >= 0 && c >= 0) { + cell = { row: r, col: c }; + cell.grid = this.grid; + cell.date = this.grid.getCellDate(cell); + } + } + + return cell; + }, + + + // If there is a containerEl, compute the bounds into min/max values + computeBounds: function() { + var containerOffset; + + if (this.containerEl) { + containerOffset = this.containerEl.offset(); + this.minX = containerOffset.left; + this.maxX = containerOffset.left + this.containerEl.outerWidth(); + this.minY = containerOffset.top; + this.maxY = containerOffset.top + this.containerEl.outerHeight(); + } + }, + + + // Determines if the given coordinates are in bounds. If no `containerEl`, always true + inBounds: function(x, y) { + if (this.containerEl) { + return x >= this.minX && x < this.maxX && y >= this.minY && y < this.maxY; + } + return true; + } + +}; + + +/* Coordinate map that is a combination of multiple other coordinate maps +----------------------------------------------------------------------------------------------------------------------*/ + +function ComboCoordMap(coordMaps) { + this.coordMaps = coordMaps; +} + + +ComboCoordMap.prototype = { + + coordMaps: null, // an array of CoordMaps + + + // Builds all coordMaps + build: function() { + var coordMaps = this.coordMaps; + var i; + + for (i = 0; i < coordMaps.length; i++) { + coordMaps[i].build(); + } + }, + + + // Queries all coordMaps for the cell underneath the given coordinates, returning the first result + getCell: function(x, y) { + var coordMaps = this.coordMaps; + var cell = null; + var i; + + for (i = 0; i < coordMaps.length && !cell; i++) { + cell = coordMaps[i].getCell(x, y); + } + + return cell; + } + +}; diff --git a/src/common/CoordinateGrid.js b/src/common/CoordinateGrid.js deleted file mode 100644 index e200873..0000000 --- a/src/common/CoordinateGrid.js +++ /dev/null @@ -1,46 +0,0 @@ - -function CoordinateGrid(buildFunc) { - - var t = this; - var rows; - var cols; - - - t.build = function() { - rows = []; - cols = []; - buildFunc(rows, cols); - }; - - - t.cell = function(x, y) { - var rowCnt = rows.length; - var colCnt = cols.length; - var i, r=-1, c=-1; - for (i=0; i= rows[i][0] && y < rows[i][1]) { - r = i; - break; - } - } - for (i=0; i= cols[i][0] && x < cols[i][1]) { - c = i; - break; - } - } - return (r>=0 && c>=0) ? { row: r, col: c } : null; - }; - - - t.rect = function(row0, col0, row1, col1, originElement) { // row1,col1 is inclusive - var origin = originElement.offset(); - return { - top: rows[row0][0] - origin.top, - left: cols[col0][0] - origin.left, - width: cols[col1][1] - cols[col0][0], - height: rows[row1][1] - rows[row0][0] - }; - }; - -} diff --git a/src/common/DayEventRenderer.js b/src/common/DayEventRenderer.js deleted file mode 100644 index daacdc1..0000000 --- a/src/common/DayEventRenderer.js +++ /dev/null @@ -1,764 +0,0 @@ - -function DayEventRenderer() { - var t = this; - - - // exports - t.renderDayEvents = renderDayEvents; - t.draggableDayEvent = draggableDayEvent; // made public so that subclasses can override - t.resizableDayEvent = resizableDayEvent; // " - - - // imports - var opt = t.opt; - var trigger = t.trigger; - var isEventDraggable = t.isEventDraggable; - var isEventResizable = t.isEventResizable; - var reportEventElement = t.reportEventElement; - var eventElementHandlers = t.eventElementHandlers; - var showEvents = t.showEvents; - var hideEvents = t.hideEvents; - var eventDrop = t.eventDrop; - var eventResize = t.eventResize; - var getRowCnt = t.getRowCnt; - var getColCnt = t.getColCnt; - var allDayRow = t.allDayRow; // TODO: rename - var colLeft = t.colLeft; - var colRight = t.colRight; - var colContentLeft = t.colContentLeft; - var colContentRight = t.colContentRight; - var getDaySegmentContainer = t.getDaySegmentContainer; - var renderDayOverlay = t.renderDayOverlay; - var clearOverlays = t.clearOverlays; - var clearSelection = t.clearSelection; - var getHoverListener = t.getHoverListener; - var rangeToSegments = t.rangeToSegments; - var cellToDate = t.cellToDate; - var cellToCellOffset = t.cellToCellOffset; - var cellOffsetToDayOffset = t.cellOffsetToDayOffset; - var dateToDayOffset = t.dateToDayOffset; - var dayOffsetToCellOffset = t.dayOffsetToCellOffset; - var calendar = t.calendar; - var getEventEnd = calendar.getEventEnd; - - - // Render `events` onto the calendar, attach mouse event handlers, and call the `eventAfterRender` callback for each. - // Mouse event will be lazily applied, except if the event has an ID of `modifiedEventId`. - // Can only be called when the event container is empty (because it wipes out all innerHTML). - function renderDayEvents(events, modifiedEventId) { - - // do the actual rendering. Receive the intermediate "segment" data structures. - var segments = _renderDayEvents( - events, - false, // don't append event elements - true // set the heights of the rows - ); - - // report the elements to the View, for general drag/resize utilities - segmentElementEach(segments, function(segment, element) { - reportEventElement(segment.event, element); - }); - - // attach mouse handlers - attachHandlers(segments, modifiedEventId); - - // call `eventAfterRender` callback for each event - segmentElementEach(segments, function(segment, element) { - trigger('eventAfterRender', segment.event, segment.event, element); - }); - } - - - // Render an event on the calendar, but don't report them anywhere, and don't attach mouse handlers. - // Append this event element to the event container, which might already be populated with events. - // If an event's segment will have row equal to `adjustRow`, then explicitly set its top coordinate to `adjustTop`. - // This hack is used to maintain continuity when user is manually resizing an event. - // Returns an array of DOM elements for the event. - function renderTempDayEvent(event, adjustRow, adjustTop) { - - // actually render the event. `true` for appending element to container. - // Recieve the intermediate "segment" data structures. - var segments = _renderDayEvents( - [ event ], - true, // append event elements - false // don't set the heights of the rows - ); - - var elements = []; - - // Adjust certain elements' top coordinates - segmentElementEach(segments, function(segment, element) { - if (segment.row === adjustRow) { - element.css('top', adjustTop); - } - elements.push(element[0]); // accumulate DOM nodes - }); - - return elements; - } - - - // Render events onto the calendar. Only responsible for the VISUAL aspect. - // Not responsible for attaching handlers or calling callbacks. - // Set `doAppend` to `true` for rendering elements without clearing the existing container. - // Set `doRowHeights` to allow setting the height of each row, to compensate for vertical event overflow. - function _renderDayEvents(events, doAppend, doRowHeights) { - - // where the DOM nodes will eventually end up - var finalContainer = getDaySegmentContainer(); - - // the container where the initial HTML will be rendered. - // If `doAppend`==true, uses a temporary container. - var renderContainer = doAppend ? $("
") : finalContainer; - - var segments = buildSegments(events); - var html; - var elements; - - // calculate the desired `left` and `width` properties on each segment object - calculateHorizontals(segments); - - // build the HTML string. relies on `left` property - html = buildHTML(segments); - - // render the HTML. innerHTML is considerably faster than jQuery's .html() - renderContainer[0].innerHTML = html; - - // retrieve the individual elements - elements = renderContainer.children(); - - // if we were appending, and thus using a temporary container, - // re-attach elements to the real container. - if (doAppend) { - finalContainer.append(elements); - } - - // assigns each element to `segment.event`, after filtering them through user callbacks - resolveElements(segments, elements); - - // Calculate the left and right padding+margin for each element. - // We need this for setting each element's desired outer width, because of the W3C box model. - // It's important we do this in a separate pass from acually setting the width on the DOM elements - // because alternating reading/writing dimensions causes reflow for every iteration. - segmentElementEach(segments, function(segment, element) { - segment.hsides = hsides(element, true); // include margins = `true` - }); - - // Set the width of each element - segmentElementEach(segments, function(segment, element) { - element.width( - Math.max(0, segment.outerWidth - segment.hsides) - ); - }); - - // Grab each element's outerHeight (setVerticals uses this). - // To get an accurate reading, it's important to have each element's width explicitly set already. - segmentElementEach(segments, function(segment, element) { - segment.outerHeight = element.outerHeight(true); // include margins = `true` - }); - - // Set the top coordinate on each element (requires segment.outerHeight) - setVerticals(segments, doRowHeights); - - return segments; - } - - - // Generate an array of "segments" for all events. - function buildSegments(events) { - var segments = []; - for (var i=0; i" + - "
"; - if (!event.allDay && segment.isStart) { - html += - "" + - htmlEscape(t.getEventTimeText(event)) + - ""; - } - html += - "" + - htmlEscape(event.title || '') + - "" + - "
"; - if (event.allDay && segment.isEnd && isEventResizable(event)) { - html += - "
" + - "   " + // makes hit area a lot better for IE6/7 - "
"; - } - html += ""; - - // TODO: - // When these elements are initially rendered, they will be briefly visibile on the screen, - // even though their widths/heights are not set. - // SOLUTION: initially set them as visibility:hidden ? - - return html; - } - - - // Associate each segment (an object) with an element (a jQuery object), - // by setting each `segment.element`. - // Run each element through the `eventRender` filter, which allows developers to - // modify an existing element, supply a new one, or cancel rendering. - function resolveElements(segments, elements) { - for (var i=0; i div'); - } - return rowDivs; - } - - - - /* Mouse Handlers - ---------------------------------------------------------------------------------------------------*/ - // TODO: better documentation! - - - function attachHandlers(segments, modifiedEventId) { - var segmentContainer = getDaySegmentContainer(); - - segmentElementEach(segments, function(segment, element, i) { - var event = segment.event; - if (event._id === modifiedEventId) { - bindDaySeg(event, element, segment); - }else{ - element[0]._fci = i; // for lazySegBind - } - }); - - lazySegBind(segmentContainer, segments, bindDaySeg); - } - - - function bindDaySeg(event, eventElement, segment) { - - if (isEventDraggable(event)) { - t.draggableDayEvent(event, eventElement, segment); // use `t` so subclasses can override - } - - if ( - event.allDay && - segment.isEnd && // only allow resizing on the final segment for an event - isEventResizable(event) - ) { - t.resizableDayEvent(event, eventElement, segment); // use `t` so subclasses can override - } - - // attach all other handlers. - // needs to be after, because resizableDayEvent might stopImmediatePropagation on click - eventElementHandlers(event, eventElement); - } - - - function draggableDayEvent(event, eventElement) { - var hoverListener = getHoverListener(); - var dayDelta; - var eventStart; - eventElement.draggable({ - delay: 50, - opacity: opt('dragOpacity'), - revertDuration: opt('dragRevertDuration'), - start: function(ev, ui) { - trigger('eventDragStart', eventElement[0], event, ev, ui); - hideEvents(event, eventElement); - hoverListener.start(function(cell, origCell, rowDelta, colDelta) { - eventElement.draggable('option', 'revert', !cell || !rowDelta && !colDelta); - clearOverlays(); - if (cell) { - var origCellDate = cellToDate(origCell); - var cellDate = cellToDate(cell); - dayDelta = cellDate.diff(origCellDate, 'days'); - eventStart = event.start.clone().add('days', dayDelta); - renderDayOverlay( - eventStart, - getEventEnd(event).add('days', dayDelta) - ); - } - else { - dayDelta = 0; - } - }, ev, 'drag'); - }, - stop: function(ev, ui) { - hoverListener.stop(); - clearOverlays(); - trigger('eventDragStop', eventElement[0], event, ev, ui); - if (dayDelta) { - eventDrop( - eventElement[0], - event, - eventStart, - ev, - ui - ); - } - else { - eventElement.css('filter', ''); // clear IE opacity side-effects - showEvents(event, eventElement); - } - } - }); - } - - - function resizableDayEvent(event, element, segment) { - var isRTL = opt('isRTL'); - var direction = isRTL ? 'w' : 'e'; - var handle = element.find('.ui-resizable-' + direction); // TODO: stop using this class because we aren't using jqui for this - var isResizing = false; - - // TODO: look into using jquery-ui mouse widget for this stuff - disableTextSelection(element); // prevent native selection for IE - element - .mousedown(function(ev) { // prevent native selection for others - ev.preventDefault(); - }) - .click(function(ev) { - if (isResizing) { - ev.preventDefault(); // prevent link from being visited (only method that worked in IE6) - ev.stopImmediatePropagation(); // prevent fullcalendar eventClick handler from being called - // (eventElementHandlers needs to be bound after resizableDayEvent) - } - }); - - handle.mousedown(function(ev) { - if (ev.which != 1) { - return; // needs to be left mouse button - } - isResizing = true; - var hoverListener = getHoverListener(); - var elementTop = element.css('top'); - var dayDelta; - var eventEnd; - var helpers; - var eventCopy = $.extend({}, event); - var minCellOffset = dayOffsetToCellOffset(dateToDayOffset(event.start)); - clearSelection(); - $('body') - .css('cursor', direction + '-resize') - .one('mouseup', mouseup); - trigger('eventResizeStart', element[0], event, ev, {}); // {} is dummy jqui event - hoverListener.start(function(cell, origCell) { - if (cell) { - - var origCellOffset = cellToCellOffset(origCell); - var cellOffset = cellToCellOffset(cell); - - // don't let resizing move earlier than start date cell - cellOffset = Math.max(cellOffset, minCellOffset); - - dayDelta = - cellOffsetToDayOffset(cellOffset) - - cellOffsetToDayOffset(origCellOffset); - - eventEnd = getEventEnd(event).add('days', dayDelta); // assumed to already have a stripped time - - if (dayDelta) { - eventCopy.end = eventEnd; - var oldHelpers = helpers; - helpers = renderTempDayEvent(eventCopy, segment.row, elementTop); - helpers = $(helpers); // turn array into a jQuery object - helpers.find('*').css('cursor', direction + '-resize'); - if (oldHelpers) { - oldHelpers.remove(); - } - hideEvents(event); - } - else { - if (helpers) { - showEvents(event); - helpers.remove(); - helpers = null; - } - } - - clearOverlays(); - renderDayOverlay( // coordinate grid already rebuilt with hoverListener.start() - event.start, - eventEnd - // TODO: instead of calling renderDayOverlay() with dates, - // call _renderDayOverlay (or whatever) with cell offsets. - ); - } - }, ev); - - function mouseup(ev) { - trigger('eventResizeStop', element[0], event, ev, {}); // {} is dummy jqui event - $('body').css('cursor', ''); - hoverListener.stop(); - clearOverlays(); - - if (dayDelta) { - eventResize( - element[0], - event, - eventEnd, - ev, - {} // dummy jqui event - ); - // event redraw will clear helpers - } - // otherwise, the drag handler already restored the old events - - setTimeout(function() { // make this happen after the element's click event - isResizing = false; - },0); - } - }); - } - - -} - - - -/* Generalized Segment Utilities --------------------------------------------------------------------------------------------------*/ - - -function isDaySegmentCollision(segment, otherSegments) { - for (var i=0; i= segment.leftCol - ) { - return true; - } - } - return false; -} - - -function segmentElementEach(segments, callback) { // TODO: use in AgendaView? - for (var i=0; i elements, one for each row, with events inside. Attached to the content skeletons. + eventTbodyEls: null, + + + // Render the given events onto the Grid and return the rendered segments + renderEvents: function(events) { + var res = this.renderEventRows(events); + var tbodyEls = this.eventTbodyEls = res.tbodyEls; + + // append to each row's content skeleton + this.rowEls.each(function(i, rowNode) { + $(rowNode).find('.fc-content-skeleton > table').append(tbodyEls[i]); + }); + + return res.segs; // return segment objects. for the view + }, + + + // Removes all rendered event elements + destroyEvents: function() { + if (this.eventTbodyEls) { + this.eventTbodyEls.remove(); + this.eventTbodyEls = null; + } + }, + + + // Uses the given events array to generate elements that should be appended to each row's content skeleton. + // Returns an object with properties 'tbodyEls' and 'segs' (which contains all the rendered segment objects). + renderEventRows: function(events) { + var view = this.view; + var allSegs = this.eventsToSegs(events); + var segRows = this.groupSegRows(allSegs); // group into nested arrays + var html = ''; + var tbodyNodes = []; + var i; + var row; + + // build a large concatenation of event segment HTML + for (i = 0; i < allSegs.length; i++) { + html += this.renderSegHtml(allSegs[i]); + } + + // Grab individual elements from the combined HTML string. Use each as the default rendering. + // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false. + $(html).each(function(i, node) { + allSegs[i].el = view.resolveEventEl(allSegs[i].event, $(node)); + }); + + // iterate each row of segment groupings + for (row = 0; row < segRows.length; row++) { + segRows[row] = $.grep(segRows[row], renderedSegFilter); // filter out non-rendered segments. reassign array + tbodyNodes.push( + this.renderSegSkeleton(segRows[row])[0] + ); + } + + return { + tbodyEls: $(tbodyNodes), // array -> jQuery set + segs: flattenArray(segRows) // flatten all rendered segments into one array + }; + }, + + + // Builds the HTML to be used for the default element for an individual segment + renderSegHtml: function(seg) { + var view = this.view; + var isRTL = view.opt('isRTL'); + var event = seg.event; + var isDraggable = view.isEventDraggable(event); + var isResizable = event.allDay && seg.isEnd && view.isEventResizable(event); // only on endings of timed events + var classes = this.getSegClasses(seg, isDraggable, isResizable); + var skinCss = this.getEventSkinCss(event); + var timeHtml = ''; + var titleHtml; + + classes.unshift('fc-day-grid-event'); + + // Only display a timed events time if it is the starting segment + if (!event.allDay && seg.isStart) { + timeHtml = '' + htmlEscape(view.getEventTimeText(event)) + ''; + } + + titleHtml = + '' + + (htmlEscape(event.title || '') || ' ') + // we always want one line of height + ''; + + return '' + + '
' + + (isRTL ? + titleHtml + ' ' + timeHtml : // put a natural space in between + timeHtml + ' ' + titleHtml // + ) + + '
' + + (isResizable ? + '
' : + '' + ) + + ''; + }, + + + // Given an array of segments all in the same row, render a element, a skeleton that contains the segments + renderSegSkeleton: function(rowSegs) { + var view = this.view; + var colCnt = view.colCnt; + var levels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels + var tbody = $(''); + var emptyTds = []; // a sparse array of references to the current row's empty cells, indexed by column + var aboveEmptyTds; // like emptyTds, but for the level above + var i, levelSegs; + var col; + var tr; + var j, seg; + var td; + + // populates empty cells from the current column (`col`) to `endCol` + function emptyCellsUntil(endCol) { + while (col < endCol) { + // try to grab an empty cell from the level above and extend its rowspan. otherwise, create a fresh cell + td = aboveEmptyTds[col]; + if (td) { + td.attr( + 'rowspan', + parseInt(td.attr('rowspan') || 1, 10) + 1 + ); + } + else { + td = $(''); + tr.append(td); + } + emptyTds[col] = td; + col++; + } + } + + // Iterate through all levels, and then beyond one. Do this so we have an empty row at the end. + // This empty row comes in handy when styling the height of the content skeleton. + for (i = 0; i < levels.length + 1; i++) { + levelSegs = levels[i]; + col = 0; + tr = $(''); + + aboveEmptyTds = emptyTds; + emptyTds = []; + + if (levelSegs) { // protect against non-existent last level + for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level + seg = levelSegs[j]; + + emptyCellsUntil(seg.leftCol); + + // create a container that occupies or more columns. append the event element. + td = $('').append(seg.el); + if (seg.rightCol > seg.leftCol) { + td.attr('colspan', seg.rightCol - seg.leftCol + 1); + } + + tr.append(td); + col = seg.rightCol + 1; + } + } + + emptyCellsUntil(colCnt); // finish off the row + + this.bookendCells(tr, 'eventSkeleton'); + tbody.append(tr); + } + + return tbody; + }, + + + // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels. + buildSegLevels: function(segs) { + var levels = []; + var i, seg; + var j; + + // Give preference to elements with certain criteria, so they have + // a chance to be closer to the top. + segs.sort(compareSegs); + + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + + // loop through levels, starting with the topmost, until the segment doesn't collide with other segments + for (j = 0; j < levels.length; j++) { + if (!isDaySegCollision(seg, levels[j])) { + break; + } + } + // `j` now holds the desired subrow index + seg.level = j; + + // create new level array if needed and append segment + (levels[j] || (levels[j] = [])).push(seg); + } + + // order segments left-to-right. very important if calendar is RTL + for (j = 0; j < levels.length; j++) { + levels[j].sort(compareDaySegCols); + } + + return levels; + }, + + + // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row + groupSegRows: function(segs) { + var view = this.view; + var segRows = []; + var i; + + for (i = 0; i < view.rowCnt; i++) { + segRows.push([]); + } + + for (i = 0; i < segs.length; i++) { + segRows[segs[i].row].push(segs[i]); + } + + return segRows; + } + +}); + + +// Computes whether two segments' columns collide. They are assumed to be in the same row. +function isDaySegCollision(seg, otherSegs) { + var i, otherSeg; + + for (i = 0; i < otherSegs.length; i++) { + otherSeg = otherSegs[i]; + + if ( + otherSeg.leftCol <= seg.rightCol && + otherSeg.rightCol >= seg.leftCol + ) { + return true; + } + } + + return false; +} + + +// A cmp function for determining the leftmost event +function compareDaySegCols(a, b) { + return a.leftCol - b.leftCol; +} diff --git a/src/common/DayGrid.js b/src/common/DayGrid.js new file mode 100644 index 0000000..3840cd2 --- /dev/null +++ b/src/common/DayGrid.js @@ -0,0 +1,298 @@ + +/* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week. +----------------------------------------------------------------------------------------------------------------------*/ + +function DayGrid(view) { + Grid.call(this, view); // call the super-constructor +} + + +DayGrid.prototype = createObject(Grid.prototype); // declare the super-class +$.extend(DayGrid.prototype, { + + numbersVisible: false, // should render a row for day/week numbers? manually set by the view + cellDuration: moment.duration({ days: 1 }), // required for Grid.event.js. Each cell is always a single day + bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid + + rowEls: null, // set of fake row elements + dayEls: null, // set of whole-day elements comprising the row's background + helperEls: null, // set of cell skeleton elements for rendering the mock event "helper" + highlightEls: null, // set of cell skeleton elements for rendering the highlight + + + // Renders the rows and columns into the component's `this.el`, which should already be assigned. + // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient. + render: function() { + var view = this.view; + var html = ''; + var row; + + for (row = 0; row < view.rowCnt; row++) { + html += this.dayRowHtml(row); + } + this.el.html(html); + + this.rowEls = this.el.find('.fc-row'); + this.dayEls = this.el.find('.fc-day'); + + // run all the day cells through the dayRender callback + this.dayEls.each(function(i, node) { + var date = view.cellToDate(Math.floor(i / view.colCnt), i % view.colCnt); + view.trigger('dayRender', null, date, $(node)); + }); + + Grid.prototype.render.call(this); // call the super-method + }, + + + // Generates the HTML for a single row. `row` is the row number. + dayRowHtml: function(row) { + var view = this.view; + var classes = [ 'fc-row', 'fc-week' ]; + + if (view.dayRowThemeClass) { // provides the view a hook to inject a theme className + classes.push(view.dayRowThemeClass); + } + + return '' + + '
' + + '
' + + '' + + this.rowHtml('day', row) + // leverages RowRenderer. calls dayCellHtml() + '
' + + '
' + + '
' + + '' + + (this.numbersVisible ? + '' + + this.rowHtml('number', row) + // leverages RowRenderer. View will define render method + '' : + '' + ) + + '
' + + '
' + + '
'; + }, + + + // Renders the HTML for a whole-day cell. Will eventually end up in the day-row's background. + // We go through a 'day' row type instead of just doing a 'bg' row type so that the View can do custom rendering + // specifically for whole-day rows, whereas a 'bg' might also be used for other purposes (TimeGrid bg for example). + dayCellHtml: function(row, col, date) { + return this.bgCellHtml(row, col, date); + }, + + + /* Coordinates & Cells + ------------------------------------------------------------------------------------------------------------------*/ + + + // Populates the empty `rows` and `cols` arrays with coordinates of the cells. For CoordGrid. + buildCoords: function(rows, cols) { + var colCnt = this.view.colCnt; + var e, n, p; + + this.dayEls.slice(0, colCnt).each(function(i, _e) { // iterate the first row of day elements + e = $(_e); + n = e.offset().left; + if (i) { + p[1] = n; + } + p = [ n ]; + cols[i] = p; + }); + p[1] = n + e.outerWidth(); + + this.rowEls.each(function(i, _e) { + e = $(_e); + n = e.offset().top; + if (i) { + p[1] = n; + } + p = [ n ]; + rows[i] = p; + }); + p[1] = n + e.outerHeight() + this.bottomCoordPadding; // hack to extend hit area of last row + }, + + + // Converts a cell to a date + getCellDate: function(cell) { + return this.view.cellToDate(cell); // leverages the View's cell system + }, + + + // Gets the whole-day element associated with the cell + getCellDayEl: function(cell) { + return this.dayEls.eq(cell.row * this.view.colCnt + cell.col); + }, + + + // Converts a range with an inclusive `start` and an exclusive `end` into an array of segment objects + rangeToSegs: function(start, end) { + return this.view.rangeToSegments(start, end); // leverages the View's cell system + }, + + + /* Event Drag Visualization + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of an event hovering over the given date(s). + // `end` can be null, as well as `seg`. See View's documentation on renderDrag for more info. + // A returned value of `true` signals that a mock "helper" event has been rendered. + renderDrag: function(start, end, seg) { + var opacity; + + // always render a highlight underneath + this.renderHighlight( + start, + end || this.view.calendar.getDefaultEventEnd(true, start) + ); + + // if a segment from the same calendar but another component is being dragged, render a helper event + if (seg && !seg.el.closest(this.el).length) { + + this.renderRangeHelper(start, end, seg); + + opacity = this.view.opt('dragOpacity'); + if (opacity !== undefined) { + this.helperEls.css('opacity', opacity); + } + + return true; // a helper has been rendered + } + }, + + + // Unrenders any visual indication of a hovering event + destroyDrag: function() { + this.destroyHighlight(); + this.destroyHelper(); + }, + + + /* Event Resize Visualization + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of an event being resized + renderResize: function(start, end, seg) { + this.renderHighlight(start, end); + this.renderRangeHelper(start, end, seg); + }, + + + // Unrenders a visual indication of an event being resized + destroyResize: function() { + this.destroyHighlight(); + this.destroyHelper(); + }, + + + /* Event Helper + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null. + renderHelper: function(event, sourceSeg) { + var helperNodes = []; + var tbodyEls = this.renderEventRows([ event ]).tbodyEls; // render events as usual, receiving tbodys to inject + + // inject each new event skeleton into each associated row + this.rowEls.each(function(row, rowNode) { + var rowEl = $(rowNode); // the .fc-row + var skeletonEl = $('
'); // will be absolutely positioned + var skeletonTop; + + // If there is an original segment, match the top position. Otherwise, put it at the row's top level + if (sourceSeg && sourceSeg.row === row) { + skeletonTop = sourceSeg.el.position().top; + } + else { + skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top; + } + + skeletonEl.css('top', skeletonTop) + .find('table') + .append(tbodyEls[row]); + + rowEl.append(skeletonEl); + helperNodes.push(skeletonEl[0]); + }); + + this.helperEls = $(helperNodes); // array -> jQuery set + }, + + + // Unrenders any visual indication of a mock helper event + destroyHelper: function() { + if (this.helperEls) { + this.helperEls.remove(); + this.helperEls = null; + } + }, + + + /* Highlighting + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders an emphasis on the given date range. `start` is an inclusive, `end` is exclusive. + renderHighlight: function(start, end) { + var segs = this.rangeToSegs(start, end); + var highlightNodes = []; + var i, seg; + var el; + + // build an event skeleton for each row that needs it + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + el = $( + this.highlightSkeletonHtml(seg.leftCol, seg.rightCol + 1) // make end exclusive + ); + el.appendTo(this.rowEls[seg.row]); + highlightNodes.push(el[0]); + } + + this.highlightEls = $(highlightNodes); // array -> jQuery set + }, + + + // Unrenders any visual emphasis on a date range + destroyHighlight: function() { + if (this.highlightEls) { + this.highlightEls.remove(); + this.highlightEls = null; + } + }, + + + // Generates the HTML used to build a single-row "highlight skeleton", a table that frames highlight cells + highlightSkeletonHtml: function(startCol, endCol) { + var colCnt = this.view.colCnt; + var cellHtml = ''; + + if (startCol > 0) { + cellHtml += ''; + }, + + + // Renders the HTML for a single-day background cell + bgCellHtml: function(row, col, date) { + var view = this.view; + var classes = this.getDayClasses(date); + + classes.unshift('fc-day', view.widgetContentClass); + + return ''; + }, + + + // Computes HTML classNames for a single-day cell + getDayClasses: function(date) { + var view = this.view; + var today = view.calendar.getNow().stripTime(); + var classes = [ 'fc-' + dayIDs[date.day()] ]; + + if ( + view.name === 'month' && + date.month() != view.intervalStart.month() + ) { + classes.push('fc-other-month'); + } + + if (date.isSame(today, 'day')) { + classes.push( + 'fc-today', + view.highlightStateClass + ); + } + else if (date < today) { + classes.push('fc-past'); + } + else { + classes.push('fc-future'); + } + + return classes; + } + +}); diff --git a/src/common/HorizontalPositionCache.js b/src/common/HorizontalPositionCache.js deleted file mode 100644 index 58530cc..0000000 --- a/src/common/HorizontalPositionCache.js +++ /dev/null @@ -1,27 +0,0 @@ - -function HorizontalPositionCache(getElement) { - - var t = this, - elements = {}, - lefts = {}, - rights = {}; - - function e(i) { - return (elements[i] = (elements[i] || getElement(i))); - } - - t.left = function(i) { - return (lefts[i] = (lefts[i] === undefined ? e(i).position().left : lefts[i])); - }; - - t.right = function(i) { - return (rights[i] = (rights[i] === undefined ? t.left(i) + e(i).width() : rights[i])); - }; - - t.clear = function() { - elements = {}; - lefts = {}; - rights = {}; - }; - -} diff --git a/src/common/HoverListener.js b/src/common/HoverListener.js deleted file mode 100644 index 76dea75..0000000 --- a/src/common/HoverListener.js +++ /dev/null @@ -1,62 +0,0 @@ - -function HoverListener(coordinateGrid) { - - - var t = this; - var bindType; - var change; - var firstCell; - var cell; - - - t.start = function(_change, ev, _bindType) { - change = _change; - firstCell = cell = null; - coordinateGrid.build(); - mouse(ev); - bindType = _bindType || 'mousemove'; - $(document).bind(bindType, mouse); - }; - - - function mouse(ev) { - _fixUIEvent(ev); // see below - var newCell = coordinateGrid.cell(ev.pageX, ev.pageY); - if ( - Boolean(newCell) !== Boolean(cell) || - newCell && (newCell.row != cell.row || newCell.col != cell.col) - ) { - if (newCell) { - if (!firstCell) { - firstCell = newCell; - } - change(newCell, firstCell, newCell.row-firstCell.row, newCell.col-firstCell.col); - }else{ - change(newCell, firstCell); - } - cell = newCell; - } - } - - - t.stop = function() { - $(document).unbind(bindType, mouse); - return cell; - }; - - -} - - - -// this fix was only necessary for jQuery UI 1.8.16 (and jQuery 1.7 or 1.7.1) -// upgrading to jQuery UI 1.8.17 (and using either jQuery 1.7 or 1.7.1) fixed the problem -// but keep this in here for 1.8.16 users -// and maybe remove it down the line - -function _fixUIEvent(event) { // for issue 1168 - if (event.pageX === undefined) { - event.pageX = event.originalEvent.pageX; - event.pageY = event.originalEvent.pageY; - } -} \ No newline at end of file diff --git a/src/common/MouseFollower.js b/src/common/MouseFollower.js new file mode 100644 index 0000000..651b8bb --- /dev/null +++ b/src/common/MouseFollower.js @@ -0,0 +1,184 @@ + +/* Creates a clone of an element and lets it track the mouse as it moves +----------------------------------------------------------------------------------------------------------------------*/ + +function MouseFollower(sourceEl, options) { + this.options = options = options || {}; + this.sourceEl = sourceEl; + this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent +} + + +MouseFollower.prototype = { + + options: null, + + sourceEl: null, // the element that will be cloned and made to look like it is dragging + el: null, // the clone of `sourceEl` that will track the mouse + parentEl: null, // the element that `el` (the clone) will be attached to + + // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl + top0: null, + left0: null, + + // the initial position of the mouse + mouseY0: null, + mouseX0: null, + + // the number of pixels the mouse has moved from its initial position + topDelta: null, + leftDelta: null, + + mousemoveProxy: null, // document mousemove handler, bound to the MouseFollower's `this` + + isFollowing: false, + isHidden: false, + isAnimating: false, // doing the revert animation? + + + // Causes the element to start following the mouse + start: function(ev) { + if (!this.isFollowing) { + this.isFollowing = true; + + this.mouseY0 = ev.pageY; + this.mouseX0 = ev.pageX; + this.topDelta = 0; + this.leftDelta = 0; + + if (!this.isHidden) { + this.updatePosition(); + } + + $(document).on('mousemove', this.mousemoveProxy = $.proxy(this, 'mousemove')); + } + }, + + + // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position. + // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately. + stop: function(shouldRevert, callback) { + var _this = this; + var revertDuration = this.options.revertDuration; + + function complete() { + this.isAnimating = false; + _this.destroyEl(); + + this.top0 = this.left0 = null; // reset state for future updatePosition calls + + if (callback) { + callback(); + } + } + + if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time + this.isFollowing = false; + + $(document).off('mousemove', this.mousemoveProxy); + + if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation? + this.isAnimating = true; + this.el.animate({ + top: this.top0, + left: this.left0 + }, { + duration: revertDuration, + complete: complete + }); + } + else { + complete(); + } + } + }, + + + // Gets the tracking element. Create it if necessary + getEl: function() { + var el = this.el; + + if (!el) { + el = this.el = this.sourceEl.clone() + .css({ + position: 'absolute', + visibility: '', // in case original element was hidden (commonly through hideEvents()) + display: this.isHidden ? 'none' : '', // for when initially hidden + margin: 0, + right: 'auto', // erase and set width instead + bottom: 'auto', // erase and set height instead + width: this.sourceEl.width(), // explicit height in case there was a 'right' value + height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value + opacity: this.options.opacity || '', + zIndex: this.options.zIndex + }) + .appendTo(this.parentEl); + } + + return el; + }, + + + // Removes the tracking element if it has already been created + destroyEl: function() { + if (this.el) { + this.el.remove(); + this.el = null; + } + }, + + + // Update the CSS position of the tracking element + updatePosition: function() { + var sourceOffset; + var origin; + + this.getEl(); // ensure this.el + + // make sure origin info was computed + if (this.top0 === null) { + sourceOffset = this.sourceEl.offset(); + origin = this.el.offsetParent().offset(); + this.top0 = sourceOffset.top - origin.top; + this.left0 = sourceOffset.left - origin.left; + } + + this.el.css({ + top: this.top0 + this.topDelta, + left: this.left0 + this.leftDelta + }); + }, + + + // Gets called when the user moves the mouse + mousemove: function(ev) { + this.topDelta = ev.pageY - this.mouseY0; + this.leftDelta = ev.pageX - this.mouseX0; + + if (!this.isHidden) { + this.updatePosition(); + } + }, + + + // Temporarily makes the tracking element invisible. Can be called before following starts + hide: function() { + if (!this.isHidden) { + this.isHidden = true; + if (this.el) { + this.el.hide(); + } + } + }, + + + // Show the tracking element after it has been temporarily hidden + show: function() { + if (this.isHidden) { + this.isHidden = false; + this.updatePosition(); + this.getEl().show(); + } + } + +}; diff --git a/src/common/OverlayManager.js b/src/common/OverlayManager.js deleted file mode 100644 index 9b7cfff..0000000 --- a/src/common/OverlayManager.js +++ /dev/null @@ -1,37 +0,0 @@ - -function OverlayManager() { - var t = this; - - - // exports - t.renderOverlay = renderOverlay; - t.clearOverlays = clearOverlays; - - - // locals - var usedOverlays = []; - var unusedOverlays = []; - - - function renderOverlay(rect, parent) { - var e = unusedOverlays.shift(); - if (!e) { - e = $("
"); - } - if (e[0].parentNode != parent[0]) { - e.appendTo(parent); - } - usedOverlays.push(e.css(rect).show()); - return e; - } - - - function clearOverlays() { - var e; - while ((e = usedOverlays.shift())) { - unusedOverlays.push(e.hide().unbind()); - } - } - - -} diff --git a/src/common/RowRenderer.js b/src/common/RowRenderer.js new file mode 100644 index 0000000..1d2c285 --- /dev/null +++ b/src/common/RowRenderer.js @@ -0,0 +1,103 @@ + +/* A utility class for rendering
rows. +----------------------------------------------------------------------------------------------------------------------*/ +// It leverages methods of the subclass and the View to determine custom rendering behavior for each row "type" +// (such as highlight rows, day rows, helper rows, etc). + +function RowRenderer(view) { + this.view = view; +} + + +RowRenderer.prototype = { + + view: null, // a View object + cellHtml: '' + cellHtml + ''; + }, + + + // Applies the "intro" and "outro" HTML to the given cells. + // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro. + // `cells` can be an HTML string of element + // `row` is an optional row number. + bookendCells: function(cells, rowType, row) { + var view = this.view; + var intro = this.getHtmlRenderer('intro', rowType)(row || 0); + var outro = this.getHtmlRenderer('outro', rowType)(row || 0); + var isRTL = view.opt('isRTL'); + var prependHtml = isRTL ? outro : intro; + var appendHtml = isRTL ? intro : outro; + + if (typeof cells === 'string') { + return prependHtml + cells + appendHtml; + } + else { // a jQuery element + return cells.prepend(prependHtml).append(appendHtml); + } + }, + + + // Returns an HTML-rendering function given a specific `rendererName` (like cell, intro, or outro) and a specific + // `rowType` (like day, eventSkeleton, helperSkeleton), which is optional. + // If a renderer for the specific rowType doesn't exist, it will fall back to a generic renderer. + // We will query the View object first for any custom rendering functions, then the methods of the subclass. + getHtmlRenderer: function(rendererName, rowType) { + var view = this.view; + var generalName; // like "cellHtml" + var specificName; // like "dayCellHtml". based on rowType + var provider; // either the View or the RowRenderer subclass, whichever provided the method + var renderer; + + generalName = rendererName + 'Html'; + if (rowType) { + specificName = rowType + capitaliseFirstLetter(rendererName) + 'Html'; + } + + if (specificName && (renderer = view[specificName])) { + provider = view; + } + else if (specificName && (renderer = this[specificName])) { + provider = this; + } + else if ((renderer = view[generalName])) { + provider = view; + } + else if ((renderer = this[generalName])) { + provider = this; + } + + if (typeof renderer === 'function') { + return function(row) { + return renderer.apply(provider, arguments) || ''; // use correct `this` and always return a string + }; + } + + // the rendered can be a plain string as well. if not specified, always an empty string. + return function() { + return renderer || ''; + }; + } + +}; diff --git a/src/common/SelectionManager.js b/src/common/SelectionManager.js deleted file mode 100644 index 7b613ad..0000000 --- a/src/common/SelectionManager.js +++ /dev/null @@ -1,122 +0,0 @@ - -//BUG: unselect needs to be triggered when events are dragged+dropped - -function SelectionManager() { - var t = this; - - - // exports - t.select = select; - t.unselect = unselect; - t.reportSelection = reportSelection; - t.daySelectionMousedown = daySelectionMousedown; - t.selectionManagerDestroy = destroy; - - - // imports - var calendar = t.calendar; - var opt = t.opt; - var trigger = t.trigger; - var defaultSelectionEnd = t.defaultSelectionEnd; - var renderSelection = t.renderSelection; - var clearSelection = t.clearSelection; - - - // locals - var selected = false; - - - - // unselectAuto - if (opt('selectable') && opt('unselectAuto')) { - $(document).on('mousedown', documentMousedown); - } - - - function documentMousedown(ev) { - var ignore = opt('unselectCancel'); - if (ignore) { - if ($(ev.target).parents(ignore).length) { // could be optimized to stop after first match - return; - } - } - unselect(ev); - } - - - function select(start, end) { - unselect(); - - start = calendar.moment(start); - if (end) { - end = calendar.moment(end); - } - else { - end = defaultSelectionEnd(start); - } - - renderSelection(start, end); - reportSelection(start, end); - } - // TODO: better date normalization. see notes in automated test - - - function unselect(ev) { - if (selected) { - selected = false; - clearSelection(); - trigger('unselect', null, ev); - } - } - - - function reportSelection(start, end, ev) { - selected = true; - trigger('select', null, start, end, ev); - } - - - function daySelectionMousedown(ev) { // not really a generic manager method, oh well - var cellToDate = t.cellToDate; - var getIsCellAllDay = t.getIsCellAllDay; - var hoverListener = t.getHoverListener(); - var reportDayClick = t.reportDayClick; // this is hacky and sort of weird - - if (ev.which == 1 && opt('selectable')) { // which==1 means left mouse button - unselect(ev); - var dates; - hoverListener.start(function(cell, origCell) { // TODO: maybe put cellToDate/getIsCellAllDay info in cell - clearSelection(); - if (cell && getIsCellAllDay(cell)) { - dates = [ cellToDate(origCell), cellToDate(cell) ].sort(dateCompare); - renderSelection( - dates[0], - dates[1].clone().add('days', 1) // make exclusive - ); - }else{ - dates = null; - } - }, ev); - $(document).one('mouseup', function(ev) { - hoverListener.stop(); - if (dates) { - if (+dates[0] == +dates[1]) { - reportDayClick(dates[0], ev); - } - reportSelection( - dates[0], - dates[1].clone().add('days', 1), // make exclusive - ev - ); - } - }); - } - } - - - function destroy() { - $(document).off('mousedown', documentMousedown); - } - - -} diff --git a/src/common/TimeGrid.events.js b/src/common/TimeGrid.events.js new file mode 100644 index 0000000..85c2e21 --- /dev/null +++ b/src/common/TimeGrid.events.js @@ -0,0 +1,375 @@ + +/* Event-rendering methods for the TimeGrid class +----------------------------------------------------------------------------------------------------------------------*/ + +$.extend(TimeGrid.prototype, { + + eventSkeletonEl: null, // has cells with event-containers, which contain absolutely positioned event elements + + + // Renders the events onto the grid and returns an array of segments that have been rendered + renderEvents: function(events) { + var res = this.renderEventTable(events); + + this.eventSkeletonEl = $('
').append(res.tableEl); + this.el.append(this.eventSkeletonEl); + + return res.segs; // return segment objects. for the view + }, + + + // Removes all event segment elements from the view + destroyEvents: function() { + if (this.eventSkeletonEl) { + this.eventSkeletonEl.remove(); + this.eventSkeletonEl = null; + } + }, + + + // Renders and returns the
'; + } + if (endCol > startCol) { + cellHtml += ''; + } + if (colCnt > endCol) { + cellHtml += ''; + } + + cellHtml = this.bookendCells(cellHtml, 'highlight'); + + return '' + + '
' + + '' + + '' + + cellHtml + + '' + + '
' + + '
'; + } + +}); diff --git a/src/common/DragListener.js b/src/common/DragListener.js new file mode 100644 index 0000000..628eb92 --- /dev/null +++ b/src/common/DragListener.js @@ -0,0 +1,238 @@ + +/* Tracks mouse movements over a CoordMap and raises events about which cell the mouse is over. +----------------------------------------------------------------------------------------------------------------------*/ +// TODO: implement scrolling + +function DragListener(coordMap, options) { + this.coordMap = coordMap; + this.options = options || {}; +} + + +DragListener.prototype = { + + coordMap: null, + options: null, + + isListening: false, + isDragging: false, + + // the cell/date the mouse was over when listening started + origCell: null, + origDate: null, + + // the cell/date the mouse is over + cell: null, + date: null, + + // coordinates of the initial mousedown + mouseX0: null, + mouseY0: null, + + // handler attached to the document, bound to the DragListener's `this` + mousemoveProxy: null, + mouseupProxy: null, + + + // Call this when the user does a mousedown. Will probably lead to startListening + mousedown: function(ev) { + if (isPrimaryMouseButton(ev)) { + + ev.preventDefault(); // prevents native selection in most browsers + + this.startListening(ev); + + // start the drag immediately if there is no minimum distance for a drag start + if (!this.options.distance) { + this.startDrag(ev); + } + } + }, + + + // Call this to start tracking mouse movements + startListening: function(ev) { + var cell; + + if (!this.isListening) { + + this.coordMap.build(); // build coordinates of the cells + + // get info on the initial cell, date, and coordinates + if (ev) { + cell = this.getCell(ev); + this.origCell = cell; + this.origDate = cell ? cell.date : null; + + this.mouseX0 = ev.pageX; + this.mouseY0 = ev.pageY; + } + + $(document) + .on('mousemove', this.mousemoveProxy = $.proxy(this, 'mousemove')) + .on('mouseup', this.mouseupProxy = $.proxy(this, 'mouseup')) + .on('selectstart', this.preventDefault); // prevents native selection in IE<=8 + + this.isListening = true; + this.trigger('listenStart', ev); + } + }, + + + // Called when the user moves the mouse + mousemove: function(ev) { + var minDistance; + var distanceSq; // current distance from mouseX0/mouseY0, squared + + if (!this.isDragging) { // if not already dragging... + // then start the drag if the minimum distance criteria is met + minDistance = this.options.distance || 1; + distanceSq = Math.pow(ev.pageX - this.mouseX0, 2) + Math.pow(ev.pageY - this.mouseY0, 2); + if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem + this.startDrag(ev); + } + } + + if (this.isDragging) { + this.drag(ev); // report a drag, even if this mousemove initiated the drag + } + }, + + + // Call this to initiate a legitimate drag. + // This function is called internally from this class, but can also be called explicitly from outside + startDrag: function(ev) { + var cell; + + if (!this.isListening) { // startDrag must have manually initiated + this.startListening(); + } + + if (!this.isDragging) { + this.isDragging = true; + this.trigger('dragStart', ev); + + // report the initial cell the mouse is over + cell = this.getCell(ev); + if (cell) { + this.cellOver(cell, true); + } + } + }, + + + // Called while the mouse is being moved and when we know a legitimate drag is taking place + drag: function(ev) { + var cell; + + if (this.isDragging) { + cell = this.getCell(ev); + + if (!isCellsEqual(cell, this.cell)) { // a different cell than before? + if (this.cell) { + this.cellOut(); + } + if (cell) { + this.cellOver(cell); + } + } + } + }, + + + // Called when a the mouse has just moved over a new cell + cellOver: function(cell) { + this.cell = cell; + this.date = cell.date; + this.trigger('cellOver', cell, cell.date); + }, + + + // Called when the mouse has just moved out of a cell + cellOut: function() { + if (this.cell) { + this.trigger('cellOut', this.cell); + this.cell = null; + this.date = null; + } + }, + + + // Called when the user does a mouseup + mouseup: function(ev) { + this.stopDrag(ev); + this.stopListening(ev); + }, + + + // Called when the drag is over. Will not cause listening to stop however. + // A concluding 'cellOut' event will NOT be triggered. + stopDrag: function(ev) { + if (this.isDragging) { + this.trigger('dragStop', ev); + this.isDragging = false; + } + }, + + + // Call this to stop listening to the user's mouse events + stopListening: function(ev) { + if (this.isListening) { + + $(document) + .off('mousemove', this.mousemoveProxy) + .off('mouseup', this.mouseupProxy) + .off('selectstart', this.preventDefault); + + this.mousemoveProxy = null; + this.mouseupProxy = null; + + this.isListening = false; + this.trigger('listenStop', ev); + + this.origCell = this.cell = null; + this.origDate = this.date = null; + } + }, + + + // Gets the cell underneath the coordinates for the given mouse event + getCell: function(ev) { + return this.coordMap.getCell(ev.pageX, ev.pageY); + }, + + + // Triggers a callback. Calls a function in the option hash of the same name. + // Arguments beyond the first `name` are forwarded on. + trigger: function(name) { + if (this.options[name]) { + this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); + } + }, + + + // Stops a given mouse event from doing it's native browser action. In our case, text selection. + preventDefault: function(ev) { + ev.preventDefault(); + } + +}; + + +// Returns `true` if the cells are identically equal. `false` otherwise. +// They must have the same row, col, and be from the same grid. +// Two null values will be considered equal, as two "out of the grid" states are the same. +function isCellsEqual(cell1, cell2) { + + if (!cell1 && !cell2) { + return true; + } + + if (cell1 && cell2) { + return cell1.grid === cell2.grid && + cell1.row === cell2.row && + cell1.col === cell2.col; + } + + return false; +} diff --git a/src/common/Grid.events.js b/src/common/Grid.events.js new file mode 100644 index 0000000..c29665e --- /dev/null +++ b/src/common/Grid.events.js @@ -0,0 +1,327 @@ + +/* Event-rendering and event-interaction methods for the abstract Grid class +----------------------------------------------------------------------------------------------------------------------*/ + +$.extend(Grid.prototype, { + + isDraggingSeg: false, // is a segment being dragged? + isResizingSeg: false, // is a segment being resized? + + + // Renders the given events onto the grid + renderEvents: function(events) { + // subclasses must implement + }, + + + // Unrenders all events + destroyEvents: function() { + // subclasses must implement + }, + + + // Converts an array of event objects into an array of segment objects + eventsToSegs: function(events) { + var _this = this; + + return $.map(events, function(event) { + return _this.eventToSegs(event); // $.map flattens all returned arrays together + }); + }, + + + // Slices a single event into an array of event segments + eventToSegs: function(event) { + var eventStart = event.start.clone().stripZone(); // normalize + var eventEnd = this.view.calendar.getEventEnd(event).stripZone(); // compute (if necessary) and normalize + var segs = this.rangeToSegs(eventStart, eventEnd); // defined by the subclass + var i, seg; + + // assign extra event-related properties to the segment objects + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + seg.event = event; + seg.eventStartMS = +eventStart; + seg.eventDurationMS = eventEnd - eventStart; + } + + return segs; + }, + + + // Attaches event-element-related handlers to the container element and leverage bubbling + bindSegHandlers: function() { + var _this = this; + var view = this.view; + + $.each( + { + mouseenter: function(seg, ev) { + view.trigger('eventMouseover', this, seg.event, ev); + }, + mouseleave: function(seg, ev) { + view.trigger('eventMouseout', this, seg.event, ev); + }, + click: function(seg, ev) { + return view.trigger('eventClick', this, seg.event, ev); // can return `false` to cancel + }, + mousedown: function(seg, ev) { + if ($(ev.target).is('.fc-resizer') && view.isEventResizable(seg.event)) { + _this.segResizeMousedown(seg, ev); + } + else if (view.isEventDraggable(seg.event)) { + _this.segDragMousedown(seg, ev); + } + } + }, + function(name, func) { + // attach the handler to the container element and only listen for real event elements via bubbling + _this.el.on(name, '.fc-content-skeleton .fc-event-container > *', function(ev) { + var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents + + if (seg /*&& !_this.isDraggingSeg && !_this.isResizingSeg*/) { + // needs more work if we want eventMouseout to fire correctly + func.call(this, seg, ev); // `this` will be the event element + } + }); + } + ); + }, + + + // Called when the user does a mousedown on an event, which might lead to dragging. + // Generic enough to work with any type of Grid. + segDragMousedown: function(seg, ev) { + var _this = this; + var view = this.view; + var el = seg.el; + var event = seg.event; + var start = event.start; + var end = view.calendar.getEventEnd(event); + var newStart = null; + + // A clone of the original element that will move with the mouse + var mouseFollower = new MouseFollower(seg.el, { + parentEl: view.el, + opacity: view.opt('dragOpacity'), + revertDuration: view.opt('dragRevertDuration'), + zIndex: 2 // one above the .fc-view + }); + + // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents + // of the view. + var dragListener = new DragListener(view.coordMap, { + distance: 5, + listenStart: function(ev) { + mouseFollower.hide(); // don't show until we know this is a real drag + mouseFollower.start(ev); + }, + dragStart: function(ev) { + _this.isDraggingSeg = true; + view.hideEvent(event); // hide all event segments. our mouseFollower will take over + + view.trigger('eventDragStart', el[0], event, ev, {}); // last argument is jqui dummy + }, + cellOver: function(cell, date) { + var origDate = dragListener.origDate; + var delta; + var newEnd; + + if (origDate) { // must start out on a cell (weird accident if it didn't) + + if (date.hasTime() === origDate.hasTime()) { // staying all-day or staying timed + delta = dayishDiff(date, origDate); + newStart = start.clone().add(delta); + if (event.end === null) { // do we need to compute an end? + newEnd = null; + } + else { + newEnd = end.clone().add(delta); + } + } + else { // switching from all-day to timed, or vice versa + newStart = date; + newEnd = null; // end should be cleared + } + + if (view.renderDrag(newStart, newEnd, seg)) { // have the view render a visual indication + mouseFollower.hide(); // if the view is already using a mock event "helper", hide our own + } + else { + mouseFollower.show(); + } + } + }, + cellOut: function() { // called before mouse moves to a different cell OR moved out of all cells + newStart = null; + view.destroyDrag(); // unrender whatever was done in view.renderDrag + mouseFollower.show(); // show in case we are moving out of all cells + }, + dragStop: function(ev) { + var hasChanged = newStart && !newStart.isSame(start); + + // do revert animation if hasn't changed. calls a callback when finished (whether animation or not) + mouseFollower.stop(!hasChanged, function() { + _this.isDraggingSeg = false; + view.destroyDrag(); + view.showEvent(event); + + view.trigger('eventDragStop', el[0], event, ev, {}); // last argument is jqui dummy + + if (hasChanged) { + view.eventDrop(el[0], event, newStart, ev); // will rerender all events... + } + }); + }, + listenStop: function() { + mouseFollower.stop(); // put in listenStop in case there was a mousedown but the drag never started + } + }); + + dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart + }, + + + // Called when the user does a mousedown on an event's resizer, which might lead to resizing. + // Generic enough to work with any type of Grid. + segResizeMousedown: function(seg, ev) { + var _this = this; + var view = this.view; + var el = seg.el; + var event = seg.event; + var start = event.start; + var end = view.calendar.getEventEnd(event); + var newEnd = null; + var dragListener; + + function destroy() { // resets the rendering + _this.destroyResize(); + view.showEvent(event); + } + + // Tracks mouse movement over the *grid's* coordinate map + dragListener = new DragListener(this.coordMap, { + distance: 5, + dragStart: function(ev) { + _this.isResizingSeg = true; + + view.trigger('eventResizeStart', el[0], event, ev, {}); // last argument is jqui dummy + }, + cellOver: function(cell, date) { + // compute the new end. don't allow it to go before the event's start + if (date < start) { + date = start; + } + newEnd = date.clone().add(_this.cellDuration); // make it an exclusive end + + if (newEnd.isSame(end)) { + newEnd = null; + destroy(); + } + else { + _this.renderResize(start, newEnd, seg); + view.hideEvent(event); + } + }, + cellOut: function() { // called before mouse moves to a different cell OR moved out of all cells + newEnd = null; + destroy(); + }, + dragStop: function(ev) { + _this.isResizingSeg = false; + destroy(); + + view.trigger('eventResizeStop', el[0], event, ev, {}); // last argument is jqui dummy + + if (newEnd) { + view.eventResize(el[0], event, newEnd, ev); // will rerender all events... + } + } + }); + + dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart + }, + + + // Generic utility for generating the HTML classNames for an event segment's element + getSegClasses: function(seg, isDraggable, isResizable) { + var event = seg.event; + var classes = [ + 'fc-event', + seg.isStart ? 'fc-start' : 'fc-not-start', + seg.isEnd ? 'fc-end' : 'fc-not-end' + ].concat( + event.className, + event.source ? event.source.className : [] + ); + + if (isDraggable) { + classes.push('fc-draggable'); + } + if (isResizable) { + classes.push('fc-resizable'); + } + + return classes; + }, + + + // Utility for generating a CSS string with all the event skin-related properties + getEventSkinCss: function(event) { + var view = this.view; + var source = event.source || {}; + var eventColor = event.color; + var sourceColor = source.color; + var optionColor = view.opt('eventColor'); + var backgroundColor = + event.backgroundColor || + eventColor || + source.backgroundColor || + sourceColor || + view.opt('eventBackgroundColor') || + optionColor; + var borderColor = + event.borderColor || + eventColor || + source.borderColor || + sourceColor || + view.opt('eventBorderColor') || + optionColor; + var textColor = + event.textColor || + source.textColor || + view.opt('eventTextColor'); + var statements = []; + if (backgroundColor) { + statements.push('background-color:' + backgroundColor); + } + if (borderColor) { + statements.push('border-color:' + borderColor); + } + if (textColor) { + statements.push('color:' + textColor); + } + return statements.join(';'); + } + +}); + + +/* Event Segment Utilities +----------------------------------------------------------------------------------------------------------------------*/ + + +// A cmp function for determining which segments should take visual priority +function compareSegs(seg1, seg2) { + return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first + seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first + seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1) + (seg1.event.title || '').localeCompare(seg2.event.title); // tie? alphabetically by title +} + + +// Returns `true` if the segment has a rendered element and `false` otherwise +function renderedSegFilter(seg) { + return !!seg.el; +} + diff --git a/src/common/Grid.js b/src/common/Grid.js new file mode 100644 index 0000000..093d5b9 --- /dev/null +++ b/src/common/Grid.js @@ -0,0 +1,310 @@ + +/* An abstract class comprised of a "grid" of cells that each represent a specific datetime +----------------------------------------------------------------------------------------------------------------------*/ + +function Grid(view) { + RowRenderer.call(this, view); // call the super-constructor + this.coordMap = new GridCoordMap(this); +} + + +Grid.prototype = createObject(RowRenderer.prototype); // declare the super-class +$.extend(Grid.prototype, { + + el: null, // the containing element + coordMap: null, // a GridCoordMap that converts pixel values to datetimes + cellDuration: null, // a cell's duration. subclasses must assign this ASAP + + + // Renders the grid into the `el` element. + // Subclasses should override and call this super-method when done. + render: function() { + this.bindHandlers(); + }, + + + /* Coordinates & Cells + ------------------------------------------------------------------------------------------------------------------*/ + + + // Populates the given empty arrays with the y and x coordinates of the cells + buildCoords: function(rows, cols) { + // subclasses must implement + }, + + + // Given a cell object, returns the date for that cell + getCellDate: function(cell) { + // subclasses must implement + }, + + + // Given a cell object, returns the element that represents the cell's whole-day + getCellDayEl: function(cell) { + // subclasses must implement + }, + + + // Converts a range with an inclusive `start` and an exclusive `end` into an array of segment objects + rangeToSegs: function(start, end) { + // subclasses must implement + }, + + + /* Handlers + ------------------------------------------------------------------------------------------------------------------*/ + + + // Attach handlers to `this.el`, using bubbling to listen to all ancestors. + // We don't need to undo any of this in a "destroy" method, because the view will simply remove `this.el` from the + // DOM and jQuery will be smart enough to garbage collect the handlers. + bindHandlers: function() { + var _this = this; + + this.el.on('mousedown', function(ev) { + if (!$(ev.target).is('.fc-event-container *')) { // not an event element + _this.dayMousedown(ev); + } + }); + + this.bindSegHandlers(); // attach event-element-related handlers. in Grid.events.js + }, + + + // Process a mousedown on an element that represents a day. For day clicking and selecting. + dayMousedown: function(ev) { + var _this = this; + var view = this.view; + var isSelectable = view.opt('selectable'); + var dates = null; // the inclusive dates of the selection. will be null if no selection + var start; // the inclusive start of the selection + var end; // the *exclusive* end of the selection + var dayEl; + + // this listener tracks a mousedown on a day element, and a subsequent drag. + // if the drag ends on the same day, it is a 'dayClick'. + // if 'selectable' is enabled, this listener also detects selections. + var dragListener = new DragListener(this.coordMap, { + //distance: 5, // needs more work if we want dayClick to fire correctly + dragStart: function() { + view.unselect(); // since we could be rendering a new selection, we want to clear any old one + }, + cellOver: function(cell, date) { + if (dragListener.origDate) { // click needs to have started on a cell + + dayEl = _this.getCellDayEl(cell); + + dates = [ date, dragListener.origDate ].sort(dateCompare); + start = dates[0]; + end = dates[1].clone().add(_this.cellDuration); + + if (isSelectable) { + _this.renderSelection(start, end); + } + } + }, + cellOut: function(cell, date) { + dates = null; + _this.destroySelection(); + }, + listenStop: function(ev) { + if (dates) { // started and ended on a cell? + if (dates[0].isSame(dates[1])) { + view.trigger('dayClick', dayEl[0], start, ev); + } + if (isSelectable) { + // the selection will already have been rendered. just report it + view.reportSelection(start, end, ev); + } + } + } + }); + + dragListener.mousedown(ev); // start listening, which will eventually initiate a dragStart + }, + + + /* Event Dragging + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of a event being dragged over the given date(s). + // `end` can be null, as well as `seg`. See View's documentation on renderDrag for more info. + // A returned value of `true` signals that a mock "helper" event has been rendered. + renderDrag: function(start, end, seg) { + // subclasses must implement + }, + + + // Unrenders a visual indication of an event being dragged + destroyDrag: function() { + // subclasses must implement + }, + + + /* Event Resizing + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of an event being resized. + // `start` and `end` are the updated dates of the event. `seg` is the original segment object involved in the drag. + renderResize: function(start, end, seg) { + // subclasses must implement + }, + + + // Unrenders a visual indication of an event being resized. + destroyResize: function() { + // subclasses must implement + }, + + + /* Event Helper + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a mock event over the given date(s). + // `end` can be null, in which case the mock event that is rendered will have a null end time. + // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging. + renderRangeHelper: function(start, end, sourceSeg) { + var view = this.view; + var fakeEvent; + + // compute the end time if forced to do so (this is what EventManager does) + if (!end && view.opt('forceEventDuration')) { + end = view.calendar.getDefaultEventEnd(!start.hasTime(), start); + } + + fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible + fakeEvent.start = start; + fakeEvent.end = end; + fakeEvent.allDay = !(start.hasTime() || (end && end.hasTime())); // freshly compute allDay + + // this extra className will be useful for differentiating real events from mock events in CSS + fakeEvent.className = (fakeEvent.className || []).concat('fc-helper'); + + // if something external is being dragged in, don't render a resizer + if (!sourceSeg) { + fakeEvent.editable = false; + } + + this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering + }, + + + // Renders a mock event + renderHelper: function(event, sourceSeg) { + // subclasses must implement + }, + + + // Unrenders a mock event + destroyHelper: function() { + // subclasses must implement + }, + + + /* Selection + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses. + renderSelection: function(start, end) { + this.renderHighlight(start, end); + }, + + + // Unrenders any visual indications of a selection. Will unrender a highlight by default. + destroySelection: function() { + this.destroyHighlight(); + }, + + + /* Highlight + ------------------------------------------------------------------------------------------------------------------*/ + + + // Puts visual emphasis on a certain date range + renderHighlight: function(start, end) { + // subclasses should implement + }, + + + // Removes visual emphasis on a date range + destroyHighlight: function() { + // subclasses should implement + }, + + + + /* Generic rendering utilities for subclasses + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a day-of-week header row + headHtml: function() { + return '' + + '
' + + '' + + '' + + this.rowHtml('head') + // leverages RowRenderer + '' + + '
' + + '
'; + }, + + + // Used by the `headHtml` method, via RowRenderer, for rendering the HTML of a day-of-week header cell + headCellHtml: function(row, col, date) { + var view = this.view; + var calendar = view.calendar; + var colFormat = view.opt('columnFormat'); + + return '' + + '
' + + htmlEscape(calendar.formatDate(date, colFormat)) + + '
', // plain default HTML used for a cell when no other is available + + + // Renders the HTML for a row, leveraging custom cell-HTML-renderers based on the `rowType`. + // Also applies the "intro" and "outro" cells, which are specified by the subclass and views. + // `row` is an optional row number. + rowHtml: function(rowType, row) { + var view = this.view; + var renderCell = this.getHtmlRenderer('cell', rowType); + var cellHtml = ''; + var col; + var date; + + row = row || 0; + + for (col = 0; col < view.colCnt; col++) { + date = view.cellToDate(row, col); + cellHtml += renderCell(row, col, date); + } + + cellHtml = this.bookendCells(cellHtml, rowType, row); // apply intro and outro + + return '
's or a jQuery
portion of the event-skeleton. + // Returns an object with properties 'tbodyEl' and 'segs'. + renderEventTable: function(events) { + var view = this.view; + var tableEl = $('
'); + var trEl = tableEl.find('tr'); + var allSegs = this.eventsToSegs(events); + var segCols = this.groupSegCols(allSegs); // groups into sub-arrays, and assigns 'col' to each seg + var html = ''; // html string with default HTML for all events, concatenated together + var i, seg; + var col, segs; + var containerEl; + + // build the combined HTML string. and compute top/bottom + for (i = 0; i < allSegs.length; i++) { + seg = allSegs[i]; + html += this.renderSegHtml(seg); + + seg.top = this.computeDateTop(seg.start, seg.start); + seg.bottom = this.computeDateTop(seg.end, seg.start); + } + + // Grab individual elements from the combined HTML string. Use each as the default rendering. + // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false. + $(html).each(function(i, node) { + allSegs[i].el = view.resolveEventEl(allSegs[i].event, $(node)); + }); + + for (col = 0; col < segCols.length; col++) { // iterate each column grouping + segs = segCols[col]; + + segs = $.grep(segs, renderedSegFilter); // filter out unrendered segments + placeSlotSegs(segs); // compute horizontal coordinates, z-index's, and reorder the array + segCols[col] = segs; // assign back + + containerEl = $('
'); + + // assign positioning CSS and insert into container + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + seg.el.css(this.generateSegPositionCss(seg)); + containerEl.append(seg.el); + } + + trEl.append($('').append(containerEl)); + } + + this.bookendCells(trEl, 'eventSkeleton'); + + return { + tableEl: tableEl, + segs: flattenArray(segCols) // will contain only segments with rendered els + }; + }, + + + // Renders the HTML for a single event segment's default rendering + renderSegHtml: function(seg) { + var view = this.view; + var event = seg.event; + var isDraggable = view.isEventDraggable(event); + var isResizable = seg.isEnd && view.isEventResizable(event); + var classes = this.getSegClasses(seg, isDraggable, isResizable); + var skinCss = this.getEventSkinCss(event); + var timeText; + var fullTimeText; // more verbose time text. for the print stylesheet + + classes.unshift('fc-time-grid-event'); + + if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day... + // Don't display time text on segments that run entirely through a day. + // That would appear as midnight-midnight and would look dumb. + // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am) + if (seg.isStart || seg.isEnd) { + timeText = view.getEventTimeText(seg.start, seg.end); + fullTimeText = view.getEventTimeText(seg.start, seg.end, 'LT'); + } + } else { + // Display the normal time text for the *event's* times + timeText = view.getEventTimeText(event); + fullTimeText = view.getEventTimeText(event, 'LT'); + } + + return '' + + '
' + + (timeText ? + '
' + + '' + htmlEscape(timeText) + '' + + '
' : + '' + ) + + (event.title ? + '
' + + htmlEscape(event.title) + + '
' : + '' + ) + + '
' + + '
' + + (isResizable ? + '
' : + '' + ) + + ''; + }, + + + // Generates an object with css properties/values that should be applied to an event segment element. + // Contains important positioning-related properties that should be applied to any event element, customized or not. + generateSegPositionCss: function(seg) { + var view = this.view; + var isRTL = view.opt('isRTL'); + var shouldOverlap = view.opt('slotEventOverlap'); + var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point + var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point + var left; // amount of space from left edge, a fraction of the total width + var right; // amount of space from right edge, a fraction of the total width + var props; + + if (shouldOverlap) { + // double the width, but don't go beyond the maximum forward coordinate (1.0) + forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2); + } + + if (isRTL) { + left = 1 - forwardCoord; + right = backwardCoord; + } + else { + left = backwardCoord; + right = 1 - forwardCoord; + } + + props = { + zIndex: seg.level + 1, // convert from 0-base to 1-based + top: seg.top, + bottom: -seg.bottom, // flipped because needs to be space beyond bottom edge of event container + left: left * 100 + '%', + right: right * 100 + '%' + }; + + if (shouldOverlap && seg.forwardPressure) { + // add padding to the edge so that forward stacked events don't cover the resizer's icon + props[isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width + } + + return props; + }, + + + // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col + groupSegCols: function(segs) { + var view = this.view; + var segCols = []; + var i; + + for (i = 0; i < view.colCnt; i++) { + segCols.push([]); + } + + for (i = 0; i < segs.length; i++) { + segCols[segs[i].col].push(segs[i]); + } + + return segCols; + } + +}); + + +// Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each. +// Also reorders the given array by date! +function placeSlotSegs(segs) { + var levels; + var level0; + var i; + + segs.sort(compareSegs); // order by date + levels = buildSlotSegLevels(segs); + computeForwardSlotSegs(levels); + + if ((level0 = levels[0])) { + + for (i = 0; i < level0.length; i++) { + computeSlotSegPressures(level0[i]); + } + + for (i = 0; i < level0.length; i++) { + computeSlotSegCoords(level0[i], 0, 0); + } + } +} + + +// Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is +// left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date. +function buildSlotSegLevels(segs) { + var levels = []; + var i, seg; + var j; + + for (i=0; i seg2.top && seg1.top < seg2.bottom; +} + + +// 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... + compareSegs(seg1, seg2); +} diff --git a/src/common/TimeGrid.js b/src/common/TimeGrid.js new file mode 100644 index 0000000..22d3f27 --- /dev/null +++ b/src/common/TimeGrid.js @@ -0,0 +1,474 @@ + +/* A component that renders one or more columns of vertical time slots +----------------------------------------------------------------------------------------------------------------------*/ + +function TimeGrid(view) { + Grid.call(this, view); // call the super-constructor +} + + +TimeGrid.prototype = createObject(Grid.prototype); // define the super-class +$.extend(TimeGrid.prototype, { + + slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines + snapDuration: null, // granularity of time for dragging and selecting + + minTime: null, // Duration object that denotes the first visible time of any given day + maxTime: null, // Duration object that denotes the exclusive visible end time of any given day + + dayEls: null, // cells elements in the day-row background + slatEls: null, // elements running horizontally across all columns + + slatTops: null, // an array of top positions, relative to the container. last item holds bottom of last slot + + highlightEl: null, // cell skeleton element for rendering the highlight + helperEl: null, // cell skeleton element for rendering the mock event "helper" + + + // Renders the time grid into `this.el`, which should already be assigned. + // Relies on the view's colCnt. In the future, this component should probably be self-sufficient. + render: function() { + this.processOptions(); + + this.el.html(this.renderHtml()); + + this.dayEls = this.el.find('.fc-day'); + this.slatEls = this.el.find('.fc-slats tr'); + + this.computeSlatTops(); + + Grid.prototype.render.call(this); // call the super-method + }, + + + // Renders the basic HTML skeleton for the grid + renderHtml: function() { + return '' + + '
' + + '' + + this.rowHtml('slotBg') + // leverages RowRenderer, which will call slotBgCellHtml + '
' + + '
' + + '
' + + '' + + this.slatRowHtml() + + '
' + + '
'; + }, + + + // Renders the HTML for a vertical background cell behind the slots. + // This method is distinct from 'bg' because we wanted a new `rowType` so the View could customize the rendering. + slotBgCellHtml: function(row, col, date) { + return this.bgCellHtml(row, col, date); + }, + + + // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL. + slatRowHtml: function() { + var view = this.view; + var calendar = view.calendar; + var isRTL = view.opt('isRTL'); + var html = ''; + var slotNormal = this.slotDuration.asMinutes() % 15 === 0; + var slotTime = moment.duration(+this.minTime); // wish there was .clone() for durations + var slotDate; // will be on the view's first day, but we only care about its time + var minutes; + var axisHtml; + + // Calculate the time for each slot + while (slotTime < this.maxTime) { + slotDate = view.start.clone().time(slotTime); // will be in UTC but that's good. to avoid DST issues + minutes = slotDate.minutes(); + + axisHtml = + '' + + ((!slotNormal || !minutes) ? // if irregular slot duration, or on the hour, then display the time + '' + // for matchCellWidths + htmlEscape(calendar.formatDate(slotDate, view.opt('axisFormat'))) + + '' : + '' + ) + + ''; + + html += + '' + + (!isRTL ? axisHtml : '') + + '' + + (isRTL ? axisHtml : '') + + ""; + + slotTime.add(this.slotDuration); + } + + return html; + }, + + + // Parses various options into properties of this object + processOptions: function() { + var view = this.view; + var slotDuration = view.opt('slotDuration'); + var snapDuration = view.opt('snapDuration'); + + slotDuration = moment.duration(slotDuration); + snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration; + + this.slotDuration = slotDuration; + this.snapDuration = snapDuration; + this.cellDuration = snapDuration; // important to assign this for Grid.events.js + + this.minTime = moment.duration(view.opt('minTime')); + this.maxTime = moment.duration(view.opt('maxTime')); + }, + + + // Slices up a date range into a segment for each column + rangeToSegs: function(start, end) { + var view = this.view; + var segs = []; + var col; + var cellDate; + var colStart, colEnd; + var segStart, segEnd; + var isStart, isEnd; + + // normalize + start = start.clone().stripZone(); + end = end.clone().stripZone(); + + for (col = 0; col < view.colCnt; col++) { + cellDate = view.cellToDate(0, col); // use the View's cell system for this + colStart = cellDate.clone().stripZone().time(this.minTime); // normalize and calculate + colEnd = cellDate.clone().stripZone().time(this.maxTime); // normalize and calculate + + if (end > colStart && start < colEnd) { // in bounds at all? + + if (start >= colStart) { + segStart = start.clone(); + isStart = true; + } + else { + segStart = colStart; // don't need to clone + isStart = false; + } + + if (end <= colEnd) { + segEnd = end.clone(); + isEnd = true; + } + else { + segEnd = colEnd; // don't need to clone + isEnd = false; + } + + segs.push({ + col: col, + start: segStart, + end: segEnd, + isStart: isStart, + isEnd: isEnd + }); + } + } + + return segs; + }, + + + /* Coordinates + ------------------------------------------------------------------------------------------------------------------*/ + + + // Populates the given empty `rows` and `cols` arrays with offset positions of the "snap" cells. + // "Snap" cells are different the slots because they might have finer granularity. + buildCoords: function(rows, cols) { + var colCnt = this.view.colCnt; + var originTop = this.el.offset().top; + var snapTime = moment.duration(+this.minTime); + var p = null; + var e, n; + + this.dayEls.slice(0, colCnt).each(function(i, _e) { + e = $(_e); + n = e.offset().left; + if (p) { + p[1] = n; + } + p = [ n ]; + cols[i] = p; + }); + p[1] = n + e.outerWidth(); + + p = null; + while (snapTime < this.maxTime) { + n = originTop + this.computeTimeTop(snapTime); + if (p) { + p[1] = n; + } + p = [ n ]; + rows.push(p); + snapTime.add(this.snapDuration); + } + p[1] = originTop + this.computeTimeTop(snapTime); // the position of the exclusive end + }, + + + // Gets the datetime for the given slot cell + getCellDate: function(cell) { + // the View's cellToDate system only accounts for the beginning of whole days + return this.view.cellToDate(0, cell.col).time(this.snapDuration * cell.row); + }, + + + // Gets the element that represents the whole-day the cell resides on + getCellDayEl: function(cell) { + return this.dayEls.eq(cell.col); + }, + + + // Computes the top coordinate, relative to the bounds of the grid, of the given date. + // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight. + computeDateTop: function(date, startOfDayDate) { + return this.computeTimeTop( + moment.duration( + date.clone().stripZone() - startOfDayDate.clone().stripTime() + ) + ); + }, + + + // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration). + computeTimeTop: function(time) { + var slatCoverage = (time - this.minTime) / this.slotDuration; // floating-point value of # of slots covered + var slatIndex; + var slatRemainder; + var slatTop; + var slatBottom; + + // constrain. because minTime/maxTime might be customized + slatCoverage = Math.max(0, slatCoverage); + slatCoverage = Math.min(this.slatEls.length, slatCoverage); + + slatIndex = Math.floor(slatCoverage); // an integer index of the furthest whole slot + slatRemainder = slatCoverage - slatIndex; + slatTop = this.slatTops[slatIndex]; // the top position of the furthest whole slot + + if (slatRemainder) { // time spans part-way into the slot + slatBottom = this.slatTops[slatIndex + 1]; + return slatTop + (slatBottom - slatTop) * slatRemainder; // part-way between slots + } + else { + return slatTop; + } + }, + + + // Queries each `slatEl` for its position relative to the grid's container and stores it in `slatTops`. + // Includes the the bottom of the last slat as the last item in the array. + computeSlatTops: function() { + var tops = []; + var top; + + this.slatEls.each(function(i, node) { + top = $(node).position().top; + tops.push(top); + }); + + tops.push(top + this.slatEls.last().outerHeight()); // bottom of the last slat + + this.slatTops = tops; + }, + + + /* Event Drag Visualization + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of an event being dragged over the specified date(s). + // `end` and `seg` can be null. See View's documentation on renderDrag for more info. + renderDrag: function(start, end, seg) { + var opacity; + + if (seg) { // if there is event information for this drag, render a helper event + this.renderRangeHelper(start, end, seg); + + opacity = this.view.opt('dragOpacity'); + if (opacity !== undefined) { + this.helperEl.css('opacity', opacity); + } + + return true; // signal that a helper has been rendered + } + else { + // otherwise, just render a highlight + this.renderHighlight( + start, + end || this.view.calendar.getDefaultEventEnd(false, start) + ); + } + }, + + + // Unrenders any visual indication of an event being dragged + destroyDrag: function() { + this.destroyHelper(); + this.destroyHighlight(); + }, + + + /* Event Resize Visualization + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of an event being resized + renderResize: function(start, end, seg) { + this.renderRangeHelper(start, end, seg); + }, + + + // Unrenders any visual indication of an event being resized + destroyResize: function() { + this.destroyHelper(); + }, + + + /* Event Helper + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag) + renderHelper: function(event, sourceSeg) { + var res = this.renderEventTable([ event ]); + var tableEl = res.tableEl; + var segs = res.segs; + var i, seg; + var sourceEl; + + // Try to make the segment that is in the same row as sourceSeg look the same + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + if (sourceSeg && sourceSeg.col === seg.col) { + sourceEl = sourceSeg.el; + seg.el.css({ + left: sourceEl.css('left'), + right: sourceEl.css('right'), + 'margin-left': sourceEl.css('margin-left'), + 'margin-right': sourceEl.css('margin-right') + }); + } + } + + this.helperEl = $('
') + .append(tableEl) + .appendTo(this.el); + }, + + + // Unrenders any mock helper event + destroyHelper: function() { + if (this.helperEl) { + this.helperEl.remove(); + this.helperEl = null; + } + }, + + + /* Selection + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight. + renderSelection: function(start, end) { + if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered + this.renderRangeHelper(start, end); + } + else { + this.renderHighlight(start, end); + } + }, + + + // Unrenders any visual indication of a selection + destroySelection: function() { + this.destroyHelper(); + this.destroyHighlight(); + }, + + + /* Highlight + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders an emphasis on the given date range. `start` is inclusive. `end` is exclusive. + renderHighlight: function(start, end) { + this.highlightEl = $( + this.highlightSkeletonHtml(start, end) + ).appendTo(this.el); + }, + + + // Unrenders the emphasis on a date range + destroyHighlight: function() { + if (this.highlightEl) { + this.highlightEl.remove(); + this.highlightEl = null; + } + }, + + + // Generates HTML for a table element with containers in each column, responsible for absolutely positioning the + // highlight elements to cover the highlighted slots. + highlightSkeletonHtml: function(start, end) { + var view = this.view; + var segs = this.rangeToSegs(start, end); + var cellHtml = ''; + var col = 0; + var i, seg; + var dayDate; + var top, bottom; + + for (i = 0; i < segs.length; i++) { // loop through the segments. one per column + seg = segs[i]; + + // need empty cells beforehand? + if (col < seg.col) { + cellHtml += ''; + col = seg.col; + } + + // compute vertical position + dayDate = view.cellToDate(0, col); + top = this.computeDateTop(seg.start, dayDate); + bottom = this.computeDateTop(seg.end, dayDate); // the y position of the bottom edge + + // generate the cell HTML. bottom becomes negative because it needs to be a CSS value relative to the + // bottom edge of the zero-height container. + cellHtml += + '' + + '
' + + '
' + + '
' + + ''; + + col++; + } + + // need empty cells after the last segment? + if (col < view.colCnt) { + cellHtml += ''; + } + + cellHtml = this.bookendCells(cellHtml, 'highlight'); + + return '' + + '
' + + '' + + '' + + cellHtml + + '' + + '
' + + '
'; + } + +}); diff --git a/src/common/View.js b/src/common/View.js index 4d85dca..9c69412 100644 --- a/src/common/View.js +++ b/src/common/View.js @@ -1,46 +1,358 @@ +/* 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 + + segs: null, // array of rendered event segment objects + + 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, + + dayRowThemeClass: null, // sets the theme className applied to DayGrid rows (none by default) + + // document handlers, bound to `this` object + documentMousedownProxy: null, + documentDragStartProxy: null, + -function View(element, calendar, viewName) { + // 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 reference to `this`-bound handlers and attach to document + $(document) + .on('mousedown', this.documentMousedownProxy = $.proxy(this, 'documentMousedown')) + .on('dragstart', this.documentDragStartProxy = $.proxy(this, 'documentDragStart')); // jqui drag + }, + + + // Renders the view inside an already-defined `this.el`. + // Subclasses should override this and then call the super method afterwards. + render: function() { + this.updateHeight(); + this.updateWidth(); + this.trigger('viewRender', this, this, this.el); + }, + + + // 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 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) { + // `otherHeight` is the cumulative height of everything that is not the scrollerEl in the view (header+borders) + var otherHeight = this.el.outerHeight() - this.scrollerEl.height(); + 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() { + 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 assign `this.segs` and call the super-method afterwards. + renderEvents: function(events) { + this.segEach(function(seg) { + seg.el.data('fc-seg', seg); // store info about the segment object. used by handlers + this.trigger('eventAfterRender', seg.event, seg.event, seg.el); + }); + this.trigger('eventAfterAllRender'); + }, + + + // Removes event elements from the view. + // Should be overridden by subclasses. Actual element destruction should happen first, then call super-method. + destroyEvents: function() { + this.segEach(function(seg) { + this.trigger('eventDestroy', seg.event, seg.event, seg.el); + }); + this.segs = []; + }, + + + // 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.segs || []; + var i; + + for (i = 0; i < segs.length; i++) { + if (!event || segs[i].event._id === event._id) { + func.call(this, segs[i]); + } + } + }, + + + /* 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.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); + + t.init(); // the "constructor" that concerns the prototype methods - - function opt(name, viewNameOverride) { + function opt(name) { var v = options[name]; if ($.isPlainObject(v) && !isForcedAtomicOption(name)) { - return smartProperty(v, viewNameOverride || viewName); + return smartProperty(v, t.name); } return v; } @@ -61,130 +373,66 @@ function View(element, calendar, viewName) { function isEventDraggable(event) { var source = event.source || {}; + return firstDefined( - event.startEditable, - source.startEditable, - opt('eventStartEditable'), - event.editable, - source.editable, - opt('editable') - ); + 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 + function isEventResizable(event) { var source = event.source || {}; + return firstDefined( - event.durationEditable, - source.durationEditable, - opt('eventDurationEditable'), - event.editable, - source.editable, - opt('editable') - ); - } - - - - /* Event Data - ------------------------------------------------------------------------------*/ - - - function clearEventData() { - eventElementsByID = {}; - eventElementCouples = []; + event.durationEditable, + source.durationEditable, + opt('eventDurationEditable'), + event.editable, + source.editable, + opt('editable') + ); } /* 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]; - } - } - - - 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 cell (object with row & col keys) function cellOffsetToCell(cellOffset) { - var colCnt = t.getColCnt(); + var colCnt = t.colCnt; // rtl variables. wish we could pre-populate these. but where? var dis = isRTL ? -1 : 1; @@ -445,18 +694,14 @@ function View(element, calendar, viewName) { // function rangeToSegments(start, end) { - var rowCnt = t.getRowCnt(); - var colCnt = t.getColCnt(); + var rowCnt = t.rowCnt; + var colCnt = t.colCnt; 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); + 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 @@ -489,7 +734,8 @@ function View(element, calendar, viewName) { // can translate to multiple days, and an edge case reveals itself when we the // range's first cell is hidden (we don't want isStart to be true). var isStart = cellOffsetToDayOffset(segmentCellOffsetFirst) == rangeDayOffsetStart; - var isEnd = cellOffsetToDayOffset(segmentCellOffsetLast) + 1 == rangeDayOffsetEnd; // +1 for comparing exclusively + var isEnd = cellOffsetToDayOffset(segmentCellOffsetLast) + 1 == rangeDayOffsetEnd; + // +1 for comparing exclusively segments.push({ row: row, @@ -503,6 +749,42 @@ function View(element, calendar, viewName) { return segments; } - + + + // Returns the date range of the full days the given range visually appears to occupy. + // Returns object with properties `start` (moment) and `end` (moment, exclusive end). + function computeDayRange(start, end) { + var startDay = start.clone().stripTime(); // the beginning of the day the range starts + var endDay; + var endTimeMS; + + if (end) { + endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends + endTimeMS = +end.time(); // # of milliseconds into `endDay` + + // If the end time is actually inclusively part of the next day and is equal to or + // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`. + // Otherwise, leaving it as inclusive will cause it to exclude `endDay`. + if (endTimeMS && endTimeMS >= nextDayThreshold) { + endDay.add('days', 1); + } + } + + // If no end was specified, or if it is within `startDay` but not past nextDayThreshold, + // assign the default duration of one day. + if (!end || endDay <= startDay) { + endDay = startDay.clone().add('days', 1); + } + + return { start: startDay, end: endDay }; + } + + + // Does the given event visually appear to occupy more than one day? + function isMultiDayEvent(event) { + var range = computeDayRange(event.start, event.end); + + return range.end.diff(range.start, 'days') > 1; + } } diff --git a/src/common/common.css b/src/common/common.css index 1057aef..0a859a4 100644 --- a/src/common/common.css +++ b/src/common/common.css @@ -1,90 +1,125 @@ +/*! + * <%= meta.title %> v<%= meta.version %> Stylesheet + * Docs & License: <%= meta.homepage %> + * (c) <%= meta.copyright %> + */ -/* Cell Styles -------------------------------------------------------------------------*/ -.fc-widget-header, /* , usually */ -.fc-widget-content { /* , usually */ - border: 1px solid #ddd; - } - -.fc-state-highlight { /* today cell */ /* TODO: add .fc-today to */ +.fc { + direction: ltr; + text-align: left; +} + +.fc-rtl { + text-align: right; +} + +body .fc { /* extra precedence to overcome jqui */ + font-size: 1em; +} + + +/* Colors +--------------------------------------------------------------------------------------------------*/ + +.fc-unthemed th, +.fc-unthemed td, +.fc-unthemed hr, +.fc-unthemed thead, +.fc-unthemed tbody, +.fc-unthemed .fc-row { + border-color: #ddd; +} + +.fc-unthemed hr { + background: #eee; +} + +.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 */ - } - +} -/* Buttons -------------------------------------------------------------------------*/ +/* Icons (inline elements with styled text that mock arrow icons) +--------------------------------------------------------------------------------------------------*/ -.fc-button { - position: relative; +.fc-icon { display: inline-block; - padding: 0 .6em; - 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; - } - -.fc-state-default.fc-corner-right { /* non-theme */ - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; - } - -/* - Our default prev/next buttons use HTML entities like ‹ › « » - and we'll try to make them look good cross-browser. -*/ - -.fc-button .fc-icon { - margin: 0 .1em; font-size: 2em; + line-height: .5em; + height: .5em; /* will make the total height 1em */ font-family: "Courier New", Courier, monospace; - vertical-align: baseline; /* for IE7 */ - } +} .fc-icon-left-single-arrow:after { content: "\02039"; font-weight: bold; - } +} .fc-icon-right-single-arrow:after { content: "\0203A"; font-weight: bold; - } +} .fc-icon-left-double-arrow:after { content: "\000AB"; - } +} .fc-icon-right-double-arrow:after { content: "\000BB"; - } - -/* icon (for jquery ui) */ +} -.fc-button .ui-icon { + +/* Buttons (styled