From a1ef698cf2b97b91294c5460a5d8bcdf8f97e2fc Mon Sep 17 00:00:00 2001 From: Mario Vavti Date: Thu, 23 Mar 2017 13:24:39 +0100 Subject: update fullcalendar library to version 3.2.0 --- library/fullcalendar/fullcalendar.js | 1109 +++++++++++++++++++++++----------- 1 file changed, 751 insertions(+), 358 deletions(-) (limited to 'library/fullcalendar/fullcalendar.js') diff --git a/library/fullcalendar/fullcalendar.js b/library/fullcalendar/fullcalendar.js index b7371e25f..04399302b 100644 --- a/library/fullcalendar/fullcalendar.js +++ b/library/fullcalendar/fullcalendar.js @@ -1,7 +1,7 @@ /*! - * FullCalendar v3.1.0 - * Docs & License: http://fullcalendar.io/ - * (c) 2016 Adam Shaw + * FullCalendar v3.2.0 + * Docs & License: https://fullcalendar.io/ + * (c) 2017 Adam Shaw */ (function(factory) { @@ -19,8 +19,11 @@ ;; var FC = $.fullCalendar = { - version: "3.1.0", - internalApiVersion: 7 + version: "3.2.0", + // When introducing internal API incompatibilities (where fullcalendar plugins would break), + // the minor version of the calendar should be upped (ex: 2.7.2 -> 2.8.0) + // and the below integer should be incremented. + internalApiVersion: 8 }; var fcViews = FC.views = {}; @@ -313,12 +316,13 @@ function getContentRect(el, origin) { // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. function getScrollbarWidths(el) { var leftRightWidth = el.innerWidth() - el[0].clientWidth; // the paddings cancel out, leaving the scrollbars - var widths = { - left: 0, - right: 0, - top: 0, - bottom: el.innerHeight() - el[0].clientHeight // the paddings cancel out, leaving the bottom scrollbar - }; + var bottomWidth = el.innerHeight() - el[0].clientHeight; // " + var widths; + + leftRightWidth = sanitizeScrollbarWidth(leftRightWidth); + bottomWidth = sanitizeScrollbarWidth(bottomWidth); + + widths = { left: 0, right: 0, top: 0, bottom: bottomWidth }; if (getIsLeftRtlScrollbars() && el.css('direction') == 'rtl') { // is the scrollbar on the left side? widths.left = leftRightWidth; @@ -331,6 +335,15 @@ function getScrollbarWidths(el) { } +// The scrollbar width computations in getScrollbarWidths are sometimes flawed when it comes to +// retina displays, rounding, and IE11. Massage them into a usable value. +function sanitizeScrollbarWidth(width) { + width = Math.max(0, width); // no negatives + width = Math.round(width); + return width; +} + + // Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side var _isLeftRtlScrollbars = null; @@ -381,24 +394,28 @@ function isPrimaryMouseButton(ev) { function getEvX(ev) { - if (ev.pageX !== undefined) { - return ev.pageX; - } var touches = ev.originalEvent.touches; - if (touches) { + + // on mobile FF, pageX for touch events is present, but incorrect, + // so, look at touch coordinates first. + if (touches && touches.length) { return touches[0].pageX; } + + return ev.pageX; } function getEvY(ev) { - if (ev.pageY !== undefined) { - return ev.pageY; - } var touches = ev.originalEvent.touches; - if (touches) { + + // on mobile FF, pageX for touch events is present, but incorrect, + // so, look at touch coordinates first. + if (touches && touches.length) { return touches[0].pageY; } + + return ev.pageY; } @@ -413,33 +430,15 @@ function preventSelection(el) { } -// 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; +function allowSelection(el) { + el.removeClass('fc-unselectable') + .off('selectstart', preventDefault); } -// 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; +// Stops a mouse/touch event from doing it's native browser action +function preventDefault(ev) { + ev.preventDefault(); } @@ -1329,38 +1328,42 @@ newMomentProto.toISOString = function() { }; ;; +(function() { -// Single Date Formatting -// ------------------------------------------------------------------------------------------------- - - -// call this if you want Moment's original format method to be used -function oldMomentFormat(mom, formatStr) { - return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js -} - +// exports +FC.formatDate = formatDate; +FC.formatRange = formatRange; +FC.oldMomentFormat = oldMomentFormat; +FC.queryMostGranularFormatUnit = queryMostGranularFormatUnit; -// Formats `date` with a Moment formatting string, but allow our non-zero areas and -// additional token. -function formatDate(date, formatStr) { - return formatDateWithChunks(date, getFormatStringChunks(formatStr)); -} +// Config +// --------------------------------------------------------------------------------------------------------------------- -function formatDateWithChunks(date, chunks) { - var s = ''; - var i; - - for (i=0; i "MMMM D YYYY" - formatStr = localeData.longDateFormat(formatStr) || formatStr; + // Expand localized format strings, like "LL" -> "MMMM D YYYY". // BTW, this is not important for `formatDate` because it is impossible to put custom tokens // or non-zero areas in Moment's localized format strings. + formatStr = localeData.longDateFormat(formatStr) || formatStr; - separator = separator || ' - '; - - return formatRangeWithChunks( + return renderParsedFormat( + getParsedFormatString(formatStr), date1, date2, - getFormatStringChunks(formatStr), - separator, + separator || ' - ', isRTL ); } -FC.formatRange = formatRange; // expose - -function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { - var unzonedDate1 = date1.clone().stripZone(); // for formatSimilarChunk +/* +Renders a range with an already-parsed format string. +*/ +function renderParsedFormat(parsedFormat, date1, date2, separator, isRTL) { + var sameUnits = parsedFormat.sameUnits; + var unzonedDate1 = date1.clone().stripZone(); // for same-unit comparisons var unzonedDate2 = date2.clone().stripZone(); // " - var chunkStr; // the rendering of the chunk + + var renderedParts1 = renderFakeFormatStringParts(parsedFormat.fakeFormatString, date1); + var renderedParts2 = renderFakeFormatStringParts(parsedFormat.fakeFormatString, date2); + var leftI; var leftStr = ''; var rightI; @@ -1443,28 +1462,35 @@ function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { // Start at the leftmost side of the formatting string and continue until you hit a token // that is not the same between dates. - for (leftI=0; leftIleftI; rightI--) { - chunkStr = formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunks[rightI]); - if (chunkStr === false) { + for ( + rightI = sameUnits.length - 1; + rightI > leftI && (!sameUnits[rightI] || unzonedDate1.isSame(unzonedDate2, sameUnits[rightI])); + rightI-- + ) { + // If current chunk is on the boundary of unique date-content, and is a special-case + // date-formatting postfix character, then don't consume it. Consider it unique date-content. + // TODO: make configurable + if (rightI - 1 === leftI && renderedParts1[rightI] === '.') { break; } - rightStr = chunkStr + rightStr; + + rightStr = renderedParts1[rightI] + rightStr; } // The area in the middle is different for both of the dates. // Collect them distinctly so we can jam them together later. - for (middleI=leftI; middleI<=rightI; middleI++) { - middleStr1 += formatDateWithChunk(date1, chunks[middleI]); - middleStr2 += formatDateWithChunk(date2, chunks[middleI]); + for (middleI = leftI; middleI <= rightI; middleI++) { + middleStr1 += renderedParts1[middleI]; + middleStr2 += renderedParts2[middleI]; } if (middleStr1 || middleStr2) { @@ -1476,77 +1502,59 @@ function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { } } - return leftStr + middleStr + rightStr; + return processMaybeMarkers( + leftStr + middleStr + rightStr + ); } -var similarUnitMap = { - Y: 'year', - M: 'month', - D: 'day', // day of month - d: 'day', // day of week - // prevents a separator between anything time-related... - A: 'second', // AM/PM - a: 'second', // am/pm - T: 'second', // A/P - t: 'second', // a/p - H: 'second', // hour (24) - h: 'second', // hour (12) - m: 'second', // minute - s: 'second' // second -}; -// TODO: week maybe? - - -// Given a formatting chunk, and given that both dates are similar in the regard the -// formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`. -function formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunk) { - var token; - var unit; +// Format String Parsing +// --------------------------------------------------------------------------------------------------------------------- - if (typeof chunk === 'string') { // a literal string - return chunk; - } - else if ((token = chunk.token)) { - unit = similarUnitMap[token.charAt(0)]; +var parsedFormatStrCache = {}; - // are the dates the same for this unit of measurement? - // use the unzoned dates for this calculation because unreliable when near DST (bug #2396) - if (unit && unzonedDate1.isSame(unzonedDate2, unit)) { - return oldMomentFormat(date1, token); // would be the same if we used `date2` - // BTW, don't support custom tokens - } - } - - return false; // the chunk is NOT the same for the two dates - // BTW, don't support splitting on non-zero areas +/* +Returns a parsed format string, leveraging a cache. +*/ +function getParsedFormatString(formatStr) { + return parsedFormatStrCache[formatStr] || + (parsedFormatStrCache[formatStr] = parseFormatString(formatStr)); } - -// Chunking Utils -// ------------------------------------------------------------------------------------------------- - - -var formatStringChunkCache = {}; - - -function getFormatStringChunks(formatStr) { - if (formatStr in formatStringChunkCache) { - return formatStringChunkCache[formatStr]; - } - return (formatStringChunkCache[formatStr] = chunkFormatString(formatStr)); +/* +Parses a format string into the following: +- fakeFormatString: a momentJS formatting string, littered with special control characters that get post-processed. +- sameUnits: for every part in fakeFormatString, if the part is a token, the value will be a unit string (like "day"), + that indicates how similar a range's start & end must be in order to share the same formatted text. + If not a token, then the value is null. + Always a flat array (not nested liked "chunks"). +*/ +function parseFormatString(formatStr) { + var chunks = chunkFormatString(formatStr); + + return { + fakeFormatString: buildFakeFormatString(chunks), + sameUnits: buildSameUnits(chunks) + }; } - -// Break the formatting string into an array of chunks +/* +Break the formatting string into an array of chunks. +A 'maybe' chunk will have nested chunks. +*/ function chunkFormatString(formatStr) { var chunks = []; - var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g; // TODO: more descrimination var match; + // TODO: more descrimination + // \4 is a backreference to the first character of a multi-character set. + var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g; + while ((match = chunker.exec(formatStr))) { if (match[1]) { // a literal string inside [ ... ] - chunks.push(match[1]); + chunks.push.apply(chunks, // append + splitStringLiteral(match[1]) + ); } else if (match[2]) { // non-zero formatting inside ( ... ) chunks.push({ maybe: chunkFormatString(match[2]) }); @@ -1555,41 +1563,166 @@ function chunkFormatString(formatStr) { chunks.push({ token: match[3] }); } else if (match[5]) { // an unenclosed literal string - chunks.push(match[5]); + chunks.push.apply(chunks, // append + splitStringLiteral(match[5]) + ); } } return chunks; } +/* +Potentially splits a literal-text string into multiple parts. For special cases. +*/ +function splitStringLiteral(s) { + if (s === '. ') { + return [ '.', ' ' ]; // for locales with periods bound to the end of each year/month/date + } + else { + return [ s ]; + } +} -// Misc Utils -// ------------------------------------------------------------------------------------------------- +/* +Given chunks parsed from a real format string, generate a fake (aka "intermediate") format string with special control +characters that will eventually be given to moment for formatting, and then post-processed. +*/ +function buildFakeFormatString(chunks) { + var parts = []; + var i, chunk; + for (i = 0; i < chunks.length; i++) { + chunk = chunks[i]; -// granularity only goes up until day -// TODO: unify with similarUnitMap -var tokenGranularities = { - Y: { value: 1, unit: 'year' }, - M: { value: 2, unit: 'month' }, - W: { value: 3, unit: 'week' }, - w: { value: 3, unit: 'week' }, - D: { value: 4, unit: 'day' }, // day of month - d: { value: 4, unit: 'day' } // day of week -}; + if (typeof chunk === 'string') { + parts.push('[' + chunk + ']'); + } + else if (chunk.token) { + if (chunk.token in specialTokens) { + parts.push( + SPECIAL_TOKEN_MARKER + // useful during post-processing + '[' + chunk.token + ']' // preserve as literal text + ); + } + else { + parts.push(chunk.token); // unprotected text implies a format string + } + } + else if (chunk.maybe) { + parts.push( + MAYBE_MARKER + // useful during post-processing + buildFakeFormatString(chunk.maybe) + + MAYBE_MARKER + ); + } + } + + return parts.join(PART_SEPARATOR); +} -// returns a unit string, either 'year', 'month', 'day', or null -// for the most granular formatting token in the string. -FC.queryMostGranularFormatUnit = function(formatStr) { - var chunks = getFormatStringChunks(formatStr); +/* +Given parsed chunks from a real formatting string, generates an array of unit strings (like "day") that indicate +in which regard two dates must be similar in order to share range formatting text. +The `chunks` can be nested (because of "maybe" chunks), however, the returned array will be flat. +*/ +function buildSameUnits(chunks) { + var units = []; + var i, chunk; + var tokenInfo; + + for (i = 0; i < chunks.length; i++) { + chunk = chunks[i]; + + if (chunk.token) { + tokenInfo = largeTokenMap[chunk.token.charAt(0)]; + units.push(tokenInfo ? tokenInfo.unit : 'second'); // default to a very strict same-second + } + else if (chunk.maybe) { + units.push.apply(units, // append + buildSameUnits(chunk.maybe) + ); + } + else { + units.push(null); + } + } + + return units; +} + + +// Rendering to text +// --------------------------------------------------------------------------------------------------------------------- + +/* +Formats a date with a fake format string, post-processes the control characters, then returns. +*/ +function renderFakeFormatString(fakeFormatString, date) { + return processMaybeMarkers( + renderFakeFormatStringParts(fakeFormatString, date).join('') + ); +} + +/* +Formats a date into parts that will have been post-processed, EXCEPT for the "maybe" markers. +*/ +function renderFakeFormatStringParts(fakeFormatString, date) { + var parts = []; + var fakeRender = oldMomentFormat(date, fakeFormatString); + var fakeParts = fakeRender.split(PART_SEPARATOR); + var i, fakePart; + + for (i = 0; i < fakeParts.length; i++) { + fakePart = fakeParts[i]; + + if (fakePart.charAt(0) === SPECIAL_TOKEN_MARKER) { + parts.push( + // the literal string IS the token's name. + // call special token's registered function. + specialTokens[fakePart.substring(1)](date) + ); + } + else { + parts.push(fakePart); + } + } + + return parts; +} + +/* +Accepts an almost-finally-formatted string and processes the "maybe" control characters, returning a new string. +*/ +function processMaybeMarkers(s) { + return s.replace(MAYBE_REGEXP, function(m0, m1) { // regex assumed to have 'g' flag + if (m1.match(/[1-9]/)) { // any non-zero numeric characters? + return m1; + } + else { + return ''; + } + }); +} + + +// Misc Utils +// ------------------------------------------------------------------------------------------------- + +/* +Returns a unit string, either 'year', 'month', 'day', or null for the most granular formatting token in the string. +*/ +function queryMostGranularFormatUnit(formatStr) { + var chunks = chunkFormatString(formatStr); var i, chunk; var candidate; var best; for (i = 0; i < chunks.length; i++) { chunk = chunks[i]; + if (chunk.token) { - candidate = tokenGranularities[chunk.token.charAt(0)]; + candidate = largeTokenMap[chunk.token.charAt(0)]; if (candidate) { if (!best || candidate.value > best.value) { best = candidate; @@ -1605,6 +1738,13 @@ FC.queryMostGranularFormatUnit = function(formatStr) { return null; }; +})(); + +// quick local references +var formatDate = FC.formatDate; +var formatRange = FC.formatRange; +var oldMomentFormat = FC.oldMomentFormat; + ;; FC.Class = Class; // export @@ -1998,35 +2138,6 @@ var ListenerMixin = FC.ListenerMixin = (function() { })(); ;; -// 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 ------------------------------------------------------------------------------------------------------------------------ Options: @@ -2457,7 +2568,7 @@ var CoordCache = FC.CoordCache = Class.extend({ ----------------------------------------------------------------------------------------------------------------------*/ // TODO: use Emitter -var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMixin, { +var DragListener = FC.DragListener = Class.extend(ListenerMixin, { options: null, subjectEl: null, @@ -2480,13 +2591,12 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMix delayTimeoutId: null, minDistance: null, - handleTouchScrollProxy: null, // calls handleTouchScroll, always bound to `this` + shouldCancelTouchScroll: true, + scrollAlwaysKills: false, constructor: function(options) { this.options = options || {}; - this.handleTouchScrollProxy = proxy(this, 'handleTouchScroll'); - this.initMouseIgnoring(500); }, @@ -2498,7 +2608,7 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMix var isTouch = getEvIsTouch(ev); if (ev.type === 'mousedown') { - if (this.isIgnoringMouse) { + if (GlobalEmitter.get().shouldIgnoreMouse()) { return; } else if (!isPrimaryMouseButton(ev)) { @@ -2517,6 +2627,8 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMix this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0); this.subjectEl = this.options.subjectEl; + preventSelection($('body')); + this.isInteracting = true; this.isTouch = isTouch; this.isDelayEnded = false; @@ -2558,12 +2670,7 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMix this.isInteracting = false; this.handleInteractionEnd(ev, isCancelled); - // 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(); - } + allowSelection($('body')); } }, @@ -2578,45 +2685,25 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMix bindHandlers: function() { - var _this = this; - var touchStartIgnores = 1; + // some browsers (Safari in iOS 10) don't allow preventDefault on touch events that are bound after touchstart, + // so listen to the GlobalEmitter singleton, which is always bound, instead of the document directly. + var globalEmitter = GlobalEmitter.get(); if (this.isTouch) { - this.listenTo($(document), { + this.listenTo(globalEmitter, { 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 - } - } + scroll: this.handleTouchScroll }); - - // 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), { + this.listenTo(globalEmitter, { mousemove: this.handleMouseMove, mouseup: this.endInteraction }); } - this.listenTo($(document), { + this.listenTo(globalEmitter, { selectstart: preventDefault, // don't allow selection while dragging contextmenu: preventDefault // long taps would open menu on Chrome dev tools }); @@ -2624,13 +2711,7 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMix unbindHandlers: function() { - this.stopListeningTo($(document)); - - // unbind scroll listening - unbindAnyScroll(this.handleTouchScrollProxy); - if (this.scrollEl) { - this.stopListeningTo(this.scrollEl, 'scroll'); - } + this.stopListeningTo(GlobalEmitter.get()); }, @@ -2738,8 +2819,9 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMix handleTouchMove: function(ev) { + // prevent inertia and touchmove-scrolling while dragging - if (this.isDragging) { + if (this.isDragging && this.shouldCancelTouchScroll) { ev.preventDefault(); } @@ -2759,7 +2841,7 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMix 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) { + if (!this.isDragging || this.scrollAlwaysKills) { this.endInteraction(ev, true); // isCancelled=true } }, @@ -2982,7 +3064,7 @@ options: var HitDragListener = DragListener.extend({ component: null, // converts coordinates to hits - // methods: prepareHits, releaseHits, queryHit + // methods: hitsNeeded, hitsNotNeeded, queryHit origHit: null, // the hit the mouse was over when listening started hit: null, // the hit the mouse is over @@ -3004,7 +3086,8 @@ var HitDragListener = DragListener.extend({ var origPoint; var point; - this.computeCoords(); + this.component.hitsNeeded(); + this.computeScrollBounds(); // for autoscroll if (ev) { origPoint = { left: getEvX(ev), top: getEvY(ev) }; @@ -3043,13 +3126,6 @@ var HitDragListener = DragListener.extend({ }, - // Recomputes the drag-critical positions of elements - computeCoords: function() { - this.component.prepareHits(); - this.computeScrollBounds(); // why is this here?????? - }, - - // Called when the actual drag has started handleDragStart: function(ev) { var hit; @@ -3128,7 +3204,7 @@ var HitDragListener = DragListener.extend({ this.origHit = null; this.hit = null; - this.component.releaseHits(); + this.component.hitsNotNeeded(); }, @@ -3136,7 +3212,12 @@ var HitDragListener = DragListener.extend({ handleScrollEnd: function() { DragListener.prototype.handleScrollEnd.apply(this, arguments); // call the super-method - this.computeCoords(); // hits' absolute positions will be in new places. recompute + // hits' absolute positions will be in new places after a user's scroll. + // HACK for recomputing. + if (this.isDragging) { + this.component.releaseHits(); + this.component.prepareHits(); + } }, @@ -3186,6 +3267,231 @@ function isHitPropsWithin(subHit, superHit) { ;; +/* +Listens to document and window-level user-interaction events, like touch events and mouse events, +and fires these events as-is to whoever is observing a GlobalEmitter. +Best when used as a singleton via GlobalEmitter.get() + +Normalizes mouse/touch events. For examples: +- ignores the the simulated mouse events that happen after a quick tap: mousemove+mousedown+mouseup+click +- compensates for various buggy scenarios where a touchend does not fire +*/ + +FC.touchMouseIgnoreWait = 500; + +var GlobalEmitter = Class.extend(ListenerMixin, EmitterMixin, { + + isTouching: false, + mouseIgnoreDepth: 0, + handleScrollProxy: null, + + + bind: function() { + var _this = this; + + this.listenTo($(document), { + touchstart: this.handleTouchStart, + touchcancel: this.handleTouchCancel, + touchend: this.handleTouchEnd, + mousedown: this.handleMouseDown, + mousemove: this.handleMouseMove, + mouseup: this.handleMouseUp, + click: this.handleClick, + selectstart: this.handleSelectStart, + contextmenu: this.handleContextMenu + }); + + // because we need to call preventDefault + // because https://www.chromestatus.com/features/5093566007214080 + // TODO: investigate performance because this is a global handler + window.addEventListener( + 'touchmove', + this.handleTouchMoveProxy = function(ev) { + _this.handleTouchMove($.Event(ev)); + }, + { passive: false } // allows 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 + window.addEventListener( + 'scroll', + this.handleScrollProxy = function(ev) { + _this.handleScroll($.Event(ev)); + }, + true // useCapture + ); + }, + + unbind: function() { + this.stopListeningTo($(document)); + + window.removeEventListener( + 'touchmove', + this.handleTouchMoveProxy + ); + + window.removeEventListener( + 'scroll', + this.handleScrollProxy, + true // useCapture + ); + }, + + + // Touch Handlers + // ----------------------------------------------------------------------------------------------------------------- + + handleTouchStart: function(ev) { + + // if a previous touch interaction never ended with a touchend, then implicitly end it, + // but since a new touch interaction is about to begin, don't start the mouse ignore period. + this.stopTouch(ev, true); // skipMouseIgnore=true + + this.isTouching = true; + this.trigger('touchstart', ev); + }, + + handleTouchMove: function(ev) { + if (this.isTouching) { + this.trigger('touchmove', ev); + } + }, + + handleTouchCancel: function(ev) { + if (this.isTouching) { + this.trigger('touchcancel', ev); + + // Have touchcancel fire an artificial touchend. That way, handlers won't need to listen to both. + // If touchend fires later, it won't have any effect b/c isTouching will be false. + this.stopTouch(ev); + } + }, + + handleTouchEnd: function(ev) { + this.stopTouch(ev); + }, + + + // Mouse Handlers + // ----------------------------------------------------------------------------------------------------------------- + + handleMouseDown: function(ev) { + if (!this.shouldIgnoreMouse()) { + this.trigger('mousedown', ev); + } + }, + + handleMouseMove: function(ev) { + if (!this.shouldIgnoreMouse()) { + this.trigger('mousemove', ev); + } + }, + + handleMouseUp: function(ev) { + if (!this.shouldIgnoreMouse()) { + this.trigger('mouseup', ev); + } + }, + + handleClick: function(ev) { + if (!this.shouldIgnoreMouse()) { + this.trigger('click', ev); + } + }, + + + // Misc Handlers + // ----------------------------------------------------------------------------------------------------------------- + + handleSelectStart: function(ev) { + this.trigger('selectstart', ev); + }, + + handleContextMenu: function(ev) { + this.trigger('contextmenu', ev); + }, + + handleScroll: function(ev) { + this.trigger('scroll', ev); + }, + + + // Utils + // ----------------------------------------------------------------------------------------------------------------- + + stopTouch: function(ev, skipMouseIgnore) { + if (this.isTouching) { + this.isTouching = false; + this.trigger('touchend', ev); + + if (!skipMouseIgnore) { + this.startTouchMouseIgnore(); + } + } + }, + + startTouchMouseIgnore: function() { + var _this = this; + var wait = FC.touchMouseIgnoreWait; + + if (wait) { + this.mouseIgnoreDepth++; + setTimeout(function() { + _this.mouseIgnoreDepth--; + }, wait); + } + }, + + shouldIgnoreMouse: function() { + return this.isTouching || Boolean(this.mouseIgnoreDepth); + } + +}); + + +// Singleton +// --------------------------------------------------------------------------------------------------------------------- + +(function() { + var globalEmitter = null; + var neededCount = 0; + + + // gets the singleton + GlobalEmitter.get = function() { + + if (!globalEmitter) { + globalEmitter = new GlobalEmitter(); + globalEmitter.bind(); + } + + return globalEmitter; + }; + + + // called when an object knows it will need a GlobalEmitter in the near future. + GlobalEmitter.needed = function() { + GlobalEmitter.get(); // ensures globalEmitter + neededCount++; + }; + + + // called when the object that originally called needed() doesn't need a GlobalEmitter anymore. + GlobalEmitter.unneeded = function() { + neededCount--; + + if (!neededCount) { // nobody else needs it + globalEmitter.unbind(); + globalEmitter = null; + } + }; + +})(); + +;; + /* Creates a clone of an element and lets it track the mouse as it moves ----------------------------------------------------------------------------------------------------------------------*/ @@ -3383,7 +3689,7 @@ var MouseFollower = Class.extend(ListenerMixin, { /* An abstract class comprised of a "grid" of areas that each represent a specific datetime ----------------------------------------------------------------------------------------------------------------------*/ -var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { +var Grid = FC.Grid = Class.extend(ListenerMixin, { // self-config, overridable by subclasses hasDayInteractions: true, // can user click/select ranges of time? @@ -3409,7 +3715,8 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { // TODO: port isTimeScale into same system? largeUnit: null, - dayDragListener: null, + dayClickListener: null, + daySelectListener: null, segDragListener: null, segResizeListener: null, externalDragListener: null, @@ -3420,8 +3727,8 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { this.isRTL = view.opt('isRTL'); this.elsByFill = {}; - this.dayDragListener = this.buildDayDragListener(); - this.initMouseIgnoring(); + this.dayClickListener = this.buildDayClickListener(); + this.daySelectListener = this.buildDaySelectListener(); }, @@ -3516,6 +3823,20 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { /* Hit Area ------------------------------------------------------------------------------------------------------------------*/ + hitsNeededDepth: 0, // necessary because multiple callers might need the same hits + + hitsNeeded: function() { + if (!(this.hitsNeededDepth++)) { + this.prepareHits(); + } + }, + + hitsNotNeeded: function() { + if (this.hitsNeededDepth && !(--this.hitsNeededDepth)) { + this.releaseHits(); + } + }, + // Called before one or more queryHit calls might happen. Should prepare any cached coordinates for queryHit prepareHits: function() { @@ -3643,9 +3964,19 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { // 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 + var view = this.view; + + // prevent a user's clickaway for unselecting a range or an event from + // causing a dayClick or starting an immediate new selection. + if (view.isSelected || view.selectedEvent) { + return; + } + + this.dayClickListener.startInteraction(ev); + + if (view.opt('selectable')) { + this.daySelectListener.startInteraction(ev, { + distance: view.opt('selectMinDistance') }); } }, @@ -3653,40 +3984,79 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { dayTouchStart: function(ev) { var view = this.view; - var selectLongPressDelay = view.opt('selectLongPressDelay'); + var selectLongPressDelay; - // HACK to prevent a user's clickaway for unselecting a range or an event - // from causing a dayClick. + // prevent a user's clickaway for unselecting a range or an event from + // causing a dayClick or starting an immediate new selection. if (view.isSelected || view.selectedEvent) { - this.tempIgnoreMouse(); + return; } + selectLongPressDelay = view.opt('selectLongPressDelay'); if (selectLongPressDelay == null) { selectLongPressDelay = view.opt('longPressDelay'); // fallback } - this.dayDragListener.startInteraction(ev, { - delay: selectLongPressDelay - }); + this.dayClickListener.startInteraction(ev); + + if (view.opt('selectable')) { + this.daySelectListener.startInteraction(ev, { + delay: selectLongPressDelay + }); + } }, - // Creates a listener that tracks the user's drag across day elements. - // For day clicking and selecting. - buildDayDragListener: function() { + // Creates a listener that tracks the user's drag across day elements, for day clicking. + buildDayClickListener: function() { var _this = this; var view = this.view; - var isSelectable = view.opt('selectable'); var dayClickHit; // null if invalid dayClick + + var dragListener = new HitDragListener(this, { + scroll: view.opt('dragScroll'), + interactionStart: function() { + dayClickHit = dragListener.origHit; + }, + hitOver: function(hit, isOrig, origHit) { + // if user dragged to another cell at any point, it can no longer be a dayClick + if (!isOrig) { + dayClickHit = null; + } + }, + hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits + dayClickHit = null; + }, + interactionEnd: function(ev, isCancelled) { + if (!isCancelled && dayClickHit) { + view.triggerDayClick( + _this.getHitSpan(dayClickHit), + _this.getHitEl(dayClickHit), + ev + ); + } + } + }); + + // because dayClickListener won't be called with any time delay, "dragging" will begin immediately, + // which will kill any touchmoving/scrolling. Prevent this. + dragListener.shouldCancelTouchScroll = false; + + dragListener.scrollAlwaysKills = true; + + return dragListener; + }, + + + // Creates a listener that tracks the user's drag across day elements, for day selecting. + buildDaySelectListener: function() { + var _this = this; + var view = this.view; var selectionSpan; // null if invalid selection - // this listener tracks a mousedown on a day element, and a subsequent drag. - // if the drag ends on the same day, it is a 'dayClick'. - // if 'selectable' is enabled, this listener also detects selections. var dragListener = new HitDragListener(this, { scroll: view.opt('dragScroll'), interactionStart: function() { - dayClickHit = dragListener.origHit; // for dayClick, where no dragging happens selectionSpan = null; }, dragStart: function() { @@ -3695,27 +4065,20 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { hitOver: function(hit, isOrig, origHit) { if (origHit) { // click needs to have started on a hit - // if user dragged to another cell at any point, it can no longer be a dayClick - if (!isOrig) { - dayClickHit = null; - } + selectionSpan = _this.computeSelection( + _this.getHitSpan(origHit), + _this.getHitSpan(hit) + ); - if (isSelectable) { - selectionSpan = _this.computeSelection( - _this.getHitSpan(origHit), - _this.getHitSpan(hit) - ); - if (selectionSpan) { - _this.renderSelection(selectionSpan); - } - else if (selectionSpan === false) { - disableCursor(); - } + if (selectionSpan) { + _this.renderSelection(selectionSpan); + } + else if (selectionSpan === false) { + disableCursor(); } } }, hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits - dayClickHit = null; selectionSpan = null; _this.unrenderSelection(); }, @@ -3723,21 +4086,9 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { enableCursor(); }, 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); - } + if (!isCancelled && selectionSpan) { + // the selection will already have been rendered. just report it + view.reportSelection(selectionSpan, ev); } } }); @@ -3750,7 +4101,8 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, { // 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(); + this.dayClickListener.endInteraction(); + this.daySelectListener.endInteraction(); if (this.segDragListener) { this.segDragListener.endInteraction(); // will clear this.segDragListener @@ -4269,7 +4621,6 @@ Grid.mixin({ // Attaches event-element-related handlers to an arbitrary container element. leverages bubbling. bindSegHandlersToEl: function(el) { this.bindSegHandlerToEl(el, 'touchstart', this.handleSegTouchStart); - this.bindSegHandlerToEl(el, 'touchend', this.handleSegTouchEnd); this.bindSegHandlerToEl(el, 'mouseenter', this.handleSegMouseover); this.bindSegHandlerToEl(el, 'mouseleave', this.handleSegMouseout); this.bindSegHandlerToEl(el, 'mousedown', this.handleSegMousedown); @@ -4304,7 +4655,7 @@ Grid.mixin({ // Updates internal state and triggers handlers for when an event element is moused over handleSegMouseover: function(seg, ev) { if ( - !this.isIgnoringMouse && + !GlobalEmitter.get().shouldIgnoreMouse() && !this.mousedOverSeg ) { this.mousedOverSeg = seg; @@ -4374,16 +4725,6 @@ Grid.mixin({ delay: isSelected ? 0 : eventLongPressDelay // 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(); }, @@ -4509,7 +4850,7 @@ Grid.mixin({ if (dropLocation) { // no need to re-show original, will rerender all anyways. esp important if eventRenderWait - view.reportEventDrop(event, dropLocation, _this.largeUnit, el, ev); + view.reportSegDrop(seg, dropLocation, _this.largeUnit, el, ev); } else { view.showEvent(event); @@ -4812,7 +5153,7 @@ Grid.mixin({ if (resizeLocation) { // valid date to resize to? // no need to re-show original, will rerender all anyways. esp important if eventRenderWait - view.reportEventResize(event, resizeLocation, _this.largeUnit, el, ev); + view.reportSegResize(seg, resizeLocation, _this.largeUnit, el, ev); } else { view.showEvent(event); @@ -6833,9 +7174,9 @@ DayGrid.mixin({ // because segments in the popover are not part of a grid coordinate system, provide a hint to any // grids that want to do drag-n-drop about which cell it came from - this.prepareHits(); + this.hitsNeeded(); segs[i].hit = this.getCellHit(row, col); - this.releaseHits(); + this.hitsNotNeeded(); segContainer.append(segs[i].el); } @@ -8261,11 +8602,23 @@ var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, { // Computes what the title at the top of the calendar should be for this view computeTitle: function() { + var start, end; + + // for views that span a large unit of time, show the proper interval, ignoring stray days before and after + if (this.intervalUnit === 'year' || this.intervalUnit === 'month') { + start = this.intervalStart; + end = this.intervalEnd; + } + else { // for day units or smaller, use the actual day range + start = this.start; + end = this.end; + } + return this.formatRange( { // in case intervalStart/End has a time, make sure timezone is correct - start: this.calendar.applyTimezone(this.intervalStart), - end: this.calendar.applyTimezone(this.intervalEnd) + start: this.calendar.applyTimezone(start), + end: this.calendar.applyTimezone(end) }, this.opt('titleFormat') || this.computeTitleFormat(), this.opt('titleRangeSeparator') @@ -8579,14 +8932,16 @@ var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, { // Binds DOM handlers to elements that reside outside the view container, such as the document bindGlobalHandlers: function() { - this.listenTo($(document), 'mousedown', this.handleDocumentMousedown); - this.listenTo($(document), 'touchstart', this.processUnselect); + this.listenTo(GlobalEmitter.get(), { + touchstart: this.processUnselect, + mousedown: this.handleDocumentMousedown + }); }, // Unbinds DOM handlers from elements that reside outside the view container unbindGlobalHandlers: function() { - this.stopListeningTo($(document)); + this.stopListeningTo(GlobalEmitter.get()); }, @@ -9158,15 +9513,15 @@ var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, { // Must be called when an event in the view is dropped onto new location. // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event. - reportEventDrop: function(event, dropLocation, largeUnit, el, ev) { + reportSegDrop: function(seg, dropLocation, largeUnit, el, ev) { var calendar = this.calendar; - var mutateResult = calendar.mutateEvent(event, dropLocation, largeUnit); + var mutateResult = calendar.mutateSeg(seg, dropLocation, largeUnit); var undoFunc = function() { mutateResult.undo(); calendar.reportEventChange(); }; - this.triggerEventDrop(event, mutateResult.dateDelta, undoFunc, el, ev); + this.triggerEventDrop(seg.event, mutateResult.dateDelta, undoFunc, el, ev); calendar.reportEventChange(); // will rerender events }, @@ -9261,15 +9616,15 @@ var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, { // Must be called when an event in the view has been resized to a new length - reportEventResize: function(event, resizeLocation, largeUnit, el, ev) { + reportSegResize: function(seg, resizeLocation, largeUnit, el, ev) { var calendar = this.calendar; - var mutateResult = calendar.mutateEvent(event, resizeLocation, largeUnit); + var mutateResult = calendar.mutateSeg(seg, resizeLocation, largeUnit); var undoFunc = function() { mutateResult.undo(); calendar.reportEventChange(); }; - this.triggerEventResize(event, mutateResult.durationDelta, undoFunc, el, ev); + this.triggerEventResize(seg.event, mutateResult.durationDelta, undoFunc, el, ev); calendar.reportEventChange(); // will rerender events }, @@ -10202,6 +10557,9 @@ Calendar.mixin(EmitterMixin); function Calendar_constructor(element, overrides) { var t = this; + // declare the current calendar instance relies on GlobalEmitter. needed for garbage collection. + GlobalEmitter.needed(); + // Exports // ----------------------------------------------------------------------------------- @@ -10545,6 +10903,8 @@ function Calendar_constructor(element, overrides) { if (windowResizeProxy) { $(window).unbind('resize', windowResizeProxy); } + + GlobalEmitter.unneeded(); } @@ -11176,6 +11536,7 @@ Calendar.defaults = { //selectable: false, unselectAuto: true, + //selectMinDistance: 0, dropAccept: '*', @@ -12551,6 +12912,12 @@ function EventManager() { // assumed to be a calendar } +// returns an undo function +Calendar.prototype.mutateSeg = function(seg, newProps) { + return this.mutateEvent(seg.event, newProps); +}; + + // hook for external libs to manipulate event properties upon creation. // should manipulate the event in-place. Calendar.prototype.normalizeEvent = function(event) { @@ -13096,6 +13463,16 @@ var BasicView = FC.BasicView = View.extend({ // forward all hit-related method calls to dayGrid + hitsNeeded: function() { + this.dayGrid.hitsNeeded(); + }, + + + hitsNotNeeded: function() { + this.dayGrid.hitsNotNeeded(); + }, + + prepareHits: function() { this.dayGrid.prepareHits(); }, @@ -13623,6 +14000,22 @@ var AgendaView = FC.AgendaView = View.extend({ // forward all hit-related method calls to the grids (dayGrid might not be defined) + hitsNeeded: function() { + this.timeGrid.hitsNeeded(); + if (this.dayGrid) { + this.dayGrid.hitsNeeded(); + } + }, + + + hitsNotNeeded: function() { + this.timeGrid.hitsNotNeeded(); + if (this.dayGrid) { + this.dayGrid.hitsNotNeeded(); + } + }, + + prepareHits: function() { this.timeGrid.prepareHits(); if (this.dayGrid) { -- cgit v1.2.3