From bba2d6fcad0ed0007a767134042c8bf6e2e6b013 Mon Sep 17 00:00:00 2001 From: Adam Shaw Date: Sat, 2 Aug 2014 14:56:17 -0700 Subject: [PATCH] popover when user clicks on "more" link --- lumbar.json | 1 + src/Calendar.js | 31 +++-- src/agenda/AgendaView.js | 22 +++- src/agenda/agenda.css | 15 +-- src/basic/BasicView.js | 15 ++- src/basic/MonthView.js | 4 +- src/basic/basic.css | 15 +++ src/common/DayGrid.events.js | 47 ++++---- src/common/DayGrid.js | 5 + src/common/DayGrid.limit.js | 136 +++++++++++++++++++--- src/common/Grid.events.js | 207 ++++++++++++++++++++++++++-------- src/common/Grid.js | 11 +- src/common/Popover.js | 165 +++++++++++++++++++++++++++ src/common/TimeGrid.events.js | 51 ++++----- src/common/TimeGrid.js | 45 ++------ src/common/View.js | 18 +-- src/common/common.css | 88 ++++++++++++--- src/util.js | 68 +++++++++-- 18 files changed, 736 insertions(+), 208 deletions(-) create mode 100644 src/common/Popover.js diff --git a/lumbar.json b/lumbar.json index f3a14b8..2330fbd 100644 --- a/lumbar.json +++ b/lumbar.json @@ -22,6 +22,7 @@ "src/util.js", "src/moment-ext.js", "src/date-formatting.js", + "src/common/Popover.js", "src/common/CoordMap.js", "src/common/DragListener.js", "src/common/MouseFollower.js", diff --git a/src/Calendar.js b/src/Calendar.js index 6c0d443..e8e79f9 100644 --- a/src/Calendar.js +++ b/src/Calendar.js @@ -52,7 +52,7 @@ function Calendar(element, instanceOptions) { t.today = today; t.gotoDate = gotoDate; t.incrementDate = incrementDate; - t.zoomToDay = zoomToDay; + t.zoomTo = zoomTo; t.getDate = getDate; t.getCalendar = getCalendar; t.getView = getView; @@ -424,6 +424,7 @@ function Calendar(element, instanceOptions) { } ignoreWindowResize++; + currentView.recordScroll(); // for keeping the scroll value (if any) before refreshing dimensions currentView.updateHeight(); // will poll getSuggestedViewHeight() and isHeightAuto() currentView.updateWidth(); ignoreWindowResize--; @@ -625,19 +626,25 @@ function Calendar(element, instanceOptions) { } - // Forces navigation to a day-view on the given date. `viewName` is the name of an explicit view to go to. - // If not specified, or 'auto', it will guess the best view based on which buttons are in the header toolbar. - function zoomToDay(newDate, viewName) { - var viewsWithButtons; + // Forces navigation to a view for the given date. + // `viewName` can be a specific view name or a generic one like "week" or "day". + function zoomTo(newDate, viewName) { + var viewStr; + var match; - if (fcViews[viewName] === undefined) { // not an available view, or 'auto' - viewsWithButtons = header.getViewsWithButtons(); - if ($.inArray('basicDay', viewsWithButtons) !== -1) { - viewName = 'basicDay'; - } - else { - viewName = 'agendaDay'; // the fallback + if (!viewName || fcViews[viewName] === undefined) { // a general view name, or "auto" + viewName = viewName || 'day'; + viewStr = header.getViewsWithButtons().join(' '); // space-separated string of all the views in the header + + // try to match a general view name, like "week", against a specific one, like "agendaWeek" + match = viewStr.match(new RegExp('\\w+' + capitaliseFirstLetter(viewName))); + + // fall back to the day view being used in the header + if (!match) { + match = viewStr.match(/\w+Day/); } + + viewName = match ? match[0] : 'agendaDay'; // fall back to agendaDay } date = newDate; diff --git a/src/agenda/AgendaView.js b/src/agenda/AgendaView.js index ead2169..98dadc7 100644 --- a/src/agenda/AgendaView.js +++ b/src/agenda/AgendaView.js @@ -115,6 +115,16 @@ $.extend(AgendaView.prototype, { }, + // Make subcomponents ready for cleanup + destroy: function() { + this.timeGrid.destroy(); + if (this.dayGrid) { + this.dayGrid.destroy(); + } + View.prototype.destroy.call(this); // call the super-method + }, + + // Builds the HTML skeleton for the view. // The day-grid and time-grid components will render inside containers defined by this HTML. renderHtml: function() { @@ -317,12 +327,18 @@ $.extend(AgendaView.prototype, { // the all-day area is flexible and might have a lot of events, so shift the height this.updateHeight(); - this.segs = daySegs.concat(timedSegs); // needed by the View super-class - View.prototype.renderEvents.call(this, events); // call the super-method }, + // Retrieves all segment objects that are rendered in the view + getSegs: function() { + return this.timeGrid.getSegs().concat( + this.dayGrid ? this.dayGrid.getSegs() : [] + ); + }, + + // Unrenders all event elements and clears internal segment data destroyEvents: function() { @@ -340,7 +356,7 @@ $.extend(AgendaView.prototype, { // A) a renderEvents() call always happens after this, which will eventually call updateHeight() // B) in IE8, this causes a flash whenever events are rerendered - View.prototype.destroyEvents.call(this); // call the super-method. will kill `this.segs` + View.prototype.destroyEvents.call(this); // call the super-method }, diff --git a/src/agenda/agenda.css b/src/agenda/agenda.css index 6aadf84..81fbc0e 100644 --- a/src/agenda/agenda.css +++ b/src/agenda/agenda.css @@ -126,9 +126,12 @@ /* TimeGrid Event Styling ---------------------------------------------------------------------------------------------------*/ +---------------------------------------------------------------------------------------------------- +We use the full "fc-time-grid-event" class instead of using descendants because the event won't +be a descendant of the grid when it is being dragged. +*/ -.fc-time-grid .fc-event.fc-not-start { /* events that are continuing from another day */ +.fc-time-grid-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; @@ -138,7 +141,7 @@ border-top-right-radius: 0; } -.fc-time-grid .fc-event.fc-not-end { +.fc-time-grid-event.fc-not-end { /* replace space made by the top border with padding */ border-bottom-width: 0; padding-bottom: 1px; @@ -148,12 +151,6 @@ 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 */ } diff --git a/src/basic/BasicView.js b/src/basic/BasicView.js index 3dbfb95..11dc6d9 100644 --- a/src/basic/BasicView.js +++ b/src/basic/BasicView.js @@ -50,6 +50,13 @@ $.extend(BasicView.prototype, { }, + // Make subcomponents ready for cleanup + destroy: function() { + this.dayGrid.destroy(); + View.prototype.destroy.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() { @@ -214,7 +221,7 @@ $.extend(BasicView.prototype, { // Renders the given events onto the view and populates the segments array renderEvents: function(events) { - this.segs = this.dayGrid.renderEvents(events); + this.dayGrid.renderEvents(events); this.updateHeight(); // must compensate for events that overflow the row @@ -222,6 +229,12 @@ $.extend(BasicView.prototype, { }, + // Retrieves all segment objects that are rendered in the view + getSegs: function() { + return this.dayGrid.getSegs(); + }, + + // 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 diff --git a/src/basic/MonthView.js b/src/basic/MonthView.js index ca0a75d..47a0cb6 100644 --- a/src/basic/MonthView.js +++ b/src/basic/MonthView.js @@ -6,7 +6,7 @@ setDefaults({ fixedWeekCount: true, eventLimit: false, eventLimitText: 'more', - eventLimitClick: 'auto' + eventLimitClick: 'popover' }); fcViews.month = MonthView; // register the view @@ -68,6 +68,8 @@ $.extend(MonthView.prototype, { height *= this.rowCnt / 6; } + this.dayGrid.destroySegPopover(); // kill the "more" popover if displayed + // is the event limit a constant level number? if (eventLimit && typeof eventLimit === 'number') { this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after diff --git a/src/basic/basic.css b/src/basic/basic.css index 1c9b84d..d78e73d 100644 --- a/src/basic/basic.css +++ b/src/basic/basic.css @@ -75,3 +75,18 @@ a.fc-more:hover { .fc-limited { /* rows and cells that are hidden because of a "more" link */ display: none; } + +/* popover that appears when "more" link is clicked */ + +.fc-day-grid .fc-row { + z-index: 1; /* make the "more" popover one higher than this */ +} + +.fc-more-popover { + z-index: 2; + width: 220px; +} + +.fc-more-popover .fc-event-container { + padding: 10px; +} diff --git a/src/common/DayGrid.events.js b/src/common/DayGrid.events.js index 4908e4f..bb45de6 100644 --- a/src/common/DayGrid.events.js +++ b/src/common/DayGrid.events.js @@ -4,24 +4,32 @@ $.extend(DayGrid.prototype, { - + segs: null, rowStructs: null, // an array of objects, each holding information about a row's event-rendering // Render the given events onto the Grid and return the rendered segments renderEvents: function(events) { var rowStructs = this.rowStructs = this.renderEventRows(events); - var allSegs = []; + var segs = []; // append to each row's content skeleton this.rowEls.each(function(i, rowNode) { $(rowNode).find('.fc-content-skeleton > table').append( rowStructs[i].tbodyEl ); - allSegs.push.apply(allSegs, rowStructs[i].segs); + segs.push.apply(segs, rowStructs[i].segs); }); - return allSegs; // return segment objects. for the view + this.segs = segs; + }, + + + // Retrieves all segment objects that have been rendered + getSegs: function() { + return (this.segs || []).concat( + this.popoverSegs || [] // segs rendered in the "more" events popover + ); }, @@ -33,38 +41,27 @@ $.extend(DayGrid.prototype, { while ((rowStruct = rowStructs.pop())) { rowStruct.tbodyEl.remove(); } + + this.segs = null; + this.destroySegPopover(); // removes the "more.." events popover }, // Uses the given events array to generate elements that should be appended to each row's content skeleton. // Returns an array of rowStruct objects (see the bottom of `renderEventRow`). renderEventRows: function(events) { - var view = this.view; - var allSegs = this.eventsToSegs(events); - var segRows = this.groupSegRows(allSegs); // group into nested arrays - var html = ''; + var segs = this.eventsToSegs(events); var rowStructs = []; - var i; + var segRows; var row; - var rowSegs; - // 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)); - }); + segs = this.renderSegs(segs); // returns a new array with only visible segments + segRows = this.groupSegRows(segs); // group into nested arrays // iterate each row of segment groupings for (row = 0; row < segRows.length; row++) { - rowSegs = segRows[row]; - rowSegs = $.grep(rowSegs, renderedSegFilter); // filter out non-rendered segments rowStructs.push( - this.renderEventRow(row, rowSegs) + this.renderEventRow(row, segRows[row]) ); } @@ -73,12 +70,12 @@ $.extend(DayGrid.prototype, { // Builds the HTML to be used for the default element for an individual segment - renderSegHtml: function(seg) { + renderSegHtml: function(seg, disableResizing) { 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 isResizable = !disableResizing && event.allDay && seg.isEnd && view.isEventResizable(event); var classes = this.getSegClasses(seg, isDraggable, isResizable); var skinCss = this.getEventSkinCss(event); var timeHtml = ''; diff --git a/src/common/DayGrid.js b/src/common/DayGrid.js index 7605b85..dd29987 100644 --- a/src/common/DayGrid.js +++ b/src/common/DayGrid.js @@ -46,6 +46,11 @@ $.extend(DayGrid.prototype, { }, + destroy: function() { + this.destroySegPopover(); + }, + + // Generates the HTML for a single row. `row` is the row number. dayRowHtml: function(row, isRigid) { var view = this.view; diff --git a/src/common/DayGrid.limit.js b/src/common/DayGrid.limit.js index f4f0cd3..c736598 100644 --- a/src/common/DayGrid.limit.js +++ b/src/common/DayGrid.limit.js @@ -5,6 +5,17 @@ $.extend(DayGrid.prototype, { + segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible + popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible + + + destroySegPopover: function() { + if (this.segPopover) { + this.segPopover.hide(); // will trigger destruction of `segPopover` and `popoverSegs` + } + }, + + // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid. // `levelLimit` can be false (don't limit), a number, or true (should be computed). limitRows: function(levelLimit) { @@ -73,7 +84,7 @@ $.extend(DayGrid.prototype, { var td, rowspan; var segMoreNodes; // array of "more" cells that will stand-in for the current seg's cell var j; - var moreTd, moreLink; + var moreTd, moreWrap, moreLink; // Iterates through empty level cells and places "more" links inside if need be function emptyCellsUntil(endCol) { // goes from current `col` to `endCol` @@ -83,8 +94,9 @@ $.extend(DayGrid.prototype, { if (segsBelow.length) { td = cellMatrix[levelLimit - 1][col]; moreLink = _this.renderMoreLink(cell, segsBelow); - td.append(moreLink); - moreNodes.push(moreLink[0]); + moreWrap = $('
').append(moreLink); + td.append(moreWrap); + moreNodes.push(moreWrap[0]); } col++; } @@ -124,7 +136,8 @@ $.extend(DayGrid.prototype, { segsBelow = colSegsBelow[j]; cell = { row: row, col: seg.leftCol + j }; moreLink = this.renderMoreLink(cell, [ seg ].concat(segsBelow)); // count seg as hidden too - moreTd.append(moreLink); + moreWrap = $('
').append(moreLink); + moreTd.append(moreWrap); segMoreNodes.push(moreTd[0]); moreNodes.push(moreTd[0]); } @@ -168,27 +181,116 @@ $.extend(DayGrid.prototype, { .text( this.getMoreLinkText(hiddenSegs.length) ) - .on('click', function() { - var date = view.cellToDate(cell); + .on('click', function(ev) { var clickOption = view.opt('eventLimitClick'); + var date = view.cellToDate(cell); + var moreEl = $(this); + var dayEl = _this.getCellDayEl(cell); + var allSegs = _this.getCellSegs(cell); - if (typeof clickOption === 'string') { // a view name or 'auto' - view.calendar.zoomToDay(date, clickOption); + // rescope the segments to be within the cell's date + var reslicedAllSegs = _this.resliceDaySegs(allSegs, date); + var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date); + + if (clickOption === 'popover') { + _this.showSegPopover(date, cell, moreEl, reslicedAllSegs); } - else { - view.trigger( - 'eventLimitClick', - null, - date, - _this.getCellDayEl(cell), // element for the cell's day - hiddenSegs, - _this.getCellSegs(cell) // all segments on that day - ); + else if (typeof clickOption === 'string') { // a view name + view.calendar.zoomTo(date, clickOption); + } + else if (typeof clickOption === 'function') { + view.trigger('eventLimitClick', null, { + date: date, + dayEl: dayEl, + moreEl: moreEl, + segs: reslicedAllSegs, + hiddenSegs: reslicedHiddenSegs + }, ev); } }); }, + // Reveals the popover that displays all events within a cell + showSegPopover: function(date, cell, moreLink, segs) { + var _this = this; + var view = this.view; + var moreWrap = moreLink.parent(); // the
wrapper around the + var options = { + className: 'fc-more-popover', + content: this.renderSegPopoverContent(date, segs), + parentEl: this.el, + top: this.rowEls.eq(cell.row).offset().top, // better than the . no border confusion + autoHide: true, // when the user clicks elsewhere, hide the popover + hide: function() { + // destroy everything when the popover is hidden + _this.segPopover.destroy(); + _this.segPopover = null; + _this.popoverSegs = null; + } + }; + + // Determine horizontal coordinate. + // We use the moreWrap instead of the to avoid border confusion. + if (view.opt('isRTL')) { + options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border + } + else { + options.left = moreWrap.offset().left - 1; // -1 to be over cell border + } + + this.segPopover = new Popover(options); + this.segPopover.show(); + }, + + + // Builds the inner DOM contents of the segment popover + renderSegPopoverContent: function(date, segs) { + var view = this.view; + var isTheme = view.opt('theme'); + var title = date.format('LL'); // TODO: make this an option somehow + var content = $( + '
' + + '' + + '' + + htmlEscape(title) + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + ); + var segContainer = content.find('.fc-event-container'); + var i; + + // render each seg's `el` and only return the visible segs + segs = this.renderSegs(segs, true); // disableResizing=true + this.popoverSegs = segs; + + for (i = 0; i < segs.length; i++) { + segs[i].isDetached = true; // signals the segment doesn't live in a cell. needed for event DnD + segContainer.append(segs[i].el); + } + + return content; + }, + + + // Given the events within an array of segment objects, reslice them to be in a single day + resliceDaySegs: function(segs, dayDate) { + var events = $.map(segs, function(seg) { + return seg.event; + }); + var dayStart = dayDate.clone().stripTime(); + var dayEnd = dayStart.clone().add('days', 1); + + return this.eventsToSegs(events, dayStart, dayEnd); + }, + + // Generates the text that should be inside a "more" link, given the number of events it represents getMoreLinkText: function(num) { var view = this.view; diff --git a/src/common/Grid.events.js b/src/common/Grid.events.js index c29665e..1a92577 100644 --- a/src/common/Grid.events.js +++ b/src/common/Grid.events.js @@ -4,6 +4,7 @@ $.extend(Grid.prototype, { + isMouseOverSeg: false, // is the user's mouse over a segment? isDraggingSeg: false, // is a segment being dragged? isResizingSeg: false, // is a segment being resized? @@ -14,29 +15,79 @@ $.extend(Grid.prototype, { }, + // Retrieves all rendered segment objects in this grid + getSegs: function() { + // subclasses must implement + }, + + // Unrenders all events destroyEvents: function() { // subclasses must implement }, + // Renders a `el` property for each seg, and only returns segments that successfully rendered + renderSegs: function(segs, disableResizing) { + var view = this.view; + var html = ''; + var renderedSegs = []; + var i; + + // build a large concatenation of event segment HTML + for (i = 0; i < segs.length; i++) { + html += this.renderSegHtml(segs[i], disableResizing); + } + + // 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) { + var seg = segs[i]; + var el = view.resolveEventEl(seg.event, $(node)); + if (el) { + el.data('fc-seg', seg); // used by handlers + seg.el = el; + renderedSegs.push(seg); + } + }); + + return renderedSegs; + }, + + + // Generates the HTML for the default rendering of a segment + renderSegHtml: function(seg, disableResizing) { + // subclasses must implement + }, + + // Converts an array of event objects into an array of segment objects - eventsToSegs: function(events) { + eventsToSegs: function(events, intervalStart, intervalEnd) { var _this = this; return $.map(events, function(event) { - return _this.eventToSegs(event); // $.map flattens all returned arrays together + return _this.eventToSegs(event, intervalStart, intervalEnd); // $.map flattens all returned arrays together }); }, - // Slices a single event into an array of event segments - eventToSegs: function(event) { + // Slices a single event into an array of event segments. + // When `intervalStart` and `intervalEnd` are specified, intersect the events with that interval. + // Otherwise, let the subclass decide how it wants to slice the segments over the grid. + eventToSegs: function(event, intervalStart, intervalEnd) { 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 segs; var i, seg; + if (intervalStart && intervalEnd) { + seg = intersectionToSeg(eventStart, eventEnd, intervalStart, intervalEnd); + segs = seg ? [ seg ] : []; + } + else { + segs = this.rangeToSegs(eventStart, eventEnd); // defined by the subclass + } + // assign extra event-related properties to the segment objects for (i = 0; i < segs.length; i++) { seg = segs[i]; @@ -49,6 +100,10 @@ $.extend(Grid.prototype, { }, + /* Handlers + ------------------------------------------------------------------------------------------------------------------*/ + + // Attaches event-element-related handlers to the container element and leverage bubbling bindSegHandlers: function() { var _this = this; @@ -57,10 +112,10 @@ $.extend(Grid.prototype, { $.each( { mouseenter: function(seg, ev) { - view.trigger('eventMouseover', this, seg.event, ev); + _this.triggerSegMouseover(seg, ev); }, mouseleave: function(seg, ev) { - view.trigger('eventMouseout', this, seg.event, ev); + _this.triggerSegMouseout(seg, ev); }, click: function(seg, ev) { return view.trigger('eventClick', this, seg.event, ev); // can return `false` to cancel @@ -76,11 +131,11 @@ $.extend(Grid.prototype, { }, 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) { + _this.el.on(name, '.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 + // only call the handlers if there is not a drag/resize in progress + if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) { func.call(this, seg, ev); // `this` will be the event element } }); @@ -89,6 +144,28 @@ $.extend(Grid.prototype, { }, + // Updates internal state and triggers handlers for when an event element is moused over + triggerSegMouseover: function(seg, ev) { + if (!this.isMouseOverSeg) { + this.isMouseOverSeg = true; + this.view.trigger('eventMouseover', seg.el[0], seg.event, ev); + } + }, + + + // Updates internal state and triggers handlers for when an event element is moused out + triggerSegMouseout: function(seg, ev) { + if (this.isMouseOverSeg) { + this.isMouseOverSeg = false; + this.view.trigger('eventMouseout', seg.el[0], seg.event, ev); + } + }, + + + /* Dragging + ------------------------------------------------------------------------------------------------------------------*/ + + // 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) { @@ -96,9 +173,7 @@ $.extend(Grid.prototype, { 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; + var newStart, newEnd; // A clone of the original element that will move with the mouse var mouseFollower = new MouseFollower(seg.el, { @@ -117,39 +192,21 @@ $.extend(Grid.prototype, { mouseFollower.start(ev); }, dragStart: function(ev) { + _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported _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; + var res = _this.computeDraggedEventDates(seg, dragListener.origDate, date); + newStart = res.start; + newEnd = res.end; - 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(); - } + 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 @@ -158,14 +215,13 @@ $.extend(Grid.prototype, { mouseFollower.show(); // show in case we are moving out of all cells }, dragStop: function(ev) { - var hasChanged = newStart && !newStart.isSame(start); + var hasChanged = newStart && !newStart.isSame(event.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) { @@ -182,6 +238,60 @@ $.extend(Grid.prototype, { }, + // Given a segment, where it originally resided on the grid, and the new date it has been dragged to, + // calculates the Event Object's new start and end dates. + computeDraggedEventDates: function(seg, origDate, newDate) { + var view = this.view; + var event = seg.event; + var start = event.start; + var end = view.calendar.getEventEnd(event); + var delta; + var newStart; + var newEnd; + + // the segment might be explicitly marked as not-in-the-grid + if (seg.isDetached) { + origDate = null; + } + + // calculate the delta (a Duration) that the event's dates must be moved. + // if `delta` remains undefined, that means the event's start will literally become newDate. + if (!origDate) { + if (newDate.hasTime()) { // over a time slot + delta = dayishDiff(newDate, start); // will move the start to the exact new datetime + } + else { // over a whole-day cell + delta = dayDiff(newDate, start); // will be a whole-day diff, so that start's time will be kept + } + } + else if (newDate.hasTime() === origDate.hasTime()) { // staying all-day or staying timed + delta = dayishDiff(newDate, origDate); + } + // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared + + // recalculate start/end + if (delta) { + 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 { + newStart = newDate; + newEnd = null; // end should be cleared + } + + return { start: newStart, end: newEnd }; + }, + + + /* Resizing + ------------------------------------------------------------------------------------------------------------------*/ + + // 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) { @@ -203,8 +313,8 @@ $.extend(Grid.prototype, { dragListener = new DragListener(this.coordMap, { distance: 5, dragStart: function(ev) { + _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported _this.isResizingSeg = true; - view.trigger('eventResizeStart', el[0], event, ev, {}); // last argument is jqui dummy }, cellOver: function(cell, date) { @@ -230,7 +340,6 @@ $.extend(Grid.prototype, { dragStop: function(ev) { _this.isResizingSeg = false; destroy(); - view.trigger('eventResizeStop', el[0], event, ev, {}); // last argument is jqui dummy if (newEnd) { @@ -243,6 +352,10 @@ $.extend(Grid.prototype, { }, + /* Rendering Utils + ------------------------------------------------------------------------------------------------------------------*/ + + // Generic utility for generating the HTML classNames for an event segment's element getSegClasses: function(seg, isDraggable, isResizable) { var event = seg.event; @@ -319,9 +432,3 @@ function compareSegs(seg1, seg2) { (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 index ae04fc7..3858f9a 100644 --- a/src/common/Grid.js +++ b/src/common/Grid.js @@ -23,6 +23,12 @@ $.extend(Grid.prototype, { }, + // Called when the grid's resources need to be cleaned up + destroy: function() { + // subclasses can implement + }, + + /* Coordinates & Cells ------------------------------------------------------------------------------------------------------------------*/ @@ -62,7 +68,10 @@ $.extend(Grid.prototype, { var _this = this; this.el.on('mousedown', function(ev) { - if (!$(ev.target).is('.fc-event-container *, .fc-more')) { // not an event element or "more" link + if ( + !$(ev.target).is('.fc-event-container *, .fc-more') && // not an an event element, or "more.." link + !$(ev.target).closest('.fc-popover').length // not on a popover (like the "more.." events one) + ) { _this.dayMousedown(ev); } }); diff --git a/src/common/Popover.js b/src/common/Popover.js new file mode 100644 index 0000000..d7bcb43 --- /dev/null +++ b/src/common/Popover.js @@ -0,0 +1,165 @@ + +/* A rectangular panel that is absolutely positioned over other content +------------------------------------------------------------------------------------------------------------------------ +Options: + - className (string) + - content (HTML string or jQuery element set) + - parentEl + - top + - left + - right (the x coord of where the right edge should be. not a "CSS" right) + - autoHide (boolean) + - show (callback) + - hide (callback) +*/ + +function Popover(options) { + this.options = options || {}; +} + + +Popover.prototype = { + + isHidden: true, + options: null, + el: null, // the container element for the popover. generated by this object + documentMousedownProxy: null, // document mousedown handler bound to `this` + margin: 10, // the space required between the popover and the edges of the scroll container + + + // Shows the popover on the specified position. Renders it if not already + show: function() { + if (this.isHidden) { + if (!this.el) { + this.render(); + } + this.el.show(); + this.position(); + this.isHidden = false; + this.trigger('show'); + } + }, + + + // Hides the popover, through CSS, but does not remove it from the DOM + hide: function() { + if (!this.isHidden) { + this.el.hide(); + this.isHidden = true; + this.trigger('hide'); + } + }, + + + // Creates `this.el` and renders content inside of it + render: function() { + var _this = this; + var options = this.options; + + this.el = $('
') + .addClass(options.className || '') + .css({ + // position initially to the top left to avoid creating scrollbars + top: 0, + left: 0 + }) + .append(options.content) + .appendTo(options.parentEl); + + // when a click happens on anything inside with a 'fc-close' className, hide the popover + this.el.on('click', '.fc-close', function() { + _this.hide(); + }); + + if (options.autoHide) { + $(document).on('mousedown', this.documentMousedownProxy = $.proxy(this, 'documentMousedown')); + } + }, + + + // Triggered when the user clicks *anywhere* in the document, for the autoHide feature + documentMousedown: function(ev) { + // only hide the popover if the click happened outside the popover + if (this.el && !$(ev.target).closest(this.el).length) { + this.hide(); + } + }, + + + // Hides and unregisters any handlers + destroy: function() { + this.hide(); + + if (this.el) { + this.el.remove(); + this.el = null; + } + + $(document).off('mousedown', this.documentMousedownProxy); + }, + + + // Positions the popover optimally, using the top/left/right options + position: function() { + var options = this.options; + var origin = this.el.offsetParent().offset(); + var width = this.el.outerWidth(); + var height = this.el.outerHeight(); + var windowEl = $(window); + var viewportEl = getScrollParent(this.el); + var viewportTop; + var viewportLeft; + var viewportOffset; + var top; // the "position" (not "offset") values for the popover + var left; // + + // compute top and left + top = options.top || 0; + if (options.left !== undefined) { + left = options.left; + } + else if (options.right !== undefined) { + left = options.right - width; // derive the left value from the right value + } + else { + left = 0; + } + + if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result + viewportEl = windowEl; + viewportTop = 0; // the window is always at the top left + viewportLeft = 0; // (and .offset() won't work if called here) + } + else { + viewportOffset = viewportEl.offset(); + viewportTop = viewportOffset.top; + viewportLeft = viewportOffset.left; + } + + // if the window is scrolled, it causes the visible area to be further down + viewportTop += windowEl.scrollTop(); + viewportLeft += windowEl.scrollLeft(); + + // constrain to the view port. if constrained by two edges, give precedence to top/left + top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin); + top = Math.max(top, viewportTop + this.margin); + left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin); + left = Math.max(left, viewportLeft + this.margin); + + this.el.css({ + top: top - origin.top, + left: left - origin.left + }); + }, + + + // Triggers a callback. Calls a function in the option hash of the same name. + // Arguments beyond the first `name` are forwarded on. + // TODO: better code reuse for this. Repeat code + trigger: function(name) { + if (this.options[name]) { + this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); + } + } + +}; diff --git a/src/common/TimeGrid.events.js b/src/common/TimeGrid.events.js index 85c2e21..56fc892 100644 --- a/src/common/TimeGrid.events.js +++ b/src/common/TimeGrid.events.js @@ -4,6 +4,7 @@ $.extend(TimeGrid.prototype, { + segs: null, // segment objects rendered in the component. null of events haven't been rendered yet eventSkeletonEl: null, // has cells with event-containers, which contain absolutely positioned event elements @@ -14,7 +15,13 @@ $.extend(TimeGrid.prototype, { this.eventSkeletonEl = $('
').append(res.tableEl); this.el.append(this.eventSkeletonEl); - return res.segs; // return segment objects. for the view + this.segs = res.segs; + }, + + + // Retrieves rendered segment objects + getSegs: function() { + return this.segs || []; }, @@ -24,49 +31,41 @@ $.extend(TimeGrid.prototype, { this.eventSkeletonEl.remove(); this.eventSkeletonEl = null; } + + this.segs = null; }, // Renders and returns the 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 segs = this.eventsToSegs(events); + var segCols; var i, seg; - var col, segs; + var col, colSegs; 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); + segs = this.renderSegs(segs); // returns only the visible segs + segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg + // compute vertical coordinates + for (i = 0; i < segs.length; i++) { + seg = segs[i]; seg.top = this.computeDateTop(seg.start, seg.start); seg.bottom = this.computeDateTop(seg.end, seg.start); } - // 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 + colSegs = segCols[col]; + placeSlotSegs(colSegs); // compute horizontal coordinates, z-index's, and reorder the array containerEl = $('
'); // assign positioning CSS and insert into container - for (i = 0; i < segs.length; i++) { - seg = segs[i]; + for (i = 0; i < colSegs.length; i++) { + seg = colSegs[i]; seg.el.css(this.generateSegPositionCss(seg)); containerEl.append(seg.el); } @@ -78,17 +77,17 @@ $.extend(TimeGrid.prototype, { return { tableEl: tableEl, - segs: flattenArray(segCols) // will contain only segments with rendered els + segs: segs }; }, // Renders the HTML for a single event segment's default rendering - renderSegHtml: function(seg) { + renderSegHtml: function(seg, disableResizing) { var view = this.view; var event = seg.event; var isDraggable = view.isEventDraggable(event); - var isResizable = seg.isEnd && view.isEventResizable(event); + var isResizable = !disableResizing && seg.isEnd && view.isEventResizable(event); var classes = this.getSegClasses(seg, isDraggable, isResizable); var skinCss = this.getEventSkinCss(event); var timeText; diff --git a/src/common/TimeGrid.js b/src/common/TimeGrid.js index f54cc60..53104ad 100644 --- a/src/common/TimeGrid.js +++ b/src/common/TimeGrid.js @@ -124,51 +124,26 @@ $.extend(TimeGrid.prototype, { // Slices up a date range into a segment for each column - rangeToSegs: function(start, end) { + rangeToSegs: function(rangeStart, rangeEnd) { var view = this.view; var segs = []; + var seg; var col; var cellDate; var colStart, colEnd; - var segStart, segEnd; - var isStart, isEnd; // normalize - start = start.clone().stripZone(); - end = end.clone().stripZone(); + rangeStart = rangeStart.clone().stripZone(); + rangeEnd = rangeEnd.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 - }); + colStart = cellDate.clone().time(this.minTime); + colEnd = cellDate.clone().time(this.maxTime); + seg = intersectionToSeg(rangeStart, rangeEnd, colStart, colEnd); + if (seg) { + seg.col = col; + segs.push(seg); } } diff --git a/src/common/View.js b/src/common/View.js index 9c69412..fc60f15 100644 --- a/src/common/View.js +++ b/src/common/View.js @@ -19,8 +19,6 @@ View.prototype = { 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 @@ -124,7 +122,9 @@ View.prototype = { // 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(); + if (this.scrollerEl) { + this.scrollTop = this.scrollerEl.scrollTop(); + } }, @@ -143,10 +143,9 @@ View.prototype = { // Renders the events onto the view. - // Should be overriden by subclasses. Subclasses should assign `this.segs` and call the super-method afterwards. + // Should be overriden by subclasses. Subclasses should 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'); @@ -159,7 +158,6 @@ View.prototype = { this.segEach(function(seg) { this.trigger('eventDestroy', seg.event, seg.event, seg.el); }); - this.segs = []; }, @@ -199,7 +197,7 @@ View.prototype = { // 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 segs = this.getSegs(); var i; for (i = 0; i < segs.length; i++) { @@ -210,6 +208,12 @@ View.prototype = { }, + // Retrieves all the rendered segment objects for the view + getSegs: function() { + // subclasses must implement + }, + + /* Event Drag Visualization ------------------------------------------------------------------------------------------------------------------*/ diff --git a/src/common/common.css b/src/common/common.css index 0a859a4..71e595b 100644 --- a/src/common/common.css +++ b/src/common/common.css @@ -27,14 +27,24 @@ body .fc { /* extra precedence to overcome jqui */ .fc-unthemed hr, .fc-unthemed thead, .fc-unthemed tbody, -.fc-unthemed .fc-row { +.fc-unthemed .fc-row, +.fc-unthemed .fc-popover { border-color: #ddd; } -.fc-unthemed hr { +.fc-unthemed .fc-popover { + background-color: #fff; +} + +.fc-unthemed hr, +.fc-unthemed .fc-popover .fc-header { background: #eee; } +.fc-unthemed .fc-popover .fc-header .fc-close { + color: #666; +} + .fc-unthemed .fc-today { background: #fcf8e3; } @@ -75,6 +85,10 @@ body .fc { /* extra precedence to overcome jqui */ content: "\000BB"; } +.fc-icon-x:after { + content: "\000D7"; +} + /* Buttons (styled