popover when user clicks on "more" link

This commit is contained in:
Adam Shaw
2014-08-02 16:27:52 -07:00
parent 2053f4dc21
commit bba2d6fcad
18 changed files with 736 additions and 208 deletions
+1
View File
@@ -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",
+19 -12
View File
@@ -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;
+19 -3
View File
@@ -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
},
+6 -9
View File
@@ -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 */
}
+14 -1
View File
@@ -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
+3 -1
View File
@@ -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
+15
View File
@@ -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;
}
+22 -25
View File
@@ -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 <tbody> 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 = '';
+5
View File
@@ -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;
+119 -17
View File
@@ -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" <td> 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 = $('<div/>').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 = $('<div/>').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 <div> wrapper around the <a>
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 <td>. 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 <td> 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 = $(
'<div class="fc-header ' + view.widgetHeaderClass + '">' +
'<span class="fc-close ' +
(isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') +
'"></span>' +
'<span class="fc-title">' +
htmlEscape(title) +
'</span>' +
'<div class="fc-clear"/>' +
'</div>' +
'<div class="fc-body ' + view.widgetContentClass + '">' +
'<div class="fc-event-container"></div>' +
'</div>'
);
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;
+157 -50
View File
@@ -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;
}
+10 -1
View File
@@ -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);
}
});
+165
View File
@@ -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 = $('<div class="fc-popover"/>')
.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));
}
}
};
+25 -26
View File
@@ -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 = $('<div class="fc-content-skeleton"/>').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 <table> portion of the event-skeleton.
// Returns an object with properties 'tbodyEl' and 'segs'.
renderEventTable: function(events) {
var view = this.view;
var tableEl = $('<table><tr/></table>');
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 = $('<div class="fc-event-container"/>');
// 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;
+10 -35
View File
@@ -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);
}
}
+11 -7
View File
@@ -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
------------------------------------------------------------------------------------------------------------------*/
+74 -14
View File
@@ -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 <button> tags, normalized to work cross-browser)
--------------------------------------------------------------------------------------------------*/
@@ -197,6 +211,55 @@ previous button's border...
}
/* Popover
--------------------------------------------------------------------------------------------------*/
.fc-popover {
position: absolute;
box-shadow: 0 2px 6px rgba(0,0,0,.15);
}
.fc-popover .fc-header {
padding: 2px 4px;
}
.fc-popover .fc-header .fc-title {
margin: 0 2px;
}
.fc-popover .fc-header .fc-close {
cursor: pointer;
}
.fc-ltr .fc-popover .fc-header .fc-title,
.fc-rtl .fc-popover .fc-header .fc-close {
float: left;
}
.fc-rtl .fc-popover .fc-header .fc-title,
.fc-ltr .fc-popover .fc-header .fc-close {
float: right;
}
/* unthemed */
.fc-unthemed .fc-popover {
border-width: 1px;
border-style: solid;
}
.fc-unthemed .fc-popover .fc-header .fc-close {
font-size: 25px;
margin-top: 4px;
}
/* jqui themed */
.fc-popover > .ui-widget-header + .ui-widget-content {
border-top: 0; /* where they meet, let the header have the border */
}
/* Misc Reusable Components
--------------------------------------------------------------------------------------------------*/
@@ -383,16 +446,19 @@ temporary rendered events).
/* DayGrid events
--------------------------------------------------------------------------------------------------*/
----------------------------------------------------------------------------------------------------
We use the full "fc-day-grid-event" class instead of using descendants because the event won't
be a descendant of the grid when it is being dragged.
*/
.fc-day-grid .fc-event {
.fc-day-grid-event {
margin: 1px 1px 0; /* spacing between events and edges */
}
/* events that are continuing to/from another week. kill rounded corners and butt up against edge */
.fc-ltr .fc-day-grid .fc-event.fc-not-start,
.fc-rtl .fc-day-grid .fc-event.fc-not-end {
.fc-ltr .fc-day-grid-event.fc-not-start,
.fc-rtl .fc-day-grid-event.fc-not-end {
margin-left: 0;
border-left-width: 0;
padding-left: 1px; /* replace the border with padding */
@@ -400,8 +466,8 @@ temporary rendered events).
border-bottom-left-radius: 0;
}
.fc-ltr .fc-day-grid .fc-event.fc-not-end,
.fc-rtl .fc-day-grid .fc-event.fc-not-start {
.fc-ltr .fc-day-grid-event.fc-not-end,
.fc-rtl .fc-day-grid-event.fc-not-start {
margin-right: 0;
border-right-width: 0;
padding-right: 1px; /* replace the border with padding */
@@ -409,12 +475,6 @@ temporary rendered events).
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-day-grid-event".
The below styles WILL be applied to dragged events.
*/
.fc-day-grid-event > .fc-content { /* force events to be one-line tall */
white-space: nowrap;
overflow: hidden;
+61 -7
View File
@@ -116,6 +116,20 @@ function matchCellWidths(els) {
----------------------------------------------------------------------------------------------------------------------*/
// borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51
function getScrollParent(el) {
var position = el.css('position'),
scrollParent = el.parents().filter(function() {
var parent = $(this);
return (/(auto|scroll)/).test(
parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x')
);
}).eq(0);
return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent;
}
// Given a container element, return an object with the pixel values of the left/right scrollbars.
// Left scrollbars might occur on RTL browsers (IE maybe?) but I have not tested.
// PREREQUISITE: container element must have a single child with display:block
@@ -143,6 +157,42 @@ function isPrimaryMouseButton(ev) {
----------------------------------------------------------------------------------------------------------------------*/
// Creates a basic segment with the intersection of the two ranges. Returns undefined if no intersection.
// Expects all dates to be normalized to the same timezone beforehand.
function intersectionToSeg(subjectStart, subjectEnd, intervalStart, intervalEnd) {
var segStart, segEnd;
var isStart, isEnd;
if (subjectEnd > intervalStart && subjectStart < intervalEnd) { // in bounds at all?
if (subjectStart >= intervalStart) {
segStart = subjectStart.clone();
isStart = true;
}
else {
segStart = intervalStart.clone();
isStart = false;
}
if (subjectEnd <= intervalEnd) {
segEnd = subjectEnd.clone();
isEnd = true;
}
else {
segEnd = intervalEnd.clone();
isEnd = false;
}
return {
start: segStart,
end: segEnd,
isStart: isStart,
isEnd: isEnd
};
}
}
function smartProperty(obj, name) { // get a camel-cased/namespaced property of an object
obj = obj || {};
if (obj[name] !== undefined) {
@@ -163,11 +213,20 @@ function smartProperty(obj, name) { // get a camel-cased/namespaced property of
/* Date Utilities
----------------------------------------------------------------------------------------------------------------------*/
var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ];
// diffs the two moments into a Duration where full-days are recorded first, then the remaining time.
// Diffs the two moments into a Duration where only full-days are considered.
// Moments will have their timezones normalized.
function dayDiff(a, b) {
return moment.duration({
days: a.clone().stripTime().diff(b.clone().stripTime(), 'days')
});
}
// Diffs the two moments into a Duration where full-days are recorded first, then the remaining time.
// Moments will have their timezones normalized.
function dayishDiff(a, b) {
return moment.duration({
days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'),
@@ -211,11 +270,6 @@ function extend(a, b) {
}
function flattenArray(a) { // flatten an array of arrays, one level deep
return Array.prototype.concat.apply([], a);
}
function applyAll(functions, thisObj, args) {
if ($.isFunction(functions)) {
functions = [ functions ];