diff options
Diffstat (limited to 'library/fullcalendar/fullcalendar.js')
-rw-r--r-- | library/fullcalendar/fullcalendar.js | 1722 |
1 files changed, 1249 insertions, 473 deletions
diff --git a/library/fullcalendar/fullcalendar.js b/library/fullcalendar/fullcalendar.js index d6042cc17..cbe67697d 100644 --- a/library/fullcalendar/fullcalendar.js +++ b/library/fullcalendar/fullcalendar.js @@ -1,7 +1,7 @@ /*! - * FullCalendar v2.6.1 + * FullCalendar v2.7.3 * Docs & License: http://fullcalendar.io/ - * (c) 2015 Adam Shaw + * (c) 2016 Adam Shaw */ (function(factory) { @@ -19,8 +19,8 @@ ;; var FC = $.fullCalendar = { - version: "2.6.1", - internalApiVersion: 3 + version: "2.7.3", + internalApiVersion: 4 }; var fcViews = FC.views = {}; @@ -262,29 +262,25 @@ function matchCellWidths(els) { } -// Turns a container element into a scroller if its contents is taller than the allotted height. -// Returns true if the element is now a scroller, false otherwise. -// NOTE: this method is best because it takes weird zooming dimensions into account -function setPotentialScroller(containerEl, height) { - containerEl.height(height).addClass('fc-scroller'); - - // are scrollbars needed? - if (containerEl[0].scrollHeight - 1 > containerEl[0].clientHeight) { // !!! -1 because IE is often off-by-one :( - return true; - } - - unsetScroller(containerEl); // undo - return false; -} +// Given one element that resides inside another, +// Subtracts the height of the inner element from the outer element. +function subtractInnerElHeight(outerEl, innerEl) { + var both = outerEl.add(innerEl); + var diff; + // effin' IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked + both.css({ + position: 'relative', // cause a reflow, which will force fresh dimension recalculation + left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll + }); + diff = outerEl.outerHeight() - innerEl.outerHeight(); // grab the dimensions + both.css({ position: '', left: '' }); // undo hack -// Takes an element that might have been a scroller, and turns it back into a normal element. -function unsetScroller(containerEl) { - containerEl.height('').removeClass('fc-scroller'); + return diff; } -/* General DOM Utilities +/* Element Geom Utilities ----------------------------------------------------------------------------------------------------------------------*/ FC.getOuterRect = getOuterRect; @@ -309,26 +305,30 @@ function getScrollParent(el) { // Queries the outer bounding area of a jQuery element. // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). -function getOuterRect(el) { +// Origin is optional. +function getOuterRect(el, origin) { var offset = el.offset(); + var left = offset.left - (origin ? origin.left : 0); + var top = offset.top - (origin ? origin.top : 0); return { - left: offset.left, - right: offset.left + el.outerWidth(), - top: offset.top, - bottom: offset.top + el.outerHeight() + left: left, + right: left + el.outerWidth(), + top: top, + bottom: top + el.outerHeight() }; } // Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding. // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). +// Origin is optional. // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. -function getClientRect(el) { +function getClientRect(el, origin) { var offset = el.offset(); var scrollbarWidths = getScrollbarWidths(el); - var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left; - var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top; + var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left - (origin ? origin.left : 0); + var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top - (origin ? origin.top : 0); return { left: left, @@ -341,10 +341,13 @@ function getClientRect(el) { // Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars. // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). -function getContentRect(el) { +// Origin is optional. +function getContentRect(el, origin) { var offset = el.offset(); // just outside of border, margin not included - var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left'); - var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top'); + var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left') - + (origin ? origin.left : 0); + var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top') - + (origin ? origin.top : 0); return { left: left, @@ -414,13 +417,82 @@ function getCssFloat(el, prop) { } +/* Mouse / Touch Utilities +----------------------------------------------------------------------------------------------------------------------*/ + +FC.preventDefault = preventDefault; + + // Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac) function isPrimaryMouseButton(ev) { return ev.which == 1 && !ev.ctrlKey; } -/* Geometry +function getEvX(ev) { + if (ev.pageX !== undefined) { + return ev.pageX; + } + var touches = ev.originalEvent.touches; + if (touches) { + return touches[0].pageX; + } +} + + +function getEvY(ev) { + if (ev.pageY !== undefined) { + return ev.pageY; + } + var touches = ev.originalEvent.touches; + if (touches) { + return touches[0].pageY; + } +} + + +function getEvIsTouch(ev) { + return /^touch/.test(ev.type); +} + + +function preventSelection(el) { + el.addClass('fc-unselectable') + .on('selectstart', preventDefault); +} + + +// Stops a mouse/touch event from doing it's native browser action +function preventDefault(ev) { + ev.preventDefault(); +} + + +// attach a handler to get called when ANY scroll action happens on the page. +// this was impossible to do with normal on/off because 'scroll' doesn't bubble. +// http://stackoverflow.com/a/32954565/96342 +// returns `true` on success. +function bindAnyScroll(handler) { + if (window.addEventListener) { + window.addEventListener('scroll', handler, true); // useCapture=true + return true; + } + return false; +} + + +// undoes bindAnyScroll. must pass in the original function. +// returns `true` on success. +function unbindAnyScroll(handler) { + if (window.removeEventListener) { + window.removeEventListener('scroll', handler, true); // useCapture=true + return true; + } + return false; +} + + +/* General Geometry Utils ----------------------------------------------------------------------------------------------------------------------*/ FC.intersectRects = intersectRects; @@ -946,22 +1018,21 @@ function proxy(obj, methodName) { // Returns a function, that, as long as it continues to be invoked, will not // be triggered. The function will be called after it stops being called for -// N milliseconds. +// N milliseconds. If `immediate` is passed, trigger the function on the +// leading edge, instead of the trailing. // https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714 -function debounce(func, wait) { - var timeoutId; - var args; - var context; - var timestamp; // of most recent call +function debounce(func, wait, immediate) { + var timeout, args, context, timestamp, result; + var later = function() { var last = +new Date() - timestamp; - if (last < wait && last > 0) { - timeoutId = setTimeout(later, wait - last); + if (last < wait) { + timeout = setTimeout(later, wait - last); } else { - timeoutId = null; - func.apply(context, args); - if (!timeoutId) { + timeout = null; + if (!immediate) { + result = func.apply(context, args); context = args = null; } } @@ -971,9 +1042,15 @@ function debounce(func, wait) { context = this; args = arguments; timestamp = +new Date(); - if (!timeoutId) { - timeoutId = setTimeout(later, wait); + var callNow = immediate && !timeout; + if (!timeout) { + timeout = setTimeout(later, wait); + } + if (callNow) { + result = func.apply(context, args); + context = args = null; } + return result; }; } @@ -1777,61 +1854,162 @@ function extendClass(superClass, members) { function mixIntoClass(theClass, members) { - copyOwnProps(members.prototype || members, theClass.prototype); // TODO: copyNativeMethods? + copyOwnProps(members, theClass.prototype); // TODO: copyNativeMethods? } ;; -var Emitter = FC.Emitter = Class.extend({ +var EmitterMixin = FC.EmitterMixin = { + + // jQuery-ification via $(this) allows a non-DOM object to have + // the same event handling capabilities (including namespaces). + + + on: function(types, handler) { - callbackHash: null, + // handlers are always called with an "event" object as their first param. + // sneak the `this` context and arguments into the extra parameter object + // and forward them on to the original handler. + var intercept = function(ev, extra) { + return handler.apply( + extra.context || this, + extra.args || [] + ); + }; + + // mimick jQuery's internal "proxy" system (risky, I know) + // causing all functions with the same .guid to appear to be the same. + // https://github.com/jquery/jquery/blob/2.2.4/src/core.js#L448 + // this is needed for calling .off with the original non-intercept handler. + if (!handler.guid) { + handler.guid = $.guid++; + } + intercept.guid = handler.guid; + $(this).on(types, intercept); - on: function(name, callback) { - this.getCallbacks(name).add(callback); return this; // for chaining }, - off: function(name, callback) { - this.getCallbacks(name).remove(callback); + off: function(types, handler) { + $(this).off(types, handler); + return this; // for chaining }, - trigger: function(name) { // args... - var args = Array.prototype.slice.call(arguments, 1); + trigger: function(types) { + var args = Array.prototype.slice.call(arguments, 1); // arguments after the first - this.triggerWith(name, this, args); + // pass in "extra" info to the intercept + $(this).triggerHandler(types, { args: args }); return this; // for chaining }, - triggerWith: function(name, context, args) { - var callbacks = this.getCallbacks(name); + triggerWith: function(types, context, args) { - callbacks.fireWith(context, args); + // `triggerHandler` is less reliant on the DOM compared to `trigger`. + // pass in "extra" info to the intercept. + $(this).triggerHandler(types, { context: context, args: args }); return this; // for chaining - }, + } +}; - getCallbacks: function(name) { - var callbacks; +;; - if (!this.callbackHash) { - this.callbackHash = {}; - } +/* +Utility methods for easily listening to events on another object, +and more importantly, easily unlistening from them. +*/ +var ListenerMixin = FC.ListenerMixin = (function() { + var guid = 0; + var ListenerMixin = { + + listenerId: null, + + /* + Given an `other` object that has on/off methods, bind the given `callback` to an event by the given name. + The `callback` will be called with the `this` context of the object that .listenTo is being called on. + Can be called: + .listenTo(other, eventName, callback) + OR + .listenTo(other, { + eventName1: callback1, + eventName2: callback2 + }) + */ + listenTo: function(other, arg, callback) { + if (typeof arg === 'object') { // given dictionary of callbacks + for (var eventName in arg) { + if (arg.hasOwnProperty(eventName)) { + this.listenTo(other, eventName, arg[eventName]); + } + } + } + else if (typeof arg === 'string') { + other.on( + arg + '.' + this.getListenerNamespace(), // use event namespacing to identify this object + $.proxy(callback, this) // always use `this` context + // the usually-undesired jQuery guid behavior doesn't matter, + // because we always unbind via namespace + ); + } + }, + + /* + Causes the current object to stop listening to events on the `other` object. + `eventName` is optional. If omitted, will stop listening to ALL events on `other`. + */ + stopListeningTo: function(other, eventName) { + other.off((eventName || '') + '.' + this.getListenerNamespace()); + }, - callbacks = this.callbackHash[name]; - if (!callbacks) { - callbacks = this.callbackHash[name] = $.Callbacks(); + /* + Returns a string, unique to this object, to be used for event namespacing + */ + getListenerNamespace: function() { + if (this.listenerId == null) { + this.listenerId = guid++; + } + return '_listener' + this.listenerId; } - return callbacks; + }; + return ListenerMixin; +})(); +;; + +// simple class for toggle a `isIgnoringMouse` flag on delay +// initMouseIgnoring must first be called, with a millisecond delay setting. +var MouseIgnorerMixin = { + + isIgnoringMouse: false, // bool + delayUnignoreMouse: null, // method + + + initMouseIgnoring: function(delay) { + this.delayUnignoreMouse = debounce(proxy(this, 'unignoreMouse'), delay || 1000); + }, + + + // temporarily ignore mouse actions on segments + tempIgnoreMouse: function() { + this.isIgnoringMouse = true; + this.delayUnignoreMouse(); + }, + + + // delayUnignoreMouse eventually calls this + unignoreMouse: function() { + this.isIgnoringMouse = false; } -}); +}; + ;; /* A rectangular panel that is absolutely positioned over other content @@ -1848,12 +2026,11 @@ Options: - hide (callback) */ -var Popover = Class.extend({ +var Popover = Class.extend(ListenerMixin, { 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 @@ -1907,7 +2084,7 @@ var Popover = Class.extend({ }); if (options.autoHide) { - $(document).on('mousedown', this.documentMousedownProxy = proxy(this, 'documentMousedown')); + this.listenTo($(document), 'mousedown', this.documentMousedown); } }, @@ -1930,7 +2107,7 @@ var Popover = Class.extend({ this.el = null; } - $(document).off('mousedown', this.documentMousedownProxy); + this.stopListeningTo($(document), 'mousedown'); }, @@ -2243,257 +2420,421 @@ var CoordCache = FC.CoordCache = Class.extend({ ----------------------------------------------------------------------------------------------------------------------*/ // TODO: use Emitter -var DragListener = FC.DragListener = Class.extend({ +var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMixin, { options: null, - isListening: false, - isDragging: false, + // for IE8 bug-fighting behavior + subjectEl: null, + subjectHref: null, // coordinates of the initial mousedown originX: null, originY: null, - // handler attached to the document, bound to the DragListener's `this` - mousemoveProxy: null, - mouseupProxy: null, + // the wrapping element that scrolls, or MIGHT scroll if there's overflow. + // TODO: do this for wrappers that have overflow:hidden as well. + scrollEl: null, - // for IE8 bug-fighting behavior, for now - subjectEl: null, // the element being draged. optional - subjectHref: null, + isInteracting: false, + isDistanceSurpassed: false, + isDelayEnded: false, + isDragging: false, + isTouch: false, - scrollEl: null, - scrollBounds: null, // { top, bottom, left, right } - scrollTopVel: null, // pixels per second - scrollLeftVel: null, // pixels per second - scrollIntervalId: null, // ID of setTimeout for scrolling animation loop - scrollHandlerProxy: null, // this-scoped function for handling when scrollEl is scrolled + delay: null, + delayTimeoutId: null, + minDistance: null, - scrollSensitivity: 30, // pixels from edge for scrolling to start - scrollSpeed: 200, // pixels per second, at maximum speed - scrollIntervalMs: 50, // millisecond wait between scroll increment + handleTouchScrollProxy: null, // calls handleTouchScroll, always bound to `this` constructor: function(options) { - options = options || {}; - this.options = options; - this.subjectEl = options.subjectEl; + this.options = options || {}; + this.handleTouchScrollProxy = proxy(this, 'handleTouchScroll'); + this.initMouseIgnoring(500); }, - // Call this when the user does a mousedown. Will probably lead to startListening - mousedown: function(ev) { - if (isPrimaryMouseButton(ev)) { + // Interaction (high-level) + // ----------------------------------------------------------------------------------------------------------------- + + + startInteraction: function(ev, extraOptions) { + var isTouch = getEvIsTouch(ev); + + if (ev.type === 'mousedown') { + if (this.isIgnoringMouse) { + return; + } + else if (!isPrimaryMouseButton(ev)) { + return; + } + else { + ev.preventDefault(); // prevents native selection in most browsers + } + } + + if (!this.isInteracting) { + + // process options + extraOptions = extraOptions || {}; + this.delay = firstDefined(extraOptions.delay, this.options.delay, 0); + this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0); + this.subjectEl = this.options.subjectEl; + + this.isInteracting = true; + this.isTouch = isTouch; + this.isDelayEnded = false; + this.isDistanceSurpassed = false; - ev.preventDefault(); // prevents native selection in most browsers + this.originX = getEvX(ev); + this.originY = getEvY(ev); + this.scrollEl = getScrollParent($(ev.target)); - this.startListening(ev); + this.bindHandlers(); + this.initAutoScroll(); + this.handleInteractionStart(ev); + this.startDelay(ev); - // start the drag immediately if there is no minimum distance for a drag start - if (!this.options.distance) { - this.startDrag(ev); + if (!this.minDistance) { + this.handleDistanceSurpassed(ev); } } }, - // Call this to start tracking mouse movements - startListening: function(ev) { - var scrollParent; + handleInteractionStart: function(ev) { + this.trigger('interactionStart', ev); + }, - if (!this.isListening) { - // grab scroll container and attach handler - if (ev && this.options.scroll) { - scrollParent = getScrollParent($(ev.target)); - if (!scrollParent.is(window) && !scrollParent.is(document)) { - this.scrollEl = scrollParent; + endInteraction: function(ev, isCancelled) { + if (this.isInteracting) { + this.endDrag(ev); - // scope to `this`, and use `debounce` to make sure rapid calls don't happen - this.scrollHandlerProxy = debounce(proxy(this, 'scrollHandler'), 100); - this.scrollEl.on('scroll', this.scrollHandlerProxy); - } + if (this.delayTimeoutId) { + clearTimeout(this.delayTimeoutId); + this.delayTimeoutId = null; } - $(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.destroyAutoScroll(); + this.unbindHandlers(); + + this.isInteracting = false; + this.handleInteractionEnd(ev, isCancelled); - if (ev) { - this.originX = ev.pageX; - this.originY = ev.pageY; + // a touchstart+touchend on the same element will result in the following addition simulated events: + // mouseover + mouseout + click + // let's ignore these bogus events + if (this.isTouch) { + this.tempIgnoreMouse(); } - else { - // if no starting information was given, origin will be the topleft corner of the screen. - // if so, dx/dy in the future will be the absolute coordinates. - this.originX = 0; - this.originY = 0; + } + }, + + + handleInteractionEnd: function(ev, isCancelled) { + this.trigger('interactionEnd', ev, isCancelled || false); + }, + + + // Binding To DOM + // ----------------------------------------------------------------------------------------------------------------- + + + bindHandlers: function() { + var _this = this; + var touchStartIgnores = 1; + + if (this.isTouch) { + this.listenTo($(document), { + touchmove: this.handleTouchMove, + touchend: this.endInteraction, + touchcancel: this.endInteraction, + + // Sometimes touchend doesn't fire + // (can't figure out why. touchcancel doesn't fire either. has to do with scrolling?) + // If another touchstart happens, we know it's bogus, so cancel the drag. + // touchend will continue to be broken until user does a shorttap/scroll, but this is best we can do. + touchstart: function(ev) { + if (touchStartIgnores) { // bindHandlers is called from within a touchstart, + touchStartIgnores--; // and we don't want this to fire immediately, so ignore. + } + else { + _this.endInteraction(ev, true); // isCancelled=true + } + } + }); + + // listen to ALL scroll actions on the page + if ( + !bindAnyScroll(this.handleTouchScrollProxy) && // hopefully this works and short-circuits the rest + this.scrollEl // otherwise, attach a single handler to this + ) { + this.listenTo(this.scrollEl, 'scroll', this.handleTouchScroll); } + } + else { + this.listenTo($(document), { + mousemove: this.handleMouseMove, + mouseup: this.endInteraction + }); + } - this.isListening = true; - this.listenStart(ev); + this.listenTo($(document), { + selectstart: preventDefault, // don't allow selection while dragging + contextmenu: preventDefault // long taps would open menu on Chrome dev tools + }); + }, + + + unbindHandlers: function() { + this.stopListeningTo($(document)); + + // unbind scroll listening + unbindAnyScroll(this.handleTouchScrollProxy); + if (this.scrollEl) { + this.stopListeningTo(this.scrollEl, 'scroll'); + } + }, + + + // Drag (high-level) + // ----------------------------------------------------------------------------------------------------------------- + + + // extraOptions ignored if drag already started + startDrag: function(ev, extraOptions) { + this.startInteraction(ev, extraOptions); // ensure interaction began + + if (!this.isDragging) { + this.isDragging = true; + this.handleDragStart(ev); } }, - // Called when drag listening has started (but a real drag has not necessarily began) - listenStart: function(ev) { - this.trigger('listenStart', ev); + handleDragStart: function(ev) { + this.trigger('dragStart', ev); + this.initHrefHack(); }, - // Called when the user moves the mouse - mousemove: function(ev) { - var dx = ev.pageX - this.originX; - var dy = ev.pageY - this.originY; - var minDistance; + handleMove: function(ev) { + var dx = getEvX(ev) - this.originX; + var dy = getEvY(ev) - this.originY; + var minDistance = this.minDistance; var distanceSq; // current distance from the origin, squared - if (!this.isDragging) { // if not already dragging... - // then start the drag if the minimum distance criteria is met - minDistance = this.options.distance || 1; + if (!this.isDistanceSurpassed) { distanceSq = dx * dx + dy * dy; if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem - this.startDrag(ev); + this.handleDistanceSurpassed(ev); } } if (this.isDragging) { - this.drag(dx, dy, ev); // report a drag, even if this mousemove initiated the drag + this.handleDrag(dx, dy, ev); } }, - // 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) { + // Called while the mouse is being moved and when we know a legitimate drag is taking place + handleDrag: function(dx, dy, ev) { + this.trigger('drag', dx, dy, ev); + this.updateAutoScroll(ev); // will possibly cause scrolling + }, - if (!this.isListening) { // startDrag must have manually initiated - this.startListening(); - } - if (!this.isDragging) { - this.isDragging = true; - this.dragStart(ev); + endDrag: function(ev) { + if (this.isDragging) { + this.isDragging = false; + this.handleDragEnd(ev); } }, - // Called when the actual drag has started (went beyond minDistance) - dragStart: function(ev) { - var subjectEl = this.subjectEl; + handleDragEnd: function(ev) { + this.trigger('dragEnd', ev); + this.destroyHrefHack(); + }, - this.trigger('dragStart', ev); - // remove a mousedown'd <a>'s href so it is not visited (IE8 bug) - if ((this.subjectHref = subjectEl ? subjectEl.attr('href') : null)) { - subjectEl.removeAttr('href'); + // Delay + // ----------------------------------------------------------------------------------------------------------------- + + + startDelay: function(initialEv) { + var _this = this; + + if (this.delay) { + this.delayTimeoutId = setTimeout(function() { + _this.handleDelayEnd(initialEv); + }, this.delay); + } + else { + this.handleDelayEnd(initialEv); } }, - // Called while the mouse is being moved and when we know a legitimate drag is taking place - drag: function(dx, dy, ev) { - this.trigger('drag', dx, dy, ev); - this.updateScroll(ev); // will possibly cause scrolling + handleDelayEnd: function(initialEv) { + this.isDelayEnded = true; + + if (this.isDistanceSurpassed) { + this.startDrag(initialEv); + } }, - // Called when the user does a mouseup - mouseup: function(ev) { - this.stopListening(ev); + // Distance + // ----------------------------------------------------------------------------------------------------------------- + + + handleDistanceSurpassed: function(ev) { + this.isDistanceSurpassed = true; + + if (this.isDelayEnded) { + this.startDrag(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) { + // Mouse / Touch + // ----------------------------------------------------------------------------------------------------------------- + + + handleTouchMove: function(ev) { + // prevent inertia and touchmove-scrolling while dragging if (this.isDragging) { - this.stopScrolling(); - this.dragStop(ev); - this.isDragging = false; + ev.preventDefault(); } + + this.handleMove(ev); }, - // Called when dragging has been stopped - dragStop: function(ev) { - var _this = this; + handleMouseMove: function(ev) { + this.handleMove(ev); + }, - this.trigger('dragStop', ev); - // restore a mousedown'd <a>'s href (for IE8 bug) - setTimeout(function() { // must be outside of the click's execution - if (_this.subjectHref) { - _this.subjectEl.attr('href', _this.subjectHref); - } - }, 0); - }, + // Scrolling (unrelated to auto-scroll) + // ----------------------------------------------------------------------------------------------------------------- - // Call this to stop listening to the user's mouse events - stopListening: function(ev) { - this.stopDrag(ev); // if there's a current drag, kill it + handleTouchScroll: function(ev) { + // if the drag is being initiated by touch, but a scroll happens before + // the drag-initiating delay is over, cancel the drag + if (!this.isDragging) { + this.endInteraction(ev, true); // isCancelled=true + } + }, - if (this.isListening) { - // remove the scroll handler if there is a scrollEl - if (this.scrollEl) { - this.scrollEl.off('scroll', this.scrollHandlerProxy); - this.scrollHandlerProxy = null; - } + // <A> HREF Hack + // ----------------------------------------------------------------------------------------------------------------- - $(document) - .off('mousemove', this.mousemoveProxy) - .off('mouseup', this.mouseupProxy) - .off('selectstart', this.preventDefault); - this.mousemoveProxy = null; - this.mouseupProxy = null; + initHrefHack: function() { + var subjectEl = this.subjectEl; - this.isListening = false; - this.listenStop(ev); + // remove a mousedown'd <a>'s href so it is not visited (IE8 bug) + if ((this.subjectHref = subjectEl ? subjectEl.attr('href') : null)) { + subjectEl.removeAttr('href'); } }, - // Called when drag listening has stopped - listenStop: function(ev) { - this.trigger('listenStop', ev); + destroyHrefHack: function() { + var subjectEl = this.subjectEl; + var subjectHref = this.subjectHref; + + // restore a mousedown'd <a>'s href (for IE8 bug) + setTimeout(function() { // must be outside of the click's execution + if (subjectHref) { + subjectEl.attr('href', subjectHref); + } + }, 0); }, + // Utils + // ----------------------------------------------------------------------------------------------------------------- + + // 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)); } - }, + // makes _methods callable by event name. TODO: kill this + if (this['_' + name]) { + this['_' + name].apply(this, Array.prototype.slice.call(arguments, 1)); + } + } + + +}); + +;; +/* +this.scrollEl is set in DragListener +*/ +DragListener.mixin({ + isAutoScroll: false, - // Stops a given mouse event from doing it's native browser action. In our case, text selection. - preventDefault: function(ev) { - ev.preventDefault(); + scrollBounds: null, // { top, bottom, left, right } + scrollTopVel: null, // pixels per second + scrollLeftVel: null, // pixels per second + scrollIntervalId: null, // ID of setTimeout for scrolling animation loop + + // defaults + scrollSensitivity: 30, // pixels from edge for scrolling to start + scrollSpeed: 200, // pixels per second, at maximum speed + scrollIntervalMs: 50, // millisecond wait between scroll increment + + + initAutoScroll: function() { + var scrollEl = this.scrollEl; + + this.isAutoScroll = + this.options.scroll && + scrollEl && + !scrollEl.is(window) && + !scrollEl.is(document); + + if (this.isAutoScroll) { + // debounce makes sure rapid calls don't happen + this.listenTo(scrollEl, 'scroll', debounce(this.handleDebouncedScroll, 100)); + } }, - /* Scrolling - ------------------------------------------------------------------------------------------------------------------*/ + destroyAutoScroll: function() { + this.endAutoScroll(); // kill any animation loop + + // remove the scroll handler if there is a scrollEl + if (this.isAutoScroll) { + this.stopListeningTo(this.scrollEl, 'scroll'); // will probably get removed by unbindHandlers too :( + } + }, // Computes and stores the bounding rectangle of scrollEl computeScrollBounds: function() { - var el = this.scrollEl; - - this.scrollBounds = el ? getOuterRect(el) : null; + if (this.isAutoScroll) { + this.scrollBounds = getOuterRect(this.scrollEl); // TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars + } }, // Called when the dragging is in progress and scrolling should be updated - updateScroll: function(ev) { + updateAutoScroll: function(ev) { var sensitivity = this.scrollSensitivity; var bounds = this.scrollBounds; var topCloseness, bottomCloseness; @@ -2504,10 +2845,10 @@ var DragListener = FC.DragListener = Class.extend({ if (bounds) { // only scroll if scrollEl exists // compute closeness to edges. valid range is from 0.0 - 1.0 - topCloseness = (sensitivity - (ev.pageY - bounds.top)) / sensitivity; - bottomCloseness = (sensitivity - (bounds.bottom - ev.pageY)) / sensitivity; - leftCloseness = (sensitivity - (ev.pageX - bounds.left)) / sensitivity; - rightCloseness = (sensitivity - (bounds.right - ev.pageX)) / sensitivity; + topCloseness = (sensitivity - (getEvY(ev) - bounds.top)) / sensitivity; + bottomCloseness = (sensitivity - (bounds.bottom - getEvY(ev))) / sensitivity; + leftCloseness = (sensitivity - (getEvX(ev) - bounds.left)) / sensitivity; + rightCloseness = (sensitivity - (bounds.right - getEvX(ev))) / sensitivity; // translate vertical closeness into velocity. // mouse must be completely in bounds for velocity to happen. @@ -2594,38 +2935,36 @@ var DragListener = FC.DragListener = Class.extend({ // if scrolled all the way, which causes the vels to be zero, stop the animation loop if (!this.scrollTopVel && !this.scrollLeftVel) { - this.stopScrolling(); + this.endAutoScroll(); } }, // Kills any existing scrolling animation loop - stopScrolling: function() { + endAutoScroll: function() { if (this.scrollIntervalId) { clearInterval(this.scrollIntervalId); this.scrollIntervalId = null; - // when all done with scrolling, recompute positions since they probably changed - this.scrollStop(); + this.handleScrollEnd(); } }, // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce) - scrollHandler: function() { + handleDebouncedScroll: function() { // recompute all coordinates, but *only* if this is *not* part of our scrolling animation if (!this.scrollIntervalId) { - this.scrollStop(); + this.handleScrollEnd(); } }, // Called when scrolling has stopped, whether through auto scroll, or the user scrolling - scrollStop: function() { + handleScrollEnd: function() { } }); - ;; /* Tracks mouse movements over a component and raises events about which hit the mouse is over. @@ -2654,18 +2993,16 @@ var HitDragListener = DragListener.extend({ // Called when drag listening starts (but a real drag has not necessarily began). // ev might be undefined if dragging was started manually. - listenStart: function(ev) { + handleInteractionStart: function(ev) { var subjectEl = this.subjectEl; var subjectRect; var origPoint; var point; - DragListener.prototype.listenStart.apply(this, arguments); // call the super-method - this.computeCoords(); if (ev) { - origPoint = { left: ev.pageX, top: ev.pageY }; + origPoint = { left: getEvX(ev), top: getEvY(ev) }; point = origPoint; // constrain the point to bounds of the element being dragged @@ -2695,61 +3032,64 @@ var HitDragListener = DragListener.extend({ this.origHit = null; this.coordAdjust = null; } + + // call the super-method. do it after origHit has been computed + DragListener.prototype.handleInteractionStart.apply(this, arguments); }, // Recomputes the drag-critical positions of elements computeCoords: function() { this.component.prepareHits(); - this.computeScrollBounds(); // why is this here??? + this.computeScrollBounds(); // why is this here?????? }, // Called when the actual drag has started - dragStart: function(ev) { + handleDragStart: function(ev) { var hit; - DragListener.prototype.dragStart.apply(this, arguments); // call the super-method + DragListener.prototype.handleDragStart.apply(this, arguments); // call the super-method // might be different from this.origHit if the min-distance is large - hit = this.queryHit(ev.pageX, ev.pageY); + hit = this.queryHit(getEvX(ev), getEvY(ev)); // report the initial hit the mouse is over // especially important if no min-distance and drag starts immediately if (hit) { - this.hitOver(hit); + this.handleHitOver(hit); } }, // Called when the drag moves - drag: function(dx, dy, ev) { + handleDrag: function(dx, dy, ev) { var hit; - DragListener.prototype.drag.apply(this, arguments); // call the super-method + DragListener.prototype.handleDrag.apply(this, arguments); // call the super-method - hit = this.queryHit(ev.pageX, ev.pageY); + hit = this.queryHit(getEvX(ev), getEvY(ev)); if (!isHitsEqual(hit, this.hit)) { // a different hit than before? if (this.hit) { - this.hitOut(); + this.handleHitOut(); } if (hit) { - this.hitOver(hit); + this.handleHitOver(hit); } } }, // Called when dragging has been stopped - dragStop: function() { - this.hitDone(); - DragListener.prototype.dragStop.apply(this, arguments); // call the super-method + handleDragEnd: function() { + this.handleHitDone(); + DragListener.prototype.handleDragEnd.apply(this, arguments); // call the super-method }, // Called when a the mouse has just moved over a new hit - hitOver: function(hit) { + handleHitOver: function(hit) { var isOrig = isHitsEqual(hit, this.origHit); this.hit = hit; @@ -2759,26 +3099,26 @@ var HitDragListener = DragListener.extend({ // Called when the mouse has just moved out of a hit - hitOut: function() { + handleHitOut: function() { if (this.hit) { this.trigger('hitOut', this.hit); - this.hitDone(); + this.handleHitDone(); this.hit = null; } }, // Called after a hitOut. Also called before a dragStop - hitDone: function() { + handleHitDone: function() { if (this.hit) { this.trigger('hitDone', this.hit); } }, - // Called when drag listening has stopped - listenStop: function() { - DragListener.prototype.listenStop.apply(this, arguments); // call the super-method + // Called when the interaction ends, whether there was a real drag or not + handleInteractionEnd: function() { + DragListener.prototype.handleInteractionEnd.apply(this, arguments); // call the super-method this.origHit = null; this.hit = null; @@ -2788,8 +3128,8 @@ var HitDragListener = DragListener.extend({ // Called when scrolling has stopped, whether through auto scroll, or the user scrolling - scrollStop: function() { - DragListener.prototype.scrollStop.apply(this, arguments); // call the super-method + handleScrollEnd: function() { + DragListener.prototype.handleScrollEnd.apply(this, arguments); // call the super-method this.computeCoords(); // hits' absolute positions will be in new places. recompute }, @@ -2844,7 +3184,7 @@ function isHitPropsWithin(subHit, superHit) { /* Creates a clone of an element and lets it track the mouse as it moves ----------------------------------------------------------------------------------------------------------------------*/ -var MouseFollower = Class.extend({ +var MouseFollower = Class.extend(ListenerMixin, { options: null, @@ -2856,16 +3196,14 @@ var MouseFollower = Class.extend({ top0: null, left0: null, - // the initial position of the mouse - mouseY0: null, - mouseX0: null, + // the absolute coordinates of the initiating touch/mouse action + y0: null, + x0: 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? @@ -2882,8 +3220,8 @@ var MouseFollower = Class.extend({ if (!this.isFollowing) { this.isFollowing = true; - this.mouseY0 = ev.pageY; - this.mouseX0 = ev.pageX; + this.y0 = getEvY(ev); + this.x0 = getEvX(ev); this.topDelta = 0; this.leftDelta = 0; @@ -2891,7 +3229,12 @@ var MouseFollower = Class.extend({ this.updatePosition(); } - $(document).on('mousemove', this.mousemoveProxy = proxy(this, 'mousemove')); + if (getEvIsTouch(ev)) { + this.listenTo($(document), 'touchmove', this.handleMove); + } + else { + this.listenTo($(document), 'mousemove', this.handleMove); + } } }, @@ -2916,7 +3259,7 @@ var MouseFollower = Class.extend({ if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time this.isFollowing = false; - $(document).off('mousemove', this.mousemoveProxy); + this.stopListeningTo($(document)); if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation? this.isAnimating = true; @@ -2942,6 +3285,7 @@ var MouseFollower = Class.extend({ if (!el) { this.sourceEl.width(); // hack to force IE8 to compute correct bounding box el = this.el = this.sourceEl.clone() + .addClass(this.options.additionalClass || '') .css({ position: 'absolute', visibility: '', // in case original element was hidden (commonly through hideEvents()) @@ -2953,8 +3297,13 @@ var MouseFollower = Class.extend({ height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value opacity: this.options.opacity || '', zIndex: this.options.zIndex - }) - .appendTo(this.parentEl); + }); + + // we don't want long taps or any mouse interaction causing selection/menus. + // would use preventSelection(), but that prevents selectstart, causing problems. + el.addClass('fc-unselectable'); + + el.appendTo(this.parentEl); } return el; @@ -2994,9 +3343,9 @@ var MouseFollower = Class.extend({ // Gets called when the user moves the mouse - mousemove: function(ev) { - this.topDelta = ev.pageY - this.mouseY0; - this.leftDelta = ev.pageX - this.mouseX0; + handleMove: function(ev) { + this.topDelta = getEvY(ev) - this.y0; + this.leftDelta = getEvX(ev) - this.x0; if (!this.isHidden) { this.updatePosition(); @@ -3031,7 +3380,7 @@ var MouseFollower = Class.extend({ /* An abstract class comprised of a "grid" of areas that each represent a specific datetime ----------------------------------------------------------------------------------------------------------------------*/ -var Grid = FC.Grid = Class.extend({ +var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { view: null, // a View object isRTL: null, // shortcut to the view's isRTL option @@ -3042,8 +3391,6 @@ var Grid = FC.Grid = Class.extend({ el: null, // the containing element elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name. - externalDragStartProxy: null, // binds the Grid's scope to externalDragStart (in DayGrid.events) - // derived from options eventTimeFormat: null, displayEventTime: null, @@ -3056,13 +3403,19 @@ var Grid = FC.Grid = Class.extend({ // TODO: port isTimeScale into same system? largeUnit: null, + dayDragListener: null, + segDragListener: null, + segResizeListener: null, + externalDragListener: null, + constructor: function(view) { this.view = view; this.isRTL = view.opt('isRTL'); - this.elsByFill = {}; - this.externalDragStartProxy = proxy(this, 'externalDragStart'); + + this.dayDragListener = this.buildDayDragListener(); + this.initMouseIgnoring(); }, @@ -3195,26 +3548,33 @@ var Grid = FC.Grid = Class.extend({ // Sets the container element that the grid should render inside of. // Does other DOM-related initializations. setElement: function(el) { - var _this = this; - this.el = el; + preventSelection(el); + + this.bindDayHandler('touchstart', this.dayTouchStart); + this.bindDayHandler('mousedown', this.dayMousedown); + + // attach event-element-related handlers. in Grid.events + // same garbage collection note as above. + this.bindSegHandlers(); + + this.bindGlobalHandlers(); + }, + + + bindDayHandler: function(name, handler) { + var _this = this; // attach a handler to the grid's root element. // jQuery will take care of unregistering them when removeElement gets called. - el.on('mousedown', function(ev) { + this.el.on(name, function(ev) { 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); + return handler.call(_this, ev); } }); - - // attach event-element-related handlers. in Grid.events - // same garbage collection note as above. - this.bindSegHandlers(); - - this.bindGlobalHandlers(); }, @@ -3222,6 +3582,7 @@ var Grid = FC.Grid = Class.extend({ // DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View removeElement: function() { this.unbindGlobalHandlers(); + this.clearDragListeners(); this.el.remove(); @@ -3254,18 +3615,47 @@ var Grid = FC.Grid = Class.extend({ // Binds DOM handlers to elements that reside outside the grid, such as the document bindGlobalHandlers: function() { - $(document).on('dragstart sortstart', this.externalDragStartProxy); // jqui + this.listenTo($(document), { + dragstart: this.externalDragStart, // jqui + sortstart: this.externalDragStart // jqui + }); }, // Unbinds DOM handlers from elements that reside outside the grid unbindGlobalHandlers: function() { - $(document).off('dragstart sortstart', this.externalDragStartProxy); // jqui + this.stopListeningTo($(document)); }, // Process a mousedown on an element that represents a day. For day clicking and selecting. dayMousedown: function(ev) { + if (!this.isIgnoringMouse) { + this.dayDragListener.startInteraction(ev, { + //distance: 5, // needs more work if we want dayClick to fire correctly + }); + } + }, + + + dayTouchStart: function(ev) { + var view = this.view; + + // HACK to prevent a user's clickaway for unselecting a range or an event + // from causing a dayClick. + if (view.isSelected || view.selectedEvent) { + this.tempIgnoreMouse(); + } + + this.dayDragListener.startInteraction(ev, { + delay: this.view.opt('longPressDelay') + }); + }, + + + // Creates a listener that tracks the user's drag across day elements. + // For day clicking and selecting. + buildDayDragListener: function() { var _this = this; var view = this.view; var isSelectable = view.opt('selectable'); @@ -3276,14 +3666,21 @@ var Grid = FC.Grid = Class.extend({ // if the drag ends on the same day, it is a 'dayClick'. // if 'selectable' is enabled, this listener also detects selections. var dragListener = new HitDragListener(this, { - //distance: 5, // needs more work if we want dayClick to fire correctly scroll: view.opt('dragScroll'), + interactionStart: function() { + dayClickHit = dragListener.origHit; // for dayClick, where no dragging happens + }, dragStart: function() { view.unselect(); // since we could be rendering a new selection, we want to clear any old one }, hitOver: function(hit, isOrig, origHit) { if (origHit) { // click needs to have started on a hit - dayClickHit = isOrig ? hit : null; // single-hit selection is a day click + + // if user dragged to another cell at any point, it can no longer be a dayClick + if (!isOrig) { + dayClickHit = null; + } + if (isSelectable) { selectionSpan = _this.computeSelection( _this.getHitSpan(origHit), @@ -3304,23 +3701,46 @@ var Grid = FC.Grid = Class.extend({ _this.unrenderSelection(); enableCursor(); }, - listenStop: function(ev) { - if (dayClickHit) { - view.triggerDayClick( - _this.getHitSpan(dayClickHit), - _this.getHitEl(dayClickHit), - ev - ); - } - if (selectionSpan) { - // the selection will already have been rendered. just report it - view.reportSelection(selectionSpan, ev); + interactionEnd: function(ev, isCancelled) { + if (!isCancelled) { + if ( + dayClickHit && + !_this.isIgnoringMouse // see hack in dayTouchStart + ) { + view.triggerDayClick( + _this.getHitSpan(dayClickHit), + _this.getHitEl(dayClickHit), + ev + ); + } + if (selectionSpan) { + // the selection will already have been rendered. just report it + view.reportSelection(selectionSpan, ev); + } + enableCursor(); } - enableCursor(); } }); - dragListener.mousedown(ev); // start listening, which will eventually initiate a dragStart + return dragListener; + }, + + + // Kills all in-progress dragging. + // Useful for when public API methods that result in re-rendering are invoked during a drag. + // Also useful for when touch devices misbehave and don't fire their touchend. + clearDragListeners: function() { + this.dayDragListener.endInteraction(); + + if (this.segDragListener) { + this.segDragListener.endInteraction(); // will clear this.segDragListener + } + if (this.segResizeListener) { + this.segResizeListener.endInteraction(); // will clear this.segResizeListener + } + if (this.externalDragListener) { + this.externalDragListener.endInteraction(); // will clear this.externalDragListener + } }, @@ -3330,10 +3750,11 @@ var Grid = FC.Grid = Class.extend({ // Renders a mock event at the given event location, which contains zoned start/end properties. + // Returns all mock event elements. renderEventLocationHelper: function(eventLocation, sourceSeg) { var fakeEvent = this.fabricateHelperEvent(eventLocation, sourceSeg); - this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering + return this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering }, @@ -3361,6 +3782,7 @@ var Grid = FC.Grid = Class.extend({ // Renders a mock event. Given zoned event date properties. + // Must return all mock event elements. renderHelper: function(eventLocation, sourceSeg) { // subclasses must implement }, @@ -3640,7 +4062,8 @@ Grid.mixin({ // Unrenders all events currently rendered on the grid unrenderEvents: function() { - this.triggerSegMouseout(); // trigger an eventMouseout if user's mouse is over an event + this.handleSegMouseout(); // trigger an eventMouseout if user's mouse is over an event + this.clearDragListeners(); this.unrenderFgSegs(); this.unrenderBgSegs(); @@ -3768,48 +4191,44 @@ Grid.mixin({ // Attaches event-element-related handlers to the container element and leverage bubbling bindSegHandlers: function() { + this.bindSegHandler('touchstart', this.handleSegTouchStart); + this.bindSegHandler('touchend', this.handleSegTouchEnd); + this.bindSegHandler('mouseenter', this.handleSegMouseover); + this.bindSegHandler('mouseleave', this.handleSegMouseout); + this.bindSegHandler('mousedown', this.handleSegMousedown); + this.bindSegHandler('click', this.handleSegClick); + }, + + + // Executes a handler for any a user-interaction on a segment. + // Handler gets called with (seg, ev), and with the `this` context of the Grid + bindSegHandler: function(name, handler) { var _this = this; - var view = this.view; - $.each( - { - mouseenter: function(seg, ev) { - _this.triggerSegMouseover(seg, ev); - }, - mouseleave: function(seg, ev) { - _this.triggerSegMouseout(seg, 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, $(ev.target).is('.fc-start-resizer')); - } - 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-event-container > *', function(ev) { - var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents - - // only call the handlers if there is not a drag/resize in progress - if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) { - return func.call(this, seg, ev); // `this` will be the event element - } - }); + this.el.on(name, '.fc-event-container > *', function(ev) { + var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents + + // only call the handlers if there is not a drag/resize in progress + if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) { + return handler.call(_this, seg, ev); // context will be the Grid } - ); + }); + }, + + + handleSegClick: function(seg, ev) { + return this.view.trigger('eventClick', seg.el[0], seg.event, ev); // can return `false` to cancel }, // Updates internal state and triggers handlers for when an event element is moused over - triggerSegMouseover: function(seg, ev) { - if (!this.mousedOverSeg) { + handleSegMouseover: function(seg, ev) { + if ( + !this.isIgnoringMouse && + !this.mousedOverSeg + ) { this.mousedOverSeg = seg; + seg.el.addClass('fc-allow-mouse-resize'); this.view.trigger('eventMouseover', seg.el[0], seg.event, ev); } }, @@ -3817,56 +4236,132 @@ Grid.mixin({ // Updates internal state and triggers handlers for when an event element is moused out. // Can be given no arguments, in which case it will mouseout the segment that was previously moused over. - triggerSegMouseout: function(seg, ev) { + handleSegMouseout: function(seg, ev) { ev = ev || {}; // if given no args, make a mock mouse event if (this.mousedOverSeg) { seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment this.mousedOverSeg = null; + seg.el.removeClass('fc-allow-mouse-resize'); this.view.trigger('eventMouseout', seg.el[0], seg.event, ev); } }, + handleSegMousedown: function(seg, ev) { + var isResizing = this.startSegResize(seg, ev, { distance: 5 }); + + if (!isResizing && this.view.isEventDraggable(seg.event)) { + this.buildSegDragListener(seg) + .startInteraction(ev, { + distance: 5 + }); + } + }, + + + handleSegTouchStart: function(seg, ev) { + var view = this.view; + var event = seg.event; + var isSelected = view.isEventSelected(event); + var isDraggable = view.isEventDraggable(event); + var isResizable = view.isEventResizable(event); + var isResizing = false; + var dragListener; + + if (isSelected && isResizable) { + // only allow resizing of the event is selected + isResizing = this.startSegResize(seg, ev); + } + + if (!isResizing && (isDraggable || isResizable)) { // allowed to be selected? + + dragListener = isDraggable ? + this.buildSegDragListener(seg) : + this.buildSegSelectListener(seg); // seg isn't draggable, but still needs to be selected + + dragListener.startInteraction(ev, { // won't start if already started + delay: isSelected ? 0 : this.view.opt('longPressDelay') // do delay if not already selected + }); + } + + // a long tap simulates a mouseover. ignore this bogus mouseover. + this.tempIgnoreMouse(); + }, + + + handleSegTouchEnd: function(seg, ev) { + // touchstart+touchend = click, which simulates a mouseover. + // ignore this bogus mouseover. + this.tempIgnoreMouse(); + }, + + + // returns boolean whether resizing actually started or not. + // assumes the seg allows resizing. + // `dragOptions` are optional. + startSegResize: function(seg, ev, dragOptions) { + if ($(ev.target).is('.fc-resizer')) { + this.buildSegResizeListener(seg, $(ev.target).is('.fc-start-resizer')) + .startInteraction(ev, dragOptions); + return true; + } + return false; + }, + + + /* Event Dragging ------------------------------------------------------------------------------------------------------------------*/ - // Called when the user does a mousedown on an event, which might lead to dragging. + // Builds a listener that will track user-dragging on an event segment. // Generic enough to work with any type of Grid. - segDragMousedown: function(seg, ev) { + // Has side effect of setting/unsetting `segDragListener` + buildSegDragListener: function(seg) { var _this = this; var view = this.view; var calendar = view.calendar; var el = seg.el; var event = seg.event; + var isDragging; + var mouseFollower; // A clone of the original element that will move with the mouse var dropLocation; // zoned event date properties - // 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 - }); + if (this.segDragListener) { + return this.segDragListener; + } // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents // of the view. - var dragListener = new HitDragListener(view, { - distance: 5, + var dragListener = this.segDragListener = new HitDragListener(view, { scroll: view.opt('dragScroll'), subjectEl: el, subjectCenter: true, - listenStart: function(ev) { + interactionStart: function(ev) { + isDragging = false; + mouseFollower = new MouseFollower(seg.el, { + additionalClass: 'fc-dragging', + parentEl: view.el, + opacity: dragListener.isTouch ? null : view.opt('dragOpacity'), + revertDuration: view.opt('dragRevertDuration'), + zIndex: 2 // one above the .fc-view + }); mouseFollower.hide(); // don't show until we know this is a real drag mouseFollower.start(ev); }, dragStart: function(ev) { - _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported + if (dragListener.isTouch && !view.isEventSelected(event)) { + // if not previously selected, will fire after a delay. then, select the event + view.selectEvent(event); + } + isDragging = true; + _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported _this.segDragStart(seg, ev); view.hideEvent(event); // hide all event segments. our mouseFollower will take over }, hitOver: function(hit, isOrig, origHit) { + var dragHelperEls; // starting hit could be forced (DayGrid.limit) if (seg.hit) { @@ -3886,7 +4381,13 @@ Grid.mixin({ } // if a valid drop location, have the subclass render a visual indication - if (dropLocation && view.renderDrag(dropLocation, seg)) { + if (dropLocation && (dragHelperEls = view.renderDrag(dropLocation, seg))) { + + dragHelperEls.addClass('fc-dragging'); + if (!dragListener.isTouch) { + _this.applyDragOpacity(dragHelperEls); + } + mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own } else { @@ -3902,27 +4403,54 @@ Grid.mixin({ mouseFollower.show(); // show in case we are moving out of all hits dropLocation = null; }, - hitDone: function() { // Called after a hitOut OR before a dragStop + hitDone: function() { // Called after a hitOut OR before a dragEnd enableCursor(); }, - dragStop: function(ev) { + interactionEnd: function(ev) { // do revert animation if hasn't changed. calls a callback when finished (whether animation or not) mouseFollower.stop(!dropLocation, function() { - view.unrenderDrag(); - view.showEvent(event); - _this.segDragStop(seg, ev); - + if (isDragging) { + view.unrenderDrag(); + view.showEvent(event); + _this.segDragStop(seg, ev); + } if (dropLocation) { view.reportEventDrop(event, dropLocation, this.largeUnit, el, ev); } }); + _this.segDragListener = null; + } + }); + + return dragListener; + }, + + + // seg isn't draggable, but let's use a generic DragListener + // simply for the delay, so it can be selected. + // Has side effect of setting/unsetting `segDragListener` + buildSegSelectListener: function(seg) { + var _this = this; + var view = this.view; + var event = seg.event; + + if (this.segDragListener) { + return this.segDragListener; + } + + var dragListener = this.segDragListener = new DragListener({ + dragStart: function(ev) { + if (dragListener.isTouch && !view.isEventSelected(event)) { + // if not previously selected, will fire after a delay. then, select the event + view.selectEvent(event); + } }, - listenStop: function() { - mouseFollower.stop(); // put in listenStop in case there was a mousedown but the drag never started + interactionEnd: function(ev) { + _this.segDragListener = null; } }); - dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart + return dragListener; }, @@ -4038,8 +4566,8 @@ Grid.mixin({ var dropLocation; // a null value signals an unsuccessful drag // listener that tracks mouse movement over date-associated pixel regions - var dragListener = new HitDragListener(this, { - listenStart: function() { + var dragListener = _this.externalDragListener = new HitDragListener(this, { + interactionStart: function() { _this.isDraggingExternal = true; }, hitOver: function(hit) { @@ -4063,17 +4591,16 @@ Grid.mixin({ hitOut: function() { dropLocation = null; // signal unsuccessful }, - hitDone: function() { // Called after a hitOut OR before a dragStop + hitDone: function() { // Called after a hitOut OR before a dragEnd enableCursor(); _this.unrenderDrag(); }, - dragStop: function() { + interactionEnd: function(ev) { if (dropLocation) { // element was dropped on a valid hit _this.view.reportExternalDrop(meta, dropLocation, el, ev, ui); } - }, - listenStop: function() { _this.isDraggingExternal = false; + _this.externalDragListener = null; } }); @@ -4114,6 +4641,7 @@ Grid.mixin({ // `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null. // `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null. // A truthy returned value indicates this method has rendered a helper element. + // Must return elements used for any mock events. renderDrag: function(dropLocation, seg) { // subclasses must implement }, @@ -4129,24 +4657,28 @@ Grid.mixin({ ------------------------------------------------------------------------------------------------------------------*/ - // Called when the user does a mousedown on an event's resizer, which might lead to resizing. + // Creates a listener that tracks the user as they resize an event segment. // Generic enough to work with any type of Grid. - segResizeMousedown: function(seg, ev, isStart) { + buildSegResizeListener: function(seg, isStart) { var _this = this; var view = this.view; var calendar = view.calendar; var el = seg.el; var event = seg.event; var eventEnd = calendar.getEventEnd(event); + var isDragging; var resizeLocation; // zoned event date properties. falsy if invalid resize // Tracks mouse movement over the *grid's* coordinate map - var dragListener = new HitDragListener(this, { - distance: 5, + var dragListener = this.segResizeListener = new HitDragListener(this, { scroll: view.opt('dragScroll'), subjectEl: el, + interactionStart: function() { + isDragging = false; + }, dragStart: function(ev) { - _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported + isDragging = true; + _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported _this.segResizeStart(seg, ev); }, hitOver: function(hit, isOrig, origHit) { @@ -4181,16 +4713,18 @@ Grid.mixin({ view.showEvent(event); enableCursor(); }, - dragStop: function(ev) { - _this.segResizeStop(seg, ev); - + interactionEnd: function(ev) { + if (isDragging) { + _this.segResizeStop(seg, ev); + } if (resizeLocation) { // valid date to resize to? view.reportEventResize(event, resizeLocation, this.largeUnit, el, ev); } + _this.segResizeListener = null; } }); - dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart + return dragListener; }, @@ -4267,6 +4801,7 @@ Grid.mixin({ // Renders a visual indication of an event being resized. // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag. + // Must return elements used for any mock events. renderEventResize: function(range, seg) { // subclasses must implement }, @@ -4312,6 +4847,7 @@ Grid.mixin({ // Generic utility for generating the HTML classNames for an event segment's element getSegClasses: function(seg, isDraggable, isResizable) { + var view = this.view; var event = seg.event; var classes = [ 'fc-event', @@ -4329,6 +4865,11 @@ Grid.mixin({ classes.push('fc-resizable'); } + // event is currently selected? attach a className. + if (view.isEventSelected(event)) { + classes.push('fc-selected'); + } + return classes; }, @@ -5311,10 +5852,7 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { // 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.renderEventLocationHelper(eventLocation, seg); - this.applyDragOpacity(this.helperEls); - - return true; // a helper has been rendered + return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements } }, @@ -5333,7 +5871,7 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { // Renders a visual indication of an event being resized renderEventResize: function(eventLocation, seg) { this.renderHighlight(this.eventToSpan(eventLocation)); - this.renderEventLocationHelper(eventLocation, seg); + return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements }, @@ -5379,7 +5917,9 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { helperNodes.push(skeletonEl[0]); }); - this.helperEls = $(helperNodes); // array -> jQuery set + return ( // must return the elements rendered + this.helperEls = $(helperNodes) // array -> jQuery set + ); }, @@ -6165,6 +6705,7 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { labelInterval: null, // duration of how often a label should be displayed for a slot colEls: null, // cells elements in the day-row background + slatContainerEl: null, // div that wraps all the slat rows slatEls: null, // elements running horizontally across all columns nowIndicatorEls: null, @@ -6184,7 +6725,8 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { renderDates: function() { this.el.html(this.renderHtml()); this.colEls = this.el.find('.fc-day'); - this.slatEls = this.el.find('.fc-slats tr'); + this.slatContainerEl = this.el.find('.fc-slats'); + this.slatEls = this.slatContainerEl.find('tr'); this.colCoordCache = new CoordCache({ els: this.colEls, @@ -6463,6 +7005,11 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { }, + getTotalSlatHeight: function() { + return this.slatContainerEl.outerHeight(); + }, + + // 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) { @@ -6511,13 +7058,10 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { renderDrag: function(eventLocation, seg) { if (seg) { // if there is event information for this drag, render a helper event - this.renderEventLocationHelper(eventLocation, seg); - for (var i = 0; i < this.helperSegs.length; i++) { - this.applyDragOpacity(this.helperSegs[i].el); - } - - return true; // signal that a helper has been rendered + // returns mock event elements + // signal that a helper has been rendered + return this.renderEventLocationHelper(eventLocation, seg); } else { // otherwise, just render a highlight @@ -6539,7 +7083,7 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { // Renders a visual indication of an event being resized renderEventResize: function(eventLocation, seg) { - this.renderEventLocationHelper(eventLocation, seg); + return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements }, @@ -6555,7 +7099,7 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag) renderHelper: function(event, sourceSeg) { - this.renderHelperSegs(this.eventToSegs(event), sourceSeg); + return this.renderHelperSegs(this.eventToSegs(event), sourceSeg); // returns mock event elements }, @@ -6749,6 +7293,7 @@ TimeGrid.mixin({ renderHelperSegs: function(segs, sourceSeg) { + var helperEls = []; var i, seg; var sourceEl; @@ -6766,9 +7311,12 @@ TimeGrid.mixin({ 'margin-right': sourceEl.css('margin-right') }); } + helperEls.push(seg.el[0]); } this.helperSegs = segs; + + return $(helperEls); // must return rendered helpers }, @@ -7279,7 +7827,7 @@ function isSlotSegCollision(seg1, seg2) { /* An abstract class from which other views inherit from ----------------------------------------------------------------------------------------------------------------------*/ -var View = FC.View = Class.extend({ +var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, { type: null, // subclass' view name (string) name: null, // deprecated. use `type` instead @@ -7306,13 +7854,10 @@ var View = FC.View = Class.extend({ isRTL: false, isSelected: false, // boolean whether a range of time is user-selected or not + selectedEvent: null, eventOrderSpecs: null, // criteria for ordering events when they have same date/time - // 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, @@ -7322,9 +7867,6 @@ var View = FC.View = Class.extend({ nextDayThreshold: null, isHiddenDayHash: null, - // document handlers, bound to `this` object - documentMousedownProxy: null, // TODO: doesn't work with touch - // now indicator isNowIndicatorRendered: null, initialNowDate: null, // result first getNow call @@ -7347,8 +7889,6 @@ var View = FC.View = Class.extend({ this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder')); - this.documentMousedownProxy = proxy(this, 'documentMousedown'); - this.initialize(); }, @@ -7674,13 +8214,14 @@ var View = FC.View = Class.extend({ // Binds DOM handlers to elements that reside outside the view container, such as the document bindGlobalHandlers: function() { - $(document).on('mousedown', this.documentMousedownProxy); + this.listenTo($(document), 'mousedown', this.handleDocumentMousedown); + this.listenTo($(document), 'touchstart', this.processUnselect); }, // Unbinds DOM handlers from elements that reside outside the view container unbindGlobalHandlers: function() { - $(document).off('mousedown', this.documentMousedownProxy); + this.stopListeningTo($(document)); }, @@ -7848,27 +8389,6 @@ var View = FC.View = Class.extend({ ------------------------------------------------------------------------------------------------------------------*/ - // Given the total height of the view, return the number of pixels that should be used for the scroller. - // Utility for subclasses. - computeScrollerHeight: function(totalHeight) { - var scrollerEl = this.scrollerEl; - var both; - var otherHeight; // cumulative height of everything that is not the scrollerEl in the view (header+borders) - - both = this.el.add(scrollerEl); - - // fuckin IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked - both.css({ - position: 'relative', // cause a reflow, which will force fresh dimension recalculation - left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll - }); - otherHeight = this.el.outerHeight() - scrollerEl.height(); // grab the dimensions - both.css({ position: '', left: '' }); // undo hack - - return totalHeight - otherHeight; - }, - - // Computes the initial pre-configured scroll state prior to allowing the user to change it. // Given the scroll state from the previous rendering. If first time rendering, given null. computeInitialScroll: function(previousScrollState) { @@ -7878,17 +8398,13 @@ var View = FC.View = Class.extend({ // Retrieves the view's current natural scroll state. Can return an arbitrary format. queryScroll: function() { - if (this.scrollerEl) { - return this.scrollerEl.scrollTop(); // operates on scrollerEl by default - } + // subclasses must implement }, // Sets the view's scroll state. Will accept the same format computeInitialScroll and queryScroll produce. setScroll: function(scrollState) { - if (this.scrollerEl) { - return this.scrollerEl.scrollTop(scrollState); // operates on scrollerEl by default - } + // subclasses must implement }, @@ -8103,7 +8619,8 @@ var View = FC.View = Class.extend({ // Renders a visual indication of a event or external-element drag over the given drop zone. - // If an external-element, seg will be `null` + // If an external-element, seg will be `null`. + // Must return elements used for any mock events. renderDrag: function(dropLocation, seg) { // subclasses must implement }, @@ -8166,7 +8683,7 @@ var View = FC.View = Class.extend({ }, - /* Selection + /* Selection (time range) ------------------------------------------------------------------------------------------------------------------*/ @@ -8224,13 +8741,62 @@ var View = FC.View = Class.extend({ }, - // Handler for unselecting when the user clicks something and the 'unselectAuto' setting is on - documentMousedown: function(ev) { - var ignore; + /* Event Selection + ------------------------------------------------------------------------------------------------------------------*/ + + + selectEvent: function(event) { + if (!this.selectedEvent || this.selectedEvent !== event) { + this.unselectEvent(); + this.renderedEventSegEach(function(seg) { + seg.el.addClass('fc-selected'); + }, event); + this.selectedEvent = event; + } + }, + + + unselectEvent: function() { + if (this.selectedEvent) { + this.renderedEventSegEach(function(seg) { + seg.el.removeClass('fc-selected'); + }, this.selectedEvent); + this.selectedEvent = null; + } + }, - // is there a selection, and has the user made a proper left click? - if (this.isSelected && this.opt('unselectAuto') && isPrimaryMouseButton(ev)) { + isEventSelected: function(event) { + // event references might change on refetchEvents(), while selectedEvent doesn't, + // so compare IDs + return this.selectedEvent && this.selectedEvent._id === event._id; + }, + + + /* Mouse / Touch Unselecting (time range & event unselection) + ------------------------------------------------------------------------------------------------------------------*/ + // TODO: move consistently to down/start or up/end? + // TODO: don't kill previous selection if touch scrolling + + + handleDocumentMousedown: function(ev) { + if (isPrimaryMouseButton(ev)) { + this.processUnselect(ev); + } + }, + + + processUnselect: function(ev) { + this.processRangeUnselect(ev); + this.processEventUnselect(ev); + }, + + + processRangeUnselect: function(ev) { + var ignore; + + // is there a time-range selection? + if (this.isSelected && this.opt('unselectAuto')) { // 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) { @@ -8240,6 +8806,15 @@ var View = FC.View = Class.extend({ }, + processEventUnselect: function(ev) { + if (this.selectedEvent) { + if (!$(ev.target).closest('.fc-selected').length) { + this.unselectEvent(); + } + } + }, + + /* Day Click ------------------------------------------------------------------------------------------------------------------*/ @@ -8354,6 +8929,127 @@ var View = FC.View = Class.extend({ ;; +/* +Embodies a div that has potential scrollbars +*/ +var Scroller = FC.Scroller = Class.extend({ + + el: null, // the guaranteed outer element + scrollEl: null, // the element with the scrollbars + overflowX: null, + overflowY: null, + + + constructor: function(options) { + options = options || {}; + this.overflowX = options.overflowX || options.overflow || 'auto'; + this.overflowY = options.overflowY || options.overflow || 'auto'; + }, + + + render: function() { + this.el = this.renderEl(); + this.applyOverflow(); + }, + + + renderEl: function() { + return (this.scrollEl = $('<div class="fc-scroller"></div>')); + }, + + + // sets to natural height, unlocks overflow + clear: function() { + this.setHeight('auto'); + this.applyOverflow(); + }, + + + destroy: function() { + this.el.remove(); + }, + + + // Overflow + // ----------------------------------------------------------------------------------------------------------------- + + + applyOverflow: function() { + this.scrollEl.css({ + 'overflow-x': this.overflowX, + 'overflow-y': this.overflowY + }); + }, + + + // Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'. + // Useful for preserving scrollbar widths regardless of future resizes. + // Can pass in scrollbarWidths for optimization. + lockOverflow: function(scrollbarWidths) { + var overflowX = this.overflowX; + var overflowY = this.overflowY; + + scrollbarWidths = scrollbarWidths || this.getScrollbarWidths(); + + if (overflowX === 'auto') { + overflowX = ( + scrollbarWidths.top || scrollbarWidths.bottom || // horizontal scrollbars? + // OR scrolling pane with massless scrollbars? + this.scrollEl[0].scrollWidth - 1 > this.scrollEl[0].clientWidth + // subtract 1 because of IE off-by-one issue + ) ? 'scroll' : 'hidden'; + } + + if (overflowY === 'auto') { + overflowY = ( + scrollbarWidths.left || scrollbarWidths.right || // vertical scrollbars? + // OR scrolling pane with massless scrollbars? + this.scrollEl[0].scrollHeight - 1 > this.scrollEl[0].clientHeight + // subtract 1 because of IE off-by-one issue + ) ? 'scroll' : 'hidden'; + } + + this.scrollEl.css({ 'overflow-x': overflowX, 'overflow-y': overflowY }); + }, + + + // Getters / Setters + // ----------------------------------------------------------------------------------------------------------------- + + + setHeight: function(height) { + this.scrollEl.height(height); + }, + + + getScrollTop: function() { + return this.scrollEl.scrollTop(); + }, + + + setScrollTop: function(top) { + this.scrollEl.scrollTop(top); + }, + + + getClientWidth: function() { + return this.scrollEl[0].clientWidth; + }, + + + getClientHeight: function() { + return this.scrollEl[0].clientHeight; + }, + + + getScrollbarWidths: function() { + return getScrollbarWidths(this.scrollEl); + } + +}); + +;; + var Calendar = FC.Calendar = Class.extend({ dirDefaults: null, // option defaults related to LTR or RTL @@ -8608,7 +9304,7 @@ var Calendar = FC.Calendar = Class.extend({ }); -Calendar.mixin(Emitter); +Calendar.mixin(EmitterMixin); function Calendar_constructor(element, overrides) { @@ -9369,7 +10065,9 @@ Calendar.defaults = { dayPopoverFormat: 'LL', handleWindowResize: true, - windowResizeDelay: 200 // milliseconds before an updateSize happens + windowResizeDelay: 200, // milliseconds before an updateSize happens + + longPressDelay: 1000 }; @@ -10368,6 +11066,8 @@ function EventManager(options) { // assumed to be a calendar assignDatesToEvent(start, end, allDay, out); } + t.normalizeEvent(out); // hook for external use. a prototype method + return out; } @@ -10900,6 +11600,12 @@ function EventManager(options) { // assumed to be a calendar } +// hook for external libs to manipulate event properties upon creation. +// should manipulate the event in-place. +Calendar.prototype.normalizeEvent = function(event) { +}; + + // Returns a list of events that the given event should be compared against when being considered for a move to // the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar. Calendar.prototype.getPeerEvents = function(span, event) { @@ -10937,6 +11643,8 @@ function backupEventDates(event) { var BasicView = FC.BasicView = View.extend({ + scroller: null, + dayGridClass: DayGrid, // class the dayGrid will be instantiated from (overridable by subclasses) dayGrid: null, // the main subcomponent that does most of the heavy lifting @@ -10951,6 +11659,11 @@ var BasicView = FC.BasicView = View.extend({ initialize: function() { this.dayGrid = this.instantiateDayGrid(); + + this.scroller = new Scroller({ + overflowX: 'hidden', + overflowY: 'auto' + }); }, @@ -11003,9 +11716,12 @@ var BasicView = FC.BasicView = View.extend({ this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml()); this.renderHead(); - this.scrollerEl = this.el.find('.fc-day-grid-container'); + this.scroller.render(); + var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container'); + var dayGridEl = $('<div class="fc-day-grid" />').appendTo(dayGridContainerEl); + this.el.find('.fc-body > tr > td').append(dayGridContainerEl); - this.dayGrid.setElement(this.el.find('.fc-day-grid')); + this.dayGrid.setElement(dayGridEl); this.dayGrid.renderDates(this.hasRigidRows()); }, @@ -11024,6 +11740,7 @@ var BasicView = FC.BasicView = View.extend({ unrenderDates: function() { this.dayGrid.unrenderDates(); this.dayGrid.removeElement(); + this.scroller.destroy(); }, @@ -11044,11 +11761,7 @@ var BasicView = FC.BasicView = View.extend({ '</thead>' + '<tbody class="fc-body">' + '<tr>' + - '<td class="' + this.widgetContentClass + '">' + - '<div class="fc-day-grid-container">' + - '<div class="fc-day-grid"/>' + - '</div>' + - '</td>' + + '<td class="' + this.widgetContentClass + '"></td>' + '</tr>' + '</tbody>' + '</table>'; @@ -11091,9 +11804,10 @@ var BasicView = FC.BasicView = View.extend({ setHeight: function(totalHeight, isAuto) { var eventLimit = this.opt('eventLimit'); var scrollerHeight; + var scrollbarWidths; // reset all heights to be natural - unsetScroller(this.scrollerEl); + this.scroller.clear(); uncompensateScroll(this.headRowEl); this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed @@ -11103,6 +11817,8 @@ var BasicView = FC.BasicView = View.extend({ this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after } + // distribute the height to the rows + // (totalHeight is a "recommended" value if isAuto) scrollerHeight = this.computeScrollerHeight(totalHeight); this.setGridHeight(scrollerHeight, isAuto); @@ -11111,17 +11827,33 @@ var BasicView = FC.BasicView = View.extend({ this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set } - if (!isAuto && setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars? + if (!isAuto) { // should we force dimensions of the scroll container? - compensateScroll(this.headRowEl, getScrollbarWidths(this.scrollerEl)); + this.scroller.setHeight(scrollerHeight); + scrollbarWidths = this.scroller.getScrollbarWidths(); - // doing the scrollbar compensation might have created text overflow which created more height. redo - scrollerHeight = this.computeScrollerHeight(totalHeight); - this.scrollerEl.height(scrollerHeight); + if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars? + + compensateScroll(this.headRowEl, scrollbarWidths); + + // doing the scrollbar compensation might have created text overflow which created more height. redo + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.scroller.setHeight(scrollerHeight); + } + + // guarantees the same scrollbar widths + this.scroller.lockOverflow(scrollbarWidths); } }, + // given a desired total height of the view, returns what the height of the scroller should be + computeScrollerHeight: function(totalHeight) { + return totalHeight - + subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller + }, + + // Sets the height of just the DayGrid component in this view setGridHeight: function(height, isAuto) { if (isAuto) { @@ -11133,6 +11865,20 @@ var BasicView = FC.BasicView = View.extend({ }, + /* Scroll + ------------------------------------------------------------------------------------------------------------------*/ + + + queryScroll: function() { + return this.scroller.getScrollTop(); + }, + + + setScroll: function(top) { + this.scroller.setScrollTop(top); + }, + + /* Hit Areas ------------------------------------------------------------------------------------------------------------------*/ // forward all hit-related method calls to dayGrid @@ -11368,6 +12114,8 @@ fcViews.month = { var AgendaView = FC.AgendaView = View.extend({ + scroller: null, + timeGridClass: TimeGrid, // class used to instantiate the timeGrid. subclasses can override timeGrid: null, // the main time-grid subcomponent of this view @@ -11377,11 +12125,10 @@ var AgendaView = FC.AgendaView = View.extend({ axisWidth: null, // the width of the time axis running down the side headContainerEl: null, // div that hold's the timeGrid's rendered date header - noScrollRowEls: null, // set of fake row elements that must compensate when scrollerEl has scrollbars + noScrollRowEls: null, // set of fake row elements that must compensate when scroller has scrollbars // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath bottomRuleEl: null, - bottomRuleHeight: null, initialize: function() { @@ -11390,6 +12137,11 @@ var AgendaView = FC.AgendaView = View.extend({ if (this.opt('allDaySlot')) { // should we display the "all-day" area? this.dayGrid = this.instantiateDayGrid(); // the all-day subcomponent of this view } + + this.scroller = new Scroller({ + overflowX: 'hidden', + overflowY: 'auto' + }); }, @@ -11430,10 +12182,12 @@ var AgendaView = FC.AgendaView = View.extend({ this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml()); this.renderHead(); - // the element that wraps the time-grid that will probably scroll - this.scrollerEl = this.el.find('.fc-time-grid-container'); + this.scroller.render(); + var timeGridWrapEl = this.scroller.el.addClass('fc-time-grid-container'); + var timeGridEl = $('<div class="fc-time-grid" />').appendTo(timeGridWrapEl); + this.el.find('.fc-body > tr > td').append(timeGridWrapEl); - this.timeGrid.setElement(this.el.find('.fc-time-grid')); + this.timeGrid.setElement(timeGridEl); this.timeGrid.renderDates(); // the <hr> that sometimes displays under the time-grid @@ -11470,6 +12224,8 @@ var AgendaView = FC.AgendaView = View.extend({ this.dayGrid.unrenderDates(); this.dayGrid.removeElement(); } + + this.scroller.destroy(); }, @@ -11491,9 +12247,6 @@ var AgendaView = FC.AgendaView = View.extend({ '<hr class="fc-divider ' + this.widgetHeaderClass + '"/>' : '' ) + - '<div class="fc-time-grid-container">' + - '<div class="fc-time-grid"/>' + - '</div>' + '</td>' + '</tr>' + '</tbody>' + @@ -11573,16 +12326,11 @@ var AgendaView = FC.AgendaView = View.extend({ setHeight: function(totalHeight, isAuto) { var eventLimit; var scrollerHeight; - - 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 <hr> is necessary + var scrollbarWidths; // reset all dimensions back to the original state - this.scrollerEl.css('overflow', ''); - unsetScroller(this.scrollerEl); + this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary + this.scroller.clear(); // sets height to 'auto' and clears overflow uncompensateScroll(this.noScrollRowEls); // limit number of events in the all-day area @@ -11598,28 +12346,46 @@ var AgendaView = FC.AgendaView = View.extend({ } } - if (!isAuto) { // should we force dimensions of the scroll container, or let the contents be natural height? + if (!isAuto) { // should we force dimensions of the scroll container? scrollerHeight = this.computeScrollerHeight(totalHeight); - if (setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars? + this.scroller.setHeight(scrollerHeight); + scrollbarWidths = this.scroller.getScrollbarWidths(); + + if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars? // make the all-day and header rows lines up - compensateScroll(this.noScrollRowEls, getScrollbarWidths(this.scrollerEl)); + compensateScroll(this.noScrollRowEls, scrollbarWidths); // 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); + this.scroller.setHeight(scrollerHeight); } - else { // no scrollbars - // still, force a height and display the bottom rule (marks the end of day) - this.scrollerEl.height(scrollerHeight).css('overflow', 'hidden'); // in case <hr> goes outside + + // guarantees the same scrollbar widths + this.scroller.lockOverflow(scrollbarWidths); + + // if there's any space below the slats, show the horizontal rule. + // this won't cause any new overflow, because lockOverflow already called. + if (this.timeGrid.getTotalSlatHeight() < scrollerHeight) { this.bottomRuleEl.show(); } } }, + // given a desired total height of the view, returns what the height of the scroller should be + computeScrollerHeight: function(totalHeight) { + return totalHeight - + subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller + }, + + + /* Scroll + ------------------------------------------------------------------------------------------------------------------*/ + + // Computes the initial pre-configured scroll state prior to allowing the user to change it computeInitialScroll: function() { var scrollTime = moment.duration(this.opt('scrollTime')); @@ -11636,6 +12402,16 @@ var AgendaView = FC.AgendaView = View.extend({ }, + queryScroll: function() { + return this.scroller.getScrollTop(); + }, + + + setScroll: function(top) { + this.scroller.setScrollTop(top); + }, + + /* Hit Areas ------------------------------------------------------------------------------------------------------------------*/ // forward all hit-related method calls to the grids (dayGrid might not be defined) |