Total Complexity | 2161 |
Complexity/F | 2.01 |
Lines of Code | 15585 |
Function Count | 1074 |
Duplicated Lines | 125 |
Ratio | 0.8 % |
Changes | 0 |
Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.
Common duplication problems, and corresponding solutions are:
Complex classes like assets/mdb-dashboard/js/modules/fullcalendar-3.4.0/fullcalendar.js often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
1 | /*! |
||
7 | (function(factory) { |
||
8 | if (typeof define === 'function' && define.amd) { |
||
9 | define([ 'jquery', 'moment' ], factory); |
||
10 | } |
||
11 | else if (typeof exports === 'object') { // Node/CommonJS |
||
12 | module.exports = factory(require('jquery'), require('moment')); |
||
13 | } |
||
14 | else { |
||
15 | factory(jQuery, moment); |
||
16 | } |
||
17 | })(function($, moment) { |
||
18 | |||
19 | ;; |
||
20 | |||
21 | var FC = $.fullCalendar = { |
||
22 | version: "3.4.0", |
||
23 | // When introducing internal API incompatibilities (where fullcalendar plugins would break), |
||
24 | // the minor version of the calendar should be upped (ex: 2.7.2 -> 2.8.0) |
||
25 | // and the below integer should be incremented. |
||
26 | internalApiVersion: 9 |
||
27 | }; |
||
28 | var fcViews = FC.views = {}; |
||
29 | |||
30 | |||
31 | $.fn.fullCalendar = function(options) { |
||
32 | var args = Array.prototype.slice.call(arguments, 1); // for a possible method call |
||
33 | var res = this; // what this function will return (this jQuery object by default) |
||
34 | |||
35 | this.each(function(i, _element) { // loop each DOM element involved |
||
36 | var element = $(_element); |
||
37 | var calendar = element.data('fullCalendar'); // get the existing calendar object (if any) |
||
38 | var singleRes; // the returned value of this single method call |
||
39 | |||
40 | // a method call |
||
41 | if (typeof options === 'string') { |
||
42 | if (calendar && $.isFunction(calendar[options])) { |
||
43 | singleRes = calendar[options].apply(calendar, args); |
||
44 | if (!i) { |
||
45 | res = singleRes; // record the first method call result |
||
46 | } |
||
47 | if (options === 'destroy') { // for the destroy method, must remove Calendar object data |
||
48 | element.removeData('fullCalendar'); |
||
49 | } |
||
50 | } |
||
51 | } |
||
52 | // a new calendar initialization |
||
53 | else if (!calendar) { // don't initialize twice |
||
54 | calendar = new Calendar(element, options); |
||
55 | element.data('fullCalendar', calendar); |
||
56 | calendar.render(); |
||
57 | } |
||
58 | }); |
||
59 | |||
60 | return res; |
||
61 | }; |
||
62 | |||
63 | |||
64 | var complexOptions = [ // names of options that are objects whose properties should be combined |
||
65 | 'header', |
||
66 | 'footer', |
||
67 | 'buttonText', |
||
68 | 'buttonIcons', |
||
69 | 'themeButtonIcons' |
||
70 | ]; |
||
71 | |||
72 | |||
73 | // Merges an array of option objects into a single object |
||
74 | function mergeOptions(optionObjs) { |
||
75 | return mergeProps(optionObjs, complexOptions); |
||
76 | } |
||
77 | |||
78 | ;; |
||
79 | |||
80 | // exports |
||
81 | FC.intersectRanges = intersectRanges; |
||
82 | FC.applyAll = applyAll; |
||
83 | FC.debounce = debounce; |
||
84 | FC.isInt = isInt; |
||
85 | FC.htmlEscape = htmlEscape; |
||
86 | FC.cssToStr = cssToStr; |
||
87 | FC.proxy = proxy; |
||
88 | FC.capitaliseFirstLetter = capitaliseFirstLetter; |
||
89 | |||
90 | |||
91 | /* FullCalendar-specific DOM Utilities |
||
92 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
93 | |||
94 | |||
95 | // Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left |
||
96 | // and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that. |
||
97 | function compensateScroll(rowEls, scrollbarWidths) { |
||
98 | if (scrollbarWidths.left) { |
||
99 | rowEls.css({ |
||
100 | 'border-left-width': 1, |
||
101 | 'margin-left': scrollbarWidths.left - 1 |
||
102 | }); |
||
103 | } |
||
104 | if (scrollbarWidths.right) { |
||
105 | rowEls.css({ |
||
106 | 'border-right-width': 1, |
||
107 | 'margin-right': scrollbarWidths.right - 1 |
||
108 | }); |
||
109 | } |
||
110 | } |
||
111 | |||
112 | |||
113 | // Undoes compensateScroll and restores all borders/margins |
||
114 | function uncompensateScroll(rowEls) { |
||
115 | rowEls.css({ |
||
116 | 'margin-left': '', |
||
117 | 'margin-right': '', |
||
118 | 'border-left-width': '', |
||
119 | 'border-right-width': '' |
||
120 | }); |
||
121 | } |
||
122 | |||
123 | |||
124 | // Make the mouse cursor express that an event is not allowed in the current area |
||
125 | function disableCursor() { |
||
126 | $('body').addClass('fc-not-allowed'); |
||
127 | } |
||
128 | |||
129 | |||
130 | // Returns the mouse cursor to its original look |
||
131 | function enableCursor() { |
||
132 | $('body').removeClass('fc-not-allowed'); |
||
133 | } |
||
134 | |||
135 | |||
136 | // Given a total available height to fill, have `els` (essentially child rows) expand to accomodate. |
||
137 | // By default, all elements that are shorter than the recommended height are expanded uniformly, not considering |
||
138 | // any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and |
||
139 | // reduces the available height. |
||
140 | function distributeHeight(els, availableHeight, shouldRedistribute) { |
||
141 | |||
142 | // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions, |
||
143 | // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars. |
||
144 | |||
145 | var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element |
||
146 | var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE* |
||
147 | var flexEls = []; // elements that are allowed to expand. array of DOM nodes |
||
148 | var flexOffsets = []; // amount of vertical space it takes up |
||
149 | var flexHeights = []; // actual css height |
||
150 | var usedHeight = 0; |
||
151 | |||
152 | undistributeHeight(els); // give all elements their natural height |
||
153 | |||
154 | // find elements that are below the recommended height (expandable). |
||
155 | // important to query for heights in a single first pass (to avoid reflow oscillation). |
||
156 | els.each(function(i, el) { |
||
157 | var minOffset = i === els.length - 1 ? minOffset2 : minOffset1; |
||
158 | var naturalOffset = $(el).outerHeight(true); |
||
159 | |||
160 | if (naturalOffset < minOffset) { |
||
161 | flexEls.push(el); |
||
162 | flexOffsets.push(naturalOffset); |
||
163 | flexHeights.push($(el).height()); |
||
164 | } |
||
165 | else { |
||
166 | // this element stretches past recommended height (non-expandable). mark the space as occupied. |
||
167 | usedHeight += naturalOffset; |
||
168 | } |
||
169 | }); |
||
170 | |||
171 | // readjust the recommended height to only consider the height available to non-maxed-out rows. |
||
172 | if (shouldRedistribute) { |
||
173 | availableHeight -= usedHeight; |
||
174 | minOffset1 = Math.floor(availableHeight / flexEls.length); |
||
175 | minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE* |
||
176 | } |
||
177 | |||
178 | // assign heights to all expandable elements |
||
179 | $(flexEls).each(function(i, el) { |
||
180 | var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1; |
||
181 | var naturalOffset = flexOffsets[i]; |
||
182 | var naturalHeight = flexHeights[i]; |
||
183 | var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding |
||
184 | |||
185 | if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things |
||
186 | $(el).height(newHeight); |
||
187 | } |
||
188 | }); |
||
189 | } |
||
190 | |||
191 | |||
192 | // Undoes distrubuteHeight, restoring all els to their natural height |
||
193 | function undistributeHeight(els) { |
||
194 | els.height(''); |
||
195 | } |
||
196 | |||
197 | |||
198 | // Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the |
||
199 | // cells to be that width. |
||
200 | // PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline |
||
201 | function matchCellWidths(els) { |
||
202 | var maxInnerWidth = 0; |
||
203 | |||
204 | els.find('> *').each(function(i, innerEl) { |
||
205 | var innerWidth = $(innerEl).outerWidth(); |
||
206 | if (innerWidth > maxInnerWidth) { |
||
207 | maxInnerWidth = innerWidth; |
||
208 | } |
||
209 | }); |
||
210 | |||
211 | maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance |
||
212 | |||
213 | els.width(maxInnerWidth); |
||
214 | |||
215 | return maxInnerWidth; |
||
216 | } |
||
217 | |||
218 | |||
219 | // Given one element that resides inside another, |
||
220 | // Subtracts the height of the inner element from the outer element. |
||
221 | function subtractInnerElHeight(outerEl, innerEl) { |
||
222 | var both = outerEl.add(innerEl); |
||
223 | var diff; |
||
224 | |||
225 | // effin' IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked |
||
226 | both.css({ |
||
227 | position: 'relative', // cause a reflow, which will force fresh dimension recalculation |
||
228 | left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll |
||
229 | }); |
||
230 | diff = outerEl.outerHeight() - innerEl.outerHeight(); // grab the dimensions |
||
231 | both.css({ position: '', left: '' }); // undo hack |
||
232 | |||
233 | return diff; |
||
234 | } |
||
235 | |||
236 | |||
237 | /* Element Geom Utilities |
||
238 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
239 | |||
240 | FC.getOuterRect = getOuterRect; |
||
241 | FC.getClientRect = getClientRect; |
||
242 | FC.getContentRect = getContentRect; |
||
243 | FC.getScrollbarWidths = getScrollbarWidths; |
||
244 | |||
245 | |||
246 | // borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51 |
||
247 | function getScrollParent(el) { |
||
248 | var position = el.css('position'), |
||
249 | scrollParent = el.parents().filter(function() { |
||
250 | var parent = $(this); |
||
251 | return (/(auto|scroll)/).test( |
||
252 | parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x') |
||
253 | ); |
||
254 | }).eq(0); |
||
255 | |||
256 | return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent; |
||
257 | } |
||
258 | |||
259 | |||
260 | // Queries the outer bounding area of a jQuery element. |
||
261 | // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). |
||
262 | // Origin is optional. |
||
263 | function getOuterRect(el, origin) { |
||
264 | var offset = el.offset(); |
||
265 | var left = offset.left - (origin ? origin.left : 0); |
||
266 | var top = offset.top - (origin ? origin.top : 0); |
||
267 | |||
268 | return { |
||
269 | left: left, |
||
270 | right: left + el.outerWidth(), |
||
271 | top: top, |
||
272 | bottom: top + el.outerHeight() |
||
273 | }; |
||
274 | } |
||
275 | |||
276 | |||
277 | // Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding. |
||
278 | // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). |
||
279 | // Origin is optional. |
||
280 | // WARNING: given element can't have borders |
||
281 | // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. |
||
282 | function getClientRect(el, origin) { |
||
283 | var offset = el.offset(); |
||
284 | var scrollbarWidths = getScrollbarWidths(el); |
||
285 | var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left - (origin ? origin.left : 0); |
||
286 | var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top - (origin ? origin.top : 0); |
||
287 | |||
288 | return { |
||
289 | left: left, |
||
290 | right: left + el[0].clientWidth, // clientWidth includes padding but NOT scrollbars |
||
291 | top: top, |
||
292 | bottom: top + el[0].clientHeight // clientHeight includes padding but NOT scrollbars |
||
293 | }; |
||
294 | } |
||
295 | |||
296 | |||
297 | // Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars. |
||
298 | // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). |
||
299 | // Origin is optional. |
||
300 | function getContentRect(el, origin) { |
||
301 | var offset = el.offset(); // just outside of border, margin not included |
||
302 | var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left') - |
||
303 | (origin ? origin.left : 0); |
||
304 | var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top') - |
||
305 | (origin ? origin.top : 0); |
||
306 | |||
307 | return { |
||
308 | left: left, |
||
309 | right: left + el.width(), |
||
310 | top: top, |
||
311 | bottom: top + el.height() |
||
312 | }; |
||
313 | } |
||
314 | |||
315 | |||
316 | // Returns the computed left/right/top/bottom scrollbar widths for the given jQuery element. |
||
317 | // WARNING: given element can't have borders (which will cause offsetWidth/offsetHeight to be larger). |
||
318 | // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. |
||
319 | function getScrollbarWidths(el) { |
||
320 | var leftRightWidth = el[0].offsetWidth - el[0].clientWidth; |
||
321 | var bottomWidth = el[0].offsetHeight - el[0].clientHeight; |
||
322 | var widths; |
||
323 | |||
324 | leftRightWidth = sanitizeScrollbarWidth(leftRightWidth); |
||
325 | bottomWidth = sanitizeScrollbarWidth(bottomWidth); |
||
326 | |||
327 | widths = { left: 0, right: 0, top: 0, bottom: bottomWidth }; |
||
328 | |||
329 | if (getIsLeftRtlScrollbars() && el.css('direction') == 'rtl') { // is the scrollbar on the left side? |
||
330 | widths.left = leftRightWidth; |
||
331 | } |
||
332 | else { |
||
333 | widths.right = leftRightWidth; |
||
334 | } |
||
335 | |||
336 | return widths; |
||
337 | } |
||
338 | |||
339 | |||
340 | // The scrollbar width computations in getScrollbarWidths are sometimes flawed when it comes to |
||
341 | // retina displays, rounding, and IE11. Massage them into a usable value. |
||
342 | function sanitizeScrollbarWidth(width) { |
||
343 | width = Math.max(0, width); // no negatives |
||
344 | width = Math.round(width); |
||
345 | return width; |
||
346 | } |
||
347 | |||
348 | |||
349 | // Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side |
||
350 | |||
351 | var _isLeftRtlScrollbars = null; |
||
352 | |||
353 | function getIsLeftRtlScrollbars() { // responsible for caching the computation |
||
354 | if (_isLeftRtlScrollbars === null) { |
||
355 | _isLeftRtlScrollbars = computeIsLeftRtlScrollbars(); |
||
356 | } |
||
357 | return _isLeftRtlScrollbars; |
||
358 | } |
||
359 | |||
360 | function computeIsLeftRtlScrollbars() { // creates an offscreen test element, then removes it |
||
361 | var el = $('<div><div/></div>') |
||
362 | .css({ |
||
363 | position: 'absolute', |
||
364 | top: -1000, |
||
365 | left: 0, |
||
366 | border: 0, |
||
367 | padding: 0, |
||
368 | overflow: 'scroll', |
||
369 | direction: 'rtl' |
||
370 | }) |
||
371 | .appendTo('body'); |
||
372 | var innerEl = el.children(); |
||
373 | var res = innerEl.offset().left > el.offset().left; // is the inner div shifted to accommodate a left scrollbar? |
||
374 | el.remove(); |
||
375 | return res; |
||
376 | } |
||
377 | |||
378 | |||
379 | // Retrieves a jQuery element's computed CSS value as a floating-point number. |
||
380 | // If the queried value is non-numeric (ex: IE can return "medium" for border width), will just return zero. |
||
381 | function getCssFloat(el, prop) { |
||
382 | return parseFloat(el.css(prop)) || 0; |
||
383 | } |
||
384 | |||
385 | |||
386 | /* Mouse / Touch Utilities |
||
387 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
388 | |||
389 | FC.preventDefault = preventDefault; |
||
390 | |||
391 | |||
392 | // Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac) |
||
393 | function isPrimaryMouseButton(ev) { |
||
394 | return ev.which == 1 && !ev.ctrlKey; |
||
395 | } |
||
396 | |||
397 | |||
398 | function getEvX(ev) { |
||
399 | var touches = ev.originalEvent.touches; |
||
400 | |||
401 | // on mobile FF, pageX for touch events is present, but incorrect, |
||
402 | // so, look at touch coordinates first. |
||
403 | if (touches && touches.length) { |
||
404 | return touches[0].pageX; |
||
405 | } |
||
406 | |||
407 | return ev.pageX; |
||
408 | } |
||
409 | |||
410 | |||
411 | function getEvY(ev) { |
||
412 | var touches = ev.originalEvent.touches; |
||
413 | |||
414 | // on mobile FF, pageX for touch events is present, but incorrect, |
||
415 | // so, look at touch coordinates first. |
||
416 | if (touches && touches.length) { |
||
417 | return touches[0].pageY; |
||
418 | } |
||
419 | |||
420 | return ev.pageY; |
||
421 | } |
||
422 | |||
423 | |||
424 | function getEvIsTouch(ev) { |
||
425 | return /^touch/.test(ev.type); |
||
426 | } |
||
427 | |||
428 | |||
429 | function preventSelection(el) { |
||
430 | el.addClass('fc-unselectable') |
||
431 | .on('selectstart', preventDefault); |
||
432 | } |
||
433 | |||
434 | |||
435 | function allowSelection(el) { |
||
436 | el.removeClass('fc-unselectable') |
||
437 | .off('selectstart', preventDefault); |
||
438 | } |
||
439 | |||
440 | |||
441 | // Stops a mouse/touch event from doing it's native browser action |
||
442 | function preventDefault(ev) { |
||
443 | ev.preventDefault(); |
||
444 | } |
||
445 | |||
446 | |||
447 | /* General Geometry Utils |
||
448 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
449 | |||
450 | FC.intersectRects = intersectRects; |
||
451 | |||
452 | // Returns a new rectangle that is the intersection of the two rectangles. If they don't intersect, returns false |
||
453 | function intersectRects(rect1, rect2) { |
||
454 | var res = { |
||
455 | left: Math.max(rect1.left, rect2.left), |
||
456 | right: Math.min(rect1.right, rect2.right), |
||
457 | top: Math.max(rect1.top, rect2.top), |
||
458 | bottom: Math.min(rect1.bottom, rect2.bottom) |
||
459 | }; |
||
460 | |||
461 | if (res.left < res.right && res.top < res.bottom) { |
||
462 | return res; |
||
463 | } |
||
464 | return false; |
||
465 | } |
||
466 | |||
467 | |||
468 | // Returns a new point that will have been moved to reside within the given rectangle |
||
469 | function constrainPoint(point, rect) { |
||
470 | return { |
||
471 | left: Math.min(Math.max(point.left, rect.left), rect.right), |
||
472 | top: Math.min(Math.max(point.top, rect.top), rect.bottom) |
||
473 | }; |
||
474 | } |
||
475 | |||
476 | |||
477 | // Returns a point that is the center of the given rectangle |
||
478 | function getRectCenter(rect) { |
||
479 | return { |
||
480 | left: (rect.left + rect.right) / 2, |
||
481 | top: (rect.top + rect.bottom) / 2 |
||
482 | }; |
||
483 | } |
||
484 | |||
485 | |||
486 | // Subtracts point2's coordinates from point1's coordinates, returning a delta |
||
487 | function diffPoints(point1, point2) { |
||
488 | return { |
||
489 | left: point1.left - point2.left, |
||
490 | top: point1.top - point2.top |
||
491 | }; |
||
492 | } |
||
493 | |||
494 | |||
495 | /* Object Ordering by Field |
||
496 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
497 | |||
498 | FC.parseFieldSpecs = parseFieldSpecs; |
||
499 | FC.compareByFieldSpecs = compareByFieldSpecs; |
||
500 | FC.compareByFieldSpec = compareByFieldSpec; |
||
501 | FC.flexibleCompare = flexibleCompare; |
||
502 | |||
503 | |||
504 | function parseFieldSpecs(input) { |
||
505 | var specs = []; |
||
506 | var tokens = []; |
||
507 | var i, token; |
||
508 | |||
509 | if (typeof input === 'string') { |
||
510 | tokens = input.split(/\s*,\s*/); |
||
511 | } |
||
512 | else if (typeof input === 'function') { |
||
513 | tokens = [ input ]; |
||
514 | } |
||
515 | else if ($.isArray(input)) { |
||
516 | tokens = input; |
||
517 | } |
||
518 | |||
519 | for (i = 0; i < tokens.length; i++) { |
||
520 | token = tokens[i]; |
||
521 | |||
522 | if (typeof token === 'string') { |
||
523 | specs.push( |
||
524 | token.charAt(0) == '-' ? |
||
525 | { field: token.substring(1), order: -1 } : |
||
526 | { field: token, order: 1 } |
||
527 | ); |
||
528 | } |
||
529 | else if (typeof token === 'function') { |
||
530 | specs.push({ func: token }); |
||
531 | } |
||
532 | } |
||
533 | |||
534 | return specs; |
||
535 | } |
||
536 | |||
537 | |||
538 | function compareByFieldSpecs(obj1, obj2, fieldSpecs) { |
||
539 | var i; |
||
540 | var cmp; |
||
541 | |||
542 | for (i = 0; i < fieldSpecs.length; i++) { |
||
543 | cmp = compareByFieldSpec(obj1, obj2, fieldSpecs[i]); |
||
544 | if (cmp) { |
||
545 | return cmp; |
||
546 | } |
||
547 | } |
||
548 | |||
549 | return 0; |
||
550 | } |
||
551 | |||
552 | |||
553 | function compareByFieldSpec(obj1, obj2, fieldSpec) { |
||
554 | if (fieldSpec.func) { |
||
555 | return fieldSpec.func(obj1, obj2); |
||
556 | } |
||
557 | return flexibleCompare(obj1[fieldSpec.field], obj2[fieldSpec.field]) * |
||
558 | (fieldSpec.order || 1); |
||
559 | } |
||
560 | |||
561 | |||
562 | function flexibleCompare(a, b) { |
||
563 | if (!a && !b) { |
||
564 | return 0; |
||
565 | } |
||
566 | if (b == null) { |
||
567 | return -1; |
||
568 | } |
||
569 | if (a == null) { |
||
570 | return 1; |
||
571 | } |
||
572 | if ($.type(a) === 'string' || $.type(b) === 'string') { |
||
573 | return String(a).localeCompare(String(b)); |
||
574 | } |
||
575 | return a - b; |
||
576 | } |
||
577 | |||
578 | |||
579 | /* FullCalendar-specific Misc Utilities |
||
580 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
581 | |||
582 | |||
583 | // Computes the intersection of the two ranges. Will return fresh date clones in a range. |
||
584 | // Returns undefined if no intersection. |
||
585 | // Expects all dates to be normalized to the same timezone beforehand. |
||
586 | // TODO: move to date section? |
||
587 | function intersectRanges(subjectRange, constraintRange) { |
||
588 | var subjectStart = subjectRange.start; |
||
589 | var subjectEnd = subjectRange.end; |
||
590 | var constraintStart = constraintRange.start; |
||
591 | var constraintEnd = constraintRange.end; |
||
592 | var segStart, segEnd; |
||
593 | var isStart, isEnd; |
||
594 | |||
595 | if (subjectEnd > constraintStart && subjectStart < constraintEnd) { // in bounds at all? |
||
596 | |||
597 | if (subjectStart >= constraintStart) { |
||
598 | segStart = subjectStart.clone(); |
||
599 | isStart = true; |
||
600 | } |
||
601 | else { |
||
602 | segStart = constraintStart.clone(); |
||
603 | isStart = false; |
||
604 | } |
||
605 | |||
606 | if (subjectEnd <= constraintEnd) { |
||
607 | segEnd = subjectEnd.clone(); |
||
608 | isEnd = true; |
||
609 | } |
||
610 | else { |
||
611 | segEnd = constraintEnd.clone(); |
||
612 | isEnd = false; |
||
613 | } |
||
614 | |||
615 | return { |
||
616 | start: segStart, |
||
617 | end: segEnd, |
||
618 | isStart: isStart, |
||
619 | isEnd: isEnd |
||
620 | }; |
||
621 | } |
||
622 | } |
||
623 | |||
624 | |||
625 | /* Date Utilities |
||
626 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
627 | |||
628 | FC.computeGreatestUnit = computeGreatestUnit; |
||
629 | FC.divideRangeByDuration = divideRangeByDuration; |
||
630 | FC.divideDurationByDuration = divideDurationByDuration; |
||
631 | FC.multiplyDuration = multiplyDuration; |
||
632 | FC.durationHasTime = durationHasTime; |
||
633 | |||
634 | var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ]; |
||
635 | var unitsDesc = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ]; // descending |
||
636 | |||
637 | |||
638 | // Diffs the two moments into a Duration where full-days are recorded first, then the remaining time. |
||
639 | // Moments will have their timezones normalized. |
||
640 | function diffDayTime(a, b) { |
||
641 | return moment.duration({ |
||
642 | days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'), |
||
643 | ms: a.time() - b.time() // time-of-day from day start. disregards timezone |
||
644 | }); |
||
645 | } |
||
646 | |||
647 | |||
648 | // Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations. |
||
649 | function diffDay(a, b) { |
||
650 | return moment.duration({ |
||
651 | days: a.clone().stripTime().diff(b.clone().stripTime(), 'days') |
||
652 | }); |
||
653 | } |
||
654 | |||
655 | |||
656 | // Diffs two moments, producing a duration, made of a whole-unit-increment of the given unit. Uses rounding. |
||
657 | function diffByUnit(a, b, unit) { |
||
658 | return moment.duration( |
||
659 | Math.round(a.diff(b, unit, true)), // returnFloat=true |
||
660 | unit |
||
661 | ); |
||
662 | } |
||
663 | |||
664 | |||
665 | // Computes the unit name of the largest whole-unit period of time. |
||
666 | // For example, 48 hours will be "days" whereas 49 hours will be "hours". |
||
667 | // Accepts start/end, a range object, or an original duration object. |
||
668 | function computeGreatestUnit(start, end) { |
||
669 | var i, unit; |
||
670 | var val; |
||
671 | |||
672 | for (i = 0; i < unitsDesc.length; i++) { |
||
673 | unit = unitsDesc[i]; |
||
674 | val = computeRangeAs(unit, start, end); |
||
675 | |||
676 | if (val >= 1 && isInt(val)) { |
||
677 | break; |
||
678 | } |
||
679 | } |
||
680 | |||
681 | return unit; // will be "milliseconds" if nothing else matches |
||
682 | } |
||
683 | |||
684 | |||
685 | // like computeGreatestUnit, but has special abilities to interpret the source input for clues |
||
686 | function computeDurationGreatestUnit(duration, durationInput) { |
||
687 | var unit = computeGreatestUnit(duration); |
||
688 | |||
689 | // prevent days:7 from being interpreted as a week |
||
690 | if (unit === 'week' && typeof durationInput === 'object' && durationInput.days) { |
||
691 | unit = 'day'; |
||
692 | } |
||
693 | |||
694 | return unit; |
||
695 | } |
||
696 | |||
697 | |||
698 | // Computes the number of units (like "hours") in the given range. |
||
699 | // Range can be a {start,end} object, separate start/end args, or a Duration. |
||
700 | // Results are based on Moment's .as() and .diff() methods, so results can depend on internal handling |
||
701 | // of month-diffing logic (which tends to vary from version to version). |
||
702 | function computeRangeAs(unit, start, end) { |
||
703 | |||
704 | if (end != null) { // given start, end |
||
705 | return end.diff(start, unit, true); |
||
706 | } |
||
707 | else if (moment.isDuration(start)) { // given duration |
||
708 | return start.as(unit); |
||
709 | } |
||
710 | else { // given { start, end } range object |
||
711 | return start.end.diff(start.start, unit, true); |
||
712 | } |
||
713 | } |
||
714 | |||
715 | |||
716 | // Intelligently divides a range (specified by a start/end params) by a duration |
||
717 | function divideRangeByDuration(start, end, dur) { |
||
718 | var months; |
||
719 | |||
720 | if (durationHasTime(dur)) { |
||
721 | return (end - start) / dur; |
||
722 | } |
||
723 | months = dur.asMonths(); |
||
724 | if (Math.abs(months) >= 1 && isInt(months)) { |
||
725 | return end.diff(start, 'months', true) / months; |
||
726 | } |
||
727 | return end.diff(start, 'days', true) / dur.asDays(); |
||
728 | } |
||
729 | |||
730 | |||
731 | // Intelligently divides one duration by another |
||
732 | function divideDurationByDuration(dur1, dur2) { |
||
733 | var months1, months2; |
||
734 | |||
735 | if (durationHasTime(dur1) || durationHasTime(dur2)) { |
||
736 | return dur1 / dur2; |
||
737 | } |
||
738 | months1 = dur1.asMonths(); |
||
739 | months2 = dur2.asMonths(); |
||
740 | if ( |
||
741 | Math.abs(months1) >= 1 && isInt(months1) && |
||
742 | Math.abs(months2) >= 1 && isInt(months2) |
||
743 | ) { |
||
744 | return months1 / months2; |
||
745 | } |
||
746 | return dur1.asDays() / dur2.asDays(); |
||
747 | } |
||
748 | |||
749 | |||
750 | // Intelligently multiplies a duration by a number |
||
751 | function multiplyDuration(dur, n) { |
||
752 | var months; |
||
753 | |||
754 | if (durationHasTime(dur)) { |
||
755 | return moment.duration(dur * n); |
||
756 | } |
||
757 | months = dur.asMonths(); |
||
758 | if (Math.abs(months) >= 1 && isInt(months)) { |
||
759 | return moment.duration({ months: months * n }); |
||
760 | } |
||
761 | return moment.duration({ days: dur.asDays() * n }); |
||
762 | } |
||
763 | |||
764 | |||
765 | function cloneRange(range) { |
||
766 | return { |
||
767 | start: range.start.clone(), |
||
768 | end: range.end.clone() |
||
769 | }; |
||
770 | } |
||
771 | |||
772 | |||
773 | // Trims the beginning and end of inner range to be completely within outerRange. |
||
774 | // Returns a new range object. |
||
775 | function constrainRange(innerRange, outerRange) { |
||
776 | innerRange = cloneRange(innerRange); |
||
777 | |||
778 | if (outerRange.start) { |
||
779 | // needs to be inclusively before outerRange's end |
||
780 | innerRange.start = constrainDate(innerRange.start, outerRange); |
||
781 | } |
||
782 | |||
783 | if (outerRange.end) { |
||
784 | innerRange.end = minMoment(innerRange.end, outerRange.end); |
||
785 | } |
||
786 | |||
787 | return innerRange; |
||
788 | } |
||
789 | |||
790 | |||
791 | // If the given date is not within the given range, move it inside. |
||
792 | // (If it's past the end, make it one millisecond before the end). |
||
793 | // Always returns a new moment. |
||
794 | function constrainDate(date, range) { |
||
795 | date = date.clone(); |
||
796 | |||
797 | if (range.start) { |
||
798 | date = maxMoment(date, range.start); |
||
799 | } |
||
800 | |||
801 | if (range.end && date >= range.end) { |
||
802 | date = range.end.clone().subtract(1); |
||
803 | } |
||
804 | |||
805 | return date; |
||
806 | } |
||
807 | |||
808 | |||
809 | function isDateWithinRange(date, range) { |
||
810 | return (!range.start || date >= range.start) && |
||
811 | (!range.end || date < range.end); |
||
812 | } |
||
813 | |||
814 | |||
815 | // TODO: deal with repeat code in intersectRanges |
||
816 | // constraintRange can have unspecified start/end, an open-ended range. |
||
817 | function doRangesIntersect(subjectRange, constraintRange) { |
||
818 | return (!constraintRange.start || subjectRange.end >= constraintRange.start) && |
||
819 | (!constraintRange.end || subjectRange.start < constraintRange.end); |
||
820 | } |
||
821 | |||
822 | |||
823 | function isRangeWithinRange(innerRange, outerRange) { |
||
824 | return (!outerRange.start || innerRange.start >= outerRange.start) && |
||
825 | (!outerRange.end || innerRange.end <= outerRange.end); |
||
826 | } |
||
827 | |||
828 | |||
829 | function isRangesEqual(range0, range1) { |
||
830 | return ((range0.start && range1.start && range0.start.isSame(range1.start)) || (!range0.start && !range1.start)) && |
||
831 | ((range0.end && range1.end && range0.end.isSame(range1.end)) || (!range0.end && !range1.end)); |
||
832 | } |
||
833 | |||
834 | |||
835 | // Returns the moment that's earlier in time. Always a copy. |
||
836 | function minMoment(mom1, mom2) { |
||
837 | return (mom1.isBefore(mom2) ? mom1 : mom2).clone(); |
||
838 | } |
||
839 | |||
840 | |||
841 | // Returns the moment that's later in time. Always a copy. |
||
842 | function maxMoment(mom1, mom2) { |
||
843 | return (mom1.isAfter(mom2) ? mom1 : mom2).clone(); |
||
844 | } |
||
845 | |||
846 | |||
847 | // Returns a boolean about whether the given duration has any time parts (hours/minutes/seconds/ms) |
||
848 | function durationHasTime(dur) { |
||
849 | return Boolean(dur.hours() || dur.minutes() || dur.seconds() || dur.milliseconds()); |
||
850 | } |
||
851 | |||
852 | |||
853 | function isNativeDate(input) { |
||
854 | return Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date; |
||
855 | } |
||
856 | |||
857 | |||
858 | // Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00" |
||
859 | function isTimeString(str) { |
||
860 | return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str); |
||
861 | } |
||
862 | |||
863 | |||
864 | /* Logging and Debug |
||
865 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
866 | |||
867 | FC.log = function() { |
||
868 | var console = window.console; |
||
869 | |||
870 | if (console && console.log) { |
||
871 | return console.log.apply(console, arguments); |
||
872 | } |
||
873 | }; |
||
874 | |||
875 | FC.warn = function() { |
||
876 | var console = window.console; |
||
877 | |||
878 | if (console && console.warn) { |
||
879 | return console.warn.apply(console, arguments); |
||
880 | } |
||
881 | else { |
||
882 | return FC.log.apply(FC, arguments); |
||
883 | } |
||
884 | }; |
||
885 | |||
886 | |||
887 | /* General Utilities |
||
888 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
889 | |||
890 | var hasOwnPropMethod = {}.hasOwnProperty; |
||
891 | |||
892 | |||
893 | // Merges an array of objects into a single object. |
||
894 | // The second argument allows for an array of property names who's object values will be merged together. |
||
895 | function mergeProps(propObjs, complexProps) { |
||
896 | var dest = {}; |
||
897 | var i, name; |
||
898 | var complexObjs; |
||
899 | var j, val; |
||
900 | var props; |
||
901 | |||
902 | if (complexProps) { |
||
903 | for (i = 0; i < complexProps.length; i++) { |
||
904 | name = complexProps[i]; |
||
905 | complexObjs = []; |
||
906 | |||
907 | // collect the trailing object values, stopping when a non-object is discovered |
||
908 | for (j = propObjs.length - 1; j >= 0; j--) { |
||
909 | val = propObjs[j][name]; |
||
910 | |||
911 | if (typeof val === 'object') { |
||
912 | complexObjs.unshift(val); |
||
913 | } |
||
914 | else if (val !== undefined) { |
||
915 | dest[name] = val; // if there were no objects, this value will be used |
||
916 | break; |
||
917 | } |
||
918 | } |
||
919 | |||
920 | // if the trailing values were objects, use the merged value |
||
921 | if (complexObjs.length) { |
||
922 | dest[name] = mergeProps(complexObjs); |
||
923 | } |
||
924 | } |
||
925 | } |
||
926 | |||
927 | // copy values into the destination, going from last to first |
||
928 | for (i = propObjs.length - 1; i >= 0; i--) { |
||
929 | props = propObjs[i]; |
||
930 | |||
931 | for (name in props) { |
||
932 | if (!(name in dest)) { // if already assigned by previous props or complex props, don't reassign |
||
933 | dest[name] = props[name]; |
||
934 | } |
||
935 | } |
||
936 | } |
||
937 | |||
938 | return dest; |
||
939 | } |
||
940 | |||
941 | |||
942 | // Create an object that has the given prototype. Just like Object.create |
||
943 | function createObject(proto) { |
||
944 | var f = function() {}; |
||
945 | f.prototype = proto; |
||
946 | return new f(); |
||
947 | } |
||
948 | FC.createObject = createObject; |
||
949 | |||
950 | |||
951 | function copyOwnProps(src, dest) { |
||
952 | for (var name in src) { |
||
953 | if (hasOwnProp(src, name)) { |
||
954 | dest[name] = src[name]; |
||
955 | } |
||
956 | } |
||
957 | } |
||
958 | |||
959 | |||
960 | function hasOwnProp(obj, name) { |
||
961 | return hasOwnPropMethod.call(obj, name); |
||
962 | } |
||
963 | |||
964 | |||
965 | // Is the given value a non-object non-function value? |
||
966 | function isAtomic(val) { |
||
967 | return /undefined|null|boolean|number|string/.test($.type(val)); |
||
968 | } |
||
969 | |||
970 | |||
971 | function applyAll(functions, thisObj, args) { |
||
972 | if ($.isFunction(functions)) { |
||
973 | functions = [ functions ]; |
||
974 | } |
||
975 | if (functions) { |
||
976 | var i; |
||
977 | var ret; |
||
978 | for (i=0; i<functions.length; i++) { |
||
979 | ret = functions[i].apply(thisObj, args) || ret; |
||
980 | } |
||
981 | return ret; |
||
982 | } |
||
983 | } |
||
984 | |||
985 | |||
986 | function firstDefined() { |
||
987 | for (var i=0; i<arguments.length; i++) { |
||
988 | if (arguments[i] !== undefined) { |
||
989 | return arguments[i]; |
||
990 | } |
||
991 | } |
||
992 | } |
||
993 | |||
994 | |||
995 | function htmlEscape(s) { |
||
996 | return (s + '').replace(/&/g, '&') |
||
997 | .replace(/</g, '<') |
||
998 | .replace(/>/g, '>') |
||
999 | .replace(/'/g, ''') |
||
1000 | .replace(/"/g, '"') |
||
1001 | .replace(/\n/g, '<br />'); |
||
1002 | } |
||
1003 | |||
1004 | |||
1005 | function stripHtmlEntities(text) { |
||
1006 | return text.replace(/&.*?;/g, ''); |
||
1007 | } |
||
1008 | |||
1009 | |||
1010 | // Given a hash of CSS properties, returns a string of CSS. |
||
1011 | // Uses property names as-is (no camel-case conversion). Will not make statements for null/undefined values. |
||
1012 | function cssToStr(cssProps) { |
||
1013 | var statements = []; |
||
1014 | |||
1015 | $.each(cssProps, function(name, val) { |
||
1016 | if (val != null) { |
||
1017 | statements.push(name + ':' + val); |
||
1018 | } |
||
1019 | }); |
||
1020 | |||
1021 | return statements.join(';'); |
||
1022 | } |
||
1023 | |||
1024 | |||
1025 | // Given an object hash of HTML attribute names to values, |
||
1026 | // generates a string that can be injected between < > in HTML |
||
1027 | function attrsToStr(attrs) { |
||
1028 | var parts = []; |
||
1029 | |||
1030 | $.each(attrs, function(name, val) { |
||
1031 | if (val != null) { |
||
1032 | parts.push(name + '="' + htmlEscape(val) + '"'); |
||
1033 | } |
||
1034 | }); |
||
1035 | |||
1036 | return parts.join(' '); |
||
1037 | } |
||
1038 | |||
1039 | |||
1040 | function capitaliseFirstLetter(str) { |
||
1041 | return str.charAt(0).toUpperCase() + str.slice(1); |
||
1042 | } |
||
1043 | |||
1044 | |||
1045 | function compareNumbers(a, b) { // for .sort() |
||
1046 | return a - b; |
||
1047 | } |
||
1048 | |||
1049 | |||
1050 | function isInt(n) { |
||
1051 | return n % 1 === 0; |
||
1052 | } |
||
1053 | |||
1054 | |||
1055 | // Returns a method bound to the given object context. |
||
1056 | // Just like one of the jQuery.proxy signatures, but without the undesired behavior of treating the same method with |
||
1057 | // different contexts as identical when binding/unbinding events. |
||
1058 | function proxy(obj, methodName) { |
||
1059 | var method = obj[methodName]; |
||
1060 | |||
1061 | return function() { |
||
1062 | return method.apply(obj, arguments); |
||
1063 | }; |
||
1064 | } |
||
1065 | |||
1066 | |||
1067 | // Returns a function, that, as long as it continues to be invoked, will not |
||
1068 | // be triggered. The function will be called after it stops being called for |
||
1069 | // N milliseconds. If `immediate` is passed, trigger the function on the |
||
1070 | // leading edge, instead of the trailing. |
||
1071 | // https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714 |
||
1072 | function debounce(func, wait, immediate) { |
||
1073 | var timeout, args, context, timestamp, result; |
||
1074 | |||
1075 | var later = function() { |
||
1076 | var last = +new Date() - timestamp; |
||
1077 | if (last < wait) { |
||
1078 | timeout = setTimeout(later, wait - last); |
||
1079 | } |
||
1080 | else { |
||
1081 | timeout = null; |
||
1082 | if (!immediate) { |
||
1083 | result = func.apply(context, args); |
||
1084 | context = args = null; |
||
1085 | } |
||
1086 | } |
||
1087 | }; |
||
1088 | |||
1089 | return function() { |
||
1090 | context = this; |
||
1091 | args = arguments; |
||
1092 | timestamp = +new Date(); |
||
1093 | var callNow = immediate && !timeout; |
||
1094 | if (!timeout) { |
||
1095 | timeout = setTimeout(later, wait); |
||
1096 | } |
||
1097 | if (callNow) { |
||
1098 | result = func.apply(context, args); |
||
1099 | context = args = null; |
||
1100 | } |
||
1101 | return result; |
||
1102 | }; |
||
1103 | } |
||
1104 | |||
1105 | ;; |
||
1106 | |||
1107 | /* |
||
1108 | GENERAL NOTE on moments throughout the *entire rest* of the codebase: |
||
1109 | All moments are assumed to be ambiguously-zoned unless otherwise noted, |
||
1110 | with the NOTABLE EXCEOPTION of start/end dates that live on *Event Objects*. |
||
1111 | Ambiguously-TIMED moments are assumed to be ambiguously-zoned by nature. |
||
1112 | */ |
||
1113 | |||
1114 | var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/; |
||
1115 | var ambigTimeOrZoneRegex = |
||
1116 | /^\s*\d{4}-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?)?$/; |
||
1117 | var newMomentProto = moment.fn; // where we will attach our new methods |
||
1118 | var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods |
||
1119 | |||
1120 | // tell momentjs to transfer these properties upon clone |
||
1121 | var momentProperties = moment.momentProperties; |
||
1122 | momentProperties.push('_fullCalendar'); |
||
1123 | momentProperties.push('_ambigTime'); |
||
1124 | momentProperties.push('_ambigZone'); |
||
1125 | |||
1126 | |||
1127 | // Creating |
||
1128 | // ------------------------------------------------------------------------------------------------- |
||
1129 | |||
1130 | // Creates a new moment, similar to the vanilla moment(...) constructor, but with |
||
1131 | // extra features (ambiguous time, enhanced formatting). When given an existing moment, |
||
1132 | // it will function as a clone (and retain the zone of the moment). Anything else will |
||
1133 | // result in a moment in the local zone. |
||
1134 | FC.moment = function() { |
||
1135 | return makeMoment(arguments); |
||
1136 | }; |
||
1137 | |||
1138 | // Sames as FC.moment, but forces the resulting moment to be in the UTC timezone. |
||
1139 | FC.moment.utc = function() { |
||
1140 | var mom = makeMoment(arguments, true); |
||
1141 | |||
1142 | // Force it into UTC because makeMoment doesn't guarantee it |
||
1143 | // (if given a pre-existing moment for example) |
||
1144 | if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone |
||
1145 | mom.utc(); |
||
1146 | } |
||
1147 | |||
1148 | return mom; |
||
1149 | }; |
||
1150 | |||
1151 | // Same as FC.moment, but when given an ISO8601 string, the timezone offset is preserved. |
||
1152 | // ISO8601 strings with no timezone offset will become ambiguously zoned. |
||
1153 | FC.moment.parseZone = function() { |
||
1154 | return makeMoment(arguments, true, true); |
||
1155 | }; |
||
1156 | |||
1157 | // Builds an enhanced moment from args. When given an existing moment, it clones. When given a |
||
1158 | // native Date, or called with no arguments (the current time), the resulting moment will be local. |
||
1159 | // Anything else needs to be "parsed" (a string or an array), and will be affected by: |
||
1160 | // parseAsUTC - if there is no zone information, should we parse the input in UTC? |
||
1161 | // parseZone - if there is zone information, should we force the zone of the moment? |
||
1162 | function makeMoment(args, parseAsUTC, parseZone) { |
||
1163 | var input = args[0]; |
||
1164 | var isSingleString = args.length == 1 && typeof input === 'string'; |
||
1165 | var isAmbigTime; |
||
1166 | var isAmbigZone; |
||
1167 | var ambigMatch; |
||
1168 | var mom; |
||
1169 | |||
1170 | if (moment.isMoment(input) || isNativeDate(input) || input === undefined) { |
||
1171 | mom = moment.apply(null, args); |
||
1172 | } |
||
1173 | else { // "parsing" is required |
||
1174 | isAmbigTime = false; |
||
1175 | isAmbigZone = false; |
||
1176 | |||
1177 | if (isSingleString) { |
||
1178 | if (ambigDateOfMonthRegex.test(input)) { |
||
1179 | // accept strings like '2014-05', but convert to the first of the month |
||
1180 | input += '-01'; |
||
1181 | args = [ input ]; // for when we pass it on to moment's constructor |
||
1182 | isAmbigTime = true; |
||
1183 | isAmbigZone = true; |
||
1184 | } |
||
1185 | else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) { |
||
1186 | isAmbigTime = !ambigMatch[5]; // no time part? |
||
1187 | isAmbigZone = true; |
||
1188 | } |
||
1189 | } |
||
1190 | else if ($.isArray(input)) { |
||
1191 | // arrays have no timezone information, so assume ambiguous zone |
||
1192 | isAmbigZone = true; |
||
1193 | } |
||
1194 | // otherwise, probably a string with a format |
||
1195 | |||
1196 | if (parseAsUTC || isAmbigTime) { |
||
1197 | mom = moment.utc.apply(moment, args); |
||
1198 | } |
||
1199 | else { |
||
1200 | mom = moment.apply(null, args); |
||
1201 | } |
||
1202 | |||
1203 | if (isAmbigTime) { |
||
1204 | mom._ambigTime = true; |
||
1205 | mom._ambigZone = true; // ambiguous time always means ambiguous zone |
||
1206 | } |
||
1207 | else if (parseZone) { // let's record the inputted zone somehow |
||
1208 | if (isAmbigZone) { |
||
1209 | mom._ambigZone = true; |
||
1210 | } |
||
1211 | else if (isSingleString) { |
||
1212 | mom.utcOffset(input); // if not a valid zone, will assign UTC |
||
1213 | } |
||
1214 | } |
||
1215 | } |
||
1216 | |||
1217 | mom._fullCalendar = true; // flag for extended functionality |
||
1218 | |||
1219 | return mom; |
||
1220 | } |
||
1221 | |||
1222 | |||
1223 | // Week Number |
||
1224 | // ------------------------------------------------------------------------------------------------- |
||
1225 | |||
1226 | |||
1227 | // Returns the week number, considering the locale's custom week number calcuation |
||
1228 | // `weeks` is an alias for `week` |
||
1229 | newMomentProto.week = newMomentProto.weeks = function(input) { |
||
1230 | var weekCalc = this._locale._fullCalendar_weekCalc; |
||
1231 | |||
1232 | if (input == null && typeof weekCalc === 'function') { // custom function only works for getter |
||
1233 | return weekCalc(this); |
||
1234 | } |
||
1235 | else if (weekCalc === 'ISO') { |
||
1236 | return oldMomentProto.isoWeek.apply(this, arguments); // ISO getter/setter |
||
1237 | } |
||
1238 | |||
1239 | return oldMomentProto.week.apply(this, arguments); // local getter/setter |
||
1240 | }; |
||
1241 | |||
1242 | |||
1243 | // Time-of-day |
||
1244 | // ------------------------------------------------------------------------------------------------- |
||
1245 | |||
1246 | // GETTER |
||
1247 | // Returns a Duration with the hours/minutes/seconds/ms values of the moment. |
||
1248 | // If the moment has an ambiguous time, a duration of 00:00 will be returned. |
||
1249 | // |
||
1250 | // SETTER |
||
1251 | // You can supply a Duration, a Moment, or a Duration-like argument. |
||
1252 | // When setting the time, and the moment has an ambiguous time, it then becomes unambiguous. |
||
1253 | newMomentProto.time = function(time) { |
||
1254 | |||
1255 | // Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar. |
||
1256 | // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins. |
||
1257 | if (!this._fullCalendar) { |
||
1258 | return oldMomentProto.time.apply(this, arguments); |
||
1259 | } |
||
1260 | |||
1261 | if (time == null) { // getter |
||
1262 | return moment.duration({ |
||
1263 | hours: this.hours(), |
||
1264 | minutes: this.minutes(), |
||
1265 | seconds: this.seconds(), |
||
1266 | milliseconds: this.milliseconds() |
||
1267 | }); |
||
1268 | } |
||
1269 | else { // setter |
||
1270 | |||
1271 | this._ambigTime = false; // mark that the moment now has a time |
||
1272 | |||
1273 | if (!moment.isDuration(time) && !moment.isMoment(time)) { |
||
1274 | time = moment.duration(time); |
||
1275 | } |
||
1276 | |||
1277 | // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day). |
||
1278 | // Only for Duration times, not Moment times. |
||
1279 | var dayHours = 0; |
||
1280 | if (moment.isDuration(time)) { |
||
1281 | dayHours = Math.floor(time.asDays()) * 24; |
||
1282 | } |
||
1283 | |||
1284 | // We need to set the individual fields. |
||
1285 | // Can't use startOf('day') then add duration. In case of DST at start of day. |
||
1286 | return this.hours(dayHours + time.hours()) |
||
1287 | .minutes(time.minutes()) |
||
1288 | .seconds(time.seconds()) |
||
1289 | .milliseconds(time.milliseconds()); |
||
1290 | } |
||
1291 | }; |
||
1292 | |||
1293 | // Converts the moment to UTC, stripping out its time-of-day and timezone offset, |
||
1294 | // but preserving its YMD. A moment with a stripped time will display no time |
||
1295 | // nor timezone offset when .format() is called. |
||
1296 | newMomentProto.stripTime = function() { |
||
1297 | |||
1298 | if (!this._ambigTime) { |
||
1299 | |||
1300 | this.utc(true); // keepLocalTime=true (for keeping *date* value) |
||
1301 | |||
1302 | // set time to zero |
||
1303 | this.set({ |
||
1304 | hours: 0, |
||
1305 | minutes: 0, |
||
1306 | seconds: 0, |
||
1307 | ms: 0 |
||
1308 | }); |
||
1309 | |||
1310 | // Mark the time as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), |
||
1311 | // which clears all ambig flags. |
||
1312 | this._ambigTime = true; |
||
1313 | this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset |
||
1314 | } |
||
1315 | |||
1316 | return this; // for chaining |
||
1317 | }; |
||
1318 | |||
1319 | // Returns if the moment has a non-ambiguous time (boolean) |
||
1320 | newMomentProto.hasTime = function() { |
||
1321 | return !this._ambigTime; |
||
1322 | }; |
||
1323 | |||
1324 | |||
1325 | // Timezone |
||
1326 | // ------------------------------------------------------------------------------------------------- |
||
1327 | |||
1328 | // Converts the moment to UTC, stripping out its timezone offset, but preserving its |
||
1329 | // YMD and time-of-day. A moment with a stripped timezone offset will display no |
||
1330 | // timezone offset when .format() is called. |
||
1331 | newMomentProto.stripZone = function() { |
||
1332 | var wasAmbigTime; |
||
1333 | |||
1334 | if (!this._ambigZone) { |
||
1335 | |||
1336 | wasAmbigTime = this._ambigTime; |
||
1337 | |||
1338 | this.utc(true); // keepLocalTime=true (for keeping date and time values) |
||
1339 | |||
1340 | // the above call to .utc()/.utcOffset() unfortunately might clear the ambig flags, so restore |
||
1341 | this._ambigTime = wasAmbigTime || false; |
||
1342 | |||
1343 | // Mark the zone as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), |
||
1344 | // which clears the ambig flags. |
||
1345 | this._ambigZone = true; |
||
1346 | } |
||
1347 | |||
1348 | return this; // for chaining |
||
1349 | }; |
||
1350 | |||
1351 | // Returns of the moment has a non-ambiguous timezone offset (boolean) |
||
1352 | newMomentProto.hasZone = function() { |
||
1353 | return !this._ambigZone; |
||
1354 | }; |
||
1355 | |||
1356 | |||
1357 | // implicitly marks a zone |
||
1358 | newMomentProto.local = function(keepLocalTime) { |
||
1359 | |||
1360 | // for when converting from ambiguously-zoned to local, |
||
1361 | // keep the time values when converting from UTC -> local |
||
1362 | oldMomentProto.local.call(this, this._ambigZone || keepLocalTime); |
||
1363 | |||
1364 | // ensure non-ambiguous |
||
1365 | // this probably already happened via local() -> utcOffset(), but don't rely on Moment's internals |
||
1366 | this._ambigTime = false; |
||
1367 | this._ambigZone = false; |
||
1368 | |||
1369 | return this; // for chaining |
||
1370 | }; |
||
1371 | |||
1372 | |||
1373 | // implicitly marks a zone |
||
1374 | newMomentProto.utc = function(keepLocalTime) { |
||
1375 | |||
1376 | oldMomentProto.utc.call(this, keepLocalTime); |
||
1377 | |||
1378 | // ensure non-ambiguous |
||
1379 | // this probably already happened via utc() -> utcOffset(), but don't rely on Moment's internals |
||
1380 | this._ambigTime = false; |
||
1381 | this._ambigZone = false; |
||
1382 | |||
1383 | return this; |
||
1384 | }; |
||
1385 | |||
1386 | |||
1387 | // implicitly marks a zone (will probably get called upon .utc() and .local()) |
||
1388 | newMomentProto.utcOffset = function(tzo) { |
||
1389 | |||
1390 | if (tzo != null) { // setter |
||
1391 | // these assignments needs to happen before the original zone method is called. |
||
1392 | // I forget why, something to do with a browser crash. |
||
1393 | this._ambigTime = false; |
||
1394 | this._ambigZone = false; |
||
1395 | } |
||
1396 | |||
1397 | return oldMomentProto.utcOffset.apply(this, arguments); |
||
1398 | }; |
||
1399 | |||
1400 | |||
1401 | // Formatting |
||
1402 | // ------------------------------------------------------------------------------------------------- |
||
1403 | |||
1404 | newMomentProto.format = function() { |
||
1405 | |||
1406 | if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided? |
||
1407 | return formatDate(this, arguments[0]); // our extended formatting |
||
1408 | } |
||
1409 | if (this._ambigTime) { |
||
1410 | return oldMomentFormat(englishMoment(this), 'YYYY-MM-DD'); |
||
1411 | } |
||
1412 | if (this._ambigZone) { |
||
1413 | return oldMomentFormat(englishMoment(this), 'YYYY-MM-DD[T]HH:mm:ss'); |
||
1414 | } |
||
1415 | if (this._fullCalendar) { // enhanced non-ambig moment? |
||
1416 | // moment.format() doesn't ensure english, but we want to. |
||
1417 | return oldMomentFormat(englishMoment(this)); |
||
1418 | } |
||
1419 | |||
1420 | return oldMomentProto.format.apply(this, arguments); |
||
1421 | }; |
||
1422 | |||
1423 | newMomentProto.toISOString = function() { |
||
1424 | |||
1425 | if (this._ambigTime) { |
||
1426 | return oldMomentFormat(englishMoment(this), 'YYYY-MM-DD'); |
||
1427 | } |
||
1428 | if (this._ambigZone) { |
||
1429 | return oldMomentFormat(englishMoment(this), 'YYYY-MM-DD[T]HH:mm:ss'); |
||
1430 | } |
||
1431 | if (this._fullCalendar) { // enhanced non-ambig moment? |
||
1432 | // depending on browser, moment might not output english. ensure english. |
||
1433 | // https://github.com/moment/moment/blob/2.18.1/src/lib/moment/format.js#L22 |
||
1434 | return oldMomentProto.toISOString.apply(englishMoment(this), arguments); |
||
1435 | } |
||
1436 | |||
1437 | return oldMomentProto.toISOString.apply(this, arguments); |
||
1438 | }; |
||
1439 | |||
1440 | function englishMoment(mom) { |
||
1441 | if (mom.locale() !== 'en') { |
||
1442 | return mom.clone().locale('en'); |
||
1443 | } |
||
1444 | return mom; |
||
1445 | } |
||
1446 | |||
1447 | ;; |
||
1448 | (function() { |
||
1449 | |||
1450 | // exports |
||
1451 | FC.formatDate = formatDate; |
||
1452 | FC.formatRange = formatRange; |
||
1453 | FC.oldMomentFormat = oldMomentFormat; |
||
1454 | FC.queryMostGranularFormatUnit = queryMostGranularFormatUnit; |
||
1455 | |||
1456 | |||
1457 | // Config |
||
1458 | // --------------------------------------------------------------------------------------------------------------------- |
||
1459 | |||
1460 | /* |
||
1461 | Inserted between chunks in the fake ("intermediate") formatting string. |
||
1462 | Important that it passes as whitespace (\s) because moment often identifies non-standalone months |
||
1463 | via a regexp with an \s. |
||
1464 | */ |
||
1465 | var PART_SEPARATOR = '\u000b'; // vertical tab |
||
1466 | |||
1467 | /* |
||
1468 | Inserted as the first character of a literal-text chunk to indicate that the literal text is not actually literal text, |
||
1469 | but rather, a "special" token that has custom rendering (see specialTokens map). |
||
1470 | */ |
||
1471 | var SPECIAL_TOKEN_MARKER = '\u001f'; // information separator 1 |
||
1472 | |||
1473 | /* |
||
1474 | Inserted at the beginning and end of a span of text that must have non-zero numeric characters. |
||
1475 | Handling of these markers is done in a post-processing step at the very end of text rendering. |
||
1476 | */ |
||
1477 | var MAYBE_MARKER = '\u001e'; // information separator 2 |
||
1478 | var MAYBE_REGEXP = new RegExp(MAYBE_MARKER + '([^' + MAYBE_MARKER + ']*)' + MAYBE_MARKER, 'g'); // must be global |
||
1479 | |||
1480 | /* |
||
1481 | Addition formatting tokens we want recognized |
||
1482 | */ |
||
1483 | var specialTokens = { |
||
1484 | t: function(date) { // "a" or "p" |
||
1485 | return oldMomentFormat(date, 'a').charAt(0); |
||
1486 | }, |
||
1487 | T: function(date) { // "A" or "P" |
||
1488 | return oldMomentFormat(date, 'A').charAt(0); |
||
1489 | } |
||
1490 | }; |
||
1491 | |||
1492 | /* |
||
1493 | The first characters of formatting tokens for units that are 1 day or larger. |
||
1494 | `value` is for ranking relative size (lower means bigger). |
||
1495 | `unit` is a normalized unit, used for comparing moments. |
||
1496 | */ |
||
1497 | var largeTokenMap = { |
||
1498 | Y: { value: 1, unit: 'year' }, |
||
1499 | M: { value: 2, unit: 'month' }, |
||
1500 | W: { value: 3, unit: 'week' }, // ISO week |
||
1501 | w: { value: 3, unit: 'week' }, // local week |
||
1502 | D: { value: 4, unit: 'day' }, // day of month |
||
1503 | d: { value: 4, unit: 'day' } // day of week |
||
1504 | }; |
||
1505 | |||
1506 | |||
1507 | // Single Date Formatting |
||
1508 | // --------------------------------------------------------------------------------------------------------------------- |
||
1509 | |||
1510 | /* |
||
1511 | Formats `date` with a Moment formatting string, but allow our non-zero areas and special token |
||
1512 | */ |
||
1513 | function formatDate(date, formatStr) { |
||
1514 | return renderFakeFormatString( |
||
1515 | getParsedFormatString(formatStr).fakeFormatString, |
||
1516 | date |
||
1517 | ); |
||
1518 | } |
||
1519 | |||
1520 | /* |
||
1521 | Call this if you want Moment's original format method to be used |
||
1522 | */ |
||
1523 | function oldMomentFormat(mom, formatStr) { |
||
1524 | return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js |
||
1525 | } |
||
1526 | |||
1527 | |||
1528 | // Date Range Formatting |
||
1529 | // ------------------------------------------------------------------------------------------------- |
||
1530 | // TODO: make it work with timezone offset |
||
1531 | |||
1532 | /* |
||
1533 | Using a formatting string meant for a single date, generate a range string, like |
||
1534 | "Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ. |
||
1535 | If the dates are the same as far as the format string is concerned, just return a single |
||
1536 | rendering of one date, without any separator. |
||
1537 | */ |
||
1538 | function formatRange(date1, date2, formatStr, separator, isRTL) { |
||
1539 | var localeData; |
||
1540 | |||
1541 | date1 = FC.moment.parseZone(date1); |
||
1542 | date2 = FC.moment.parseZone(date2); |
||
1543 | |||
1544 | localeData = date1.localeData(); |
||
1545 | |||
1546 | // Expand localized format strings, like "LL" -> "MMMM D YYYY". |
||
1547 | // BTW, this is not important for `formatDate` because it is impossible to put custom tokens |
||
1548 | // or non-zero areas in Moment's localized format strings. |
||
1549 | formatStr = localeData.longDateFormat(formatStr) || formatStr; |
||
1550 | |||
1551 | return renderParsedFormat( |
||
1552 | getParsedFormatString(formatStr), |
||
1553 | date1, |
||
1554 | date2, |
||
1555 | separator || ' - ', |
||
1556 | isRTL |
||
1557 | ); |
||
1558 | } |
||
1559 | |||
1560 | /* |
||
1561 | Renders a range with an already-parsed format string. |
||
1562 | */ |
||
1563 | function renderParsedFormat(parsedFormat, date1, date2, separator, isRTL) { |
||
1564 | var sameUnits = parsedFormat.sameUnits; |
||
1565 | var unzonedDate1 = date1.clone().stripZone(); // for same-unit comparisons |
||
1566 | var unzonedDate2 = date2.clone().stripZone(); // " |
||
1567 | |||
1568 | var renderedParts1 = renderFakeFormatStringParts(parsedFormat.fakeFormatString, date1); |
||
1569 | var renderedParts2 = renderFakeFormatStringParts(parsedFormat.fakeFormatString, date2); |
||
1570 | |||
1571 | var leftI; |
||
1572 | var leftStr = ''; |
||
1573 | var rightI; |
||
1574 | var rightStr = ''; |
||
1575 | var middleI; |
||
1576 | var middleStr1 = ''; |
||
1577 | var middleStr2 = ''; |
||
1578 | var middleStr = ''; |
||
1579 | |||
1580 | // Start at the leftmost side of the formatting string and continue until you hit a token |
||
1581 | // that is not the same between dates. |
||
1582 | for ( |
||
1583 | leftI = 0; |
||
1584 | leftI < sameUnits.length && (!sameUnits[leftI] || unzonedDate1.isSame(unzonedDate2, sameUnits[leftI])); |
||
1585 | leftI++ |
||
1586 | ) { |
||
1587 | leftStr += renderedParts1[leftI]; |
||
1588 | } |
||
1589 | |||
1590 | // Similarly, start at the rightmost side of the formatting string and move left |
||
1591 | for ( |
||
1592 | rightI = sameUnits.length - 1; |
||
1593 | rightI > leftI && (!sameUnits[rightI] || unzonedDate1.isSame(unzonedDate2, sameUnits[rightI])); |
||
1594 | rightI-- |
||
1595 | ) { |
||
1596 | // If current chunk is on the boundary of unique date-content, and is a special-case |
||
1597 | // date-formatting postfix character, then don't consume it. Consider it unique date-content. |
||
1598 | // TODO: make configurable |
||
1599 | if (rightI - 1 === leftI && renderedParts1[rightI] === '.') { |
||
1600 | break; |
||
1601 | } |
||
1602 | |||
1603 | rightStr = renderedParts1[rightI] + rightStr; |
||
1604 | } |
||
1605 | |||
1606 | // The area in the middle is different for both of the dates. |
||
1607 | // Collect them distinctly so we can jam them together later. |
||
1608 | for (middleI = leftI; middleI <= rightI; middleI++) { |
||
1609 | middleStr1 += renderedParts1[middleI]; |
||
1610 | middleStr2 += renderedParts2[middleI]; |
||
1611 | } |
||
1612 | |||
1613 | if (middleStr1 || middleStr2) { |
||
1614 | if (isRTL) { |
||
1615 | middleStr = middleStr2 + separator + middleStr1; |
||
1616 | } |
||
1617 | else { |
||
1618 | middleStr = middleStr1 + separator + middleStr2; |
||
1619 | } |
||
1620 | } |
||
1621 | |||
1622 | return processMaybeMarkers( |
||
1623 | leftStr + middleStr + rightStr |
||
1624 | ); |
||
1625 | } |
||
1626 | |||
1627 | |||
1628 | // Format String Parsing |
||
1629 | // --------------------------------------------------------------------------------------------------------------------- |
||
1630 | |||
1631 | var parsedFormatStrCache = {}; |
||
1632 | |||
1633 | /* |
||
1634 | Returns a parsed format string, leveraging a cache. |
||
1635 | */ |
||
1636 | function getParsedFormatString(formatStr) { |
||
1637 | return parsedFormatStrCache[formatStr] || |
||
1638 | (parsedFormatStrCache[formatStr] = parseFormatString(formatStr)); |
||
1639 | } |
||
1640 | |||
1641 | /* |
||
1642 | Parses a format string into the following: |
||
1643 | - fakeFormatString: a momentJS formatting string, littered with special control characters that get post-processed. |
||
1644 | - sameUnits: for every part in fakeFormatString, if the part is a token, the value will be a unit string (like "day"), |
||
1645 | that indicates how similar a range's start & end must be in order to share the same formatted text. |
||
1646 | If not a token, then the value is null. |
||
1647 | Always a flat array (not nested liked "chunks"). |
||
1648 | */ |
||
1649 | function parseFormatString(formatStr) { |
||
1650 | var chunks = chunkFormatString(formatStr); |
||
1651 | |||
1652 | return { |
||
1653 | fakeFormatString: buildFakeFormatString(chunks), |
||
1654 | sameUnits: buildSameUnits(chunks) |
||
1655 | }; |
||
1656 | } |
||
1657 | |||
1658 | /* |
||
1659 | Break the formatting string into an array of chunks. |
||
1660 | A 'maybe' chunk will have nested chunks. |
||
1661 | */ |
||
1662 | function chunkFormatString(formatStr) { |
||
1663 | var chunks = []; |
||
1664 | var match; |
||
1665 | |||
1666 | // TODO: more descrimination |
||
1667 | // \4 is a backreference to the first character of a multi-character set. |
||
1668 | var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g; |
||
1669 | |||
1670 | while ((match = chunker.exec(formatStr))) { |
||
1671 | if (match[1]) { // a literal string inside [ ... ] |
||
1672 | chunks.push.apply(chunks, // append |
||
1673 | splitStringLiteral(match[1]) |
||
1674 | ); |
||
1675 | } |
||
1676 | else if (match[2]) { // non-zero formatting inside ( ... ) |
||
1677 | chunks.push({ maybe: chunkFormatString(match[2]) }); |
||
1678 | } |
||
1679 | else if (match[3]) { // a formatting token |
||
1680 | chunks.push({ token: match[3] }); |
||
1681 | } |
||
1682 | else if (match[5]) { // an unenclosed literal string |
||
1683 | chunks.push.apply(chunks, // append |
||
1684 | splitStringLiteral(match[5]) |
||
1685 | ); |
||
1686 | } |
||
1687 | } |
||
1688 | |||
1689 | return chunks; |
||
1690 | } |
||
1691 | |||
1692 | /* |
||
1693 | Potentially splits a literal-text string into multiple parts. For special cases. |
||
1694 | */ |
||
1695 | function splitStringLiteral(s) { |
||
1696 | if (s === '. ') { |
||
1697 | return [ '.', ' ' ]; // for locales with periods bound to the end of each year/month/date |
||
1698 | } |
||
1699 | else { |
||
1700 | return [ s ]; |
||
1701 | } |
||
1702 | } |
||
1703 | |||
1704 | /* |
||
1705 | Given chunks parsed from a real format string, generate a fake (aka "intermediate") format string with special control |
||
1706 | characters that will eventually be given to moment for formatting, and then post-processed. |
||
1707 | */ |
||
1708 | function buildFakeFormatString(chunks) { |
||
1709 | var parts = []; |
||
1710 | var i, chunk; |
||
1711 | |||
1712 | for (i = 0; i < chunks.length; i++) { |
||
1713 | chunk = chunks[i]; |
||
1714 | |||
1715 | if (typeof chunk === 'string') { |
||
1716 | parts.push('[' + chunk + ']'); |
||
1717 | } |
||
1718 | else if (chunk.token) { |
||
1719 | if (chunk.token in specialTokens) { |
||
1720 | parts.push( |
||
1721 | SPECIAL_TOKEN_MARKER + // useful during post-processing |
||
1722 | '[' + chunk.token + ']' // preserve as literal text |
||
1723 | ); |
||
1724 | } |
||
1725 | else { |
||
1726 | parts.push(chunk.token); // unprotected text implies a format string |
||
1727 | } |
||
1728 | } |
||
1729 | else if (chunk.maybe) { |
||
1730 | parts.push( |
||
1731 | MAYBE_MARKER + // useful during post-processing |
||
1732 | buildFakeFormatString(chunk.maybe) + |
||
1733 | MAYBE_MARKER |
||
1734 | ); |
||
1735 | } |
||
1736 | } |
||
1737 | |||
1738 | return parts.join(PART_SEPARATOR); |
||
1739 | } |
||
1740 | |||
1741 | /* |
||
1742 | Given parsed chunks from a real formatting string, generates an array of unit strings (like "day") that indicate |
||
1743 | in which regard two dates must be similar in order to share range formatting text. |
||
1744 | The `chunks` can be nested (because of "maybe" chunks), however, the returned array will be flat. |
||
1745 | */ |
||
1746 | function buildSameUnits(chunks) { |
||
1747 | var units = []; |
||
1748 | var i, chunk; |
||
1749 | var tokenInfo; |
||
1750 | |||
1751 | for (i = 0; i < chunks.length; i++) { |
||
1752 | chunk = chunks[i]; |
||
1753 | |||
1754 | if (chunk.token) { |
||
1755 | tokenInfo = largeTokenMap[chunk.token.charAt(0)]; |
||
1756 | units.push(tokenInfo ? tokenInfo.unit : 'second'); // default to a very strict same-second |
||
1757 | } |
||
1758 | else if (chunk.maybe) { |
||
1759 | units.push.apply(units, // append |
||
1760 | buildSameUnits(chunk.maybe) |
||
1761 | ); |
||
1762 | } |
||
1763 | else { |
||
1764 | units.push(null); |
||
1765 | } |
||
1766 | } |
||
1767 | |||
1768 | return units; |
||
1769 | } |
||
1770 | |||
1771 | |||
1772 | // Rendering to text |
||
1773 | // --------------------------------------------------------------------------------------------------------------------- |
||
1774 | |||
1775 | /* |
||
1776 | Formats a date with a fake format string, post-processes the control characters, then returns. |
||
1777 | */ |
||
1778 | function renderFakeFormatString(fakeFormatString, date) { |
||
1779 | return processMaybeMarkers( |
||
1780 | renderFakeFormatStringParts(fakeFormatString, date).join('') |
||
1781 | ); |
||
1782 | } |
||
1783 | |||
1784 | /* |
||
1785 | Formats a date into parts that will have been post-processed, EXCEPT for the "maybe" markers. |
||
1786 | */ |
||
1787 | function renderFakeFormatStringParts(fakeFormatString, date) { |
||
1788 | var parts = []; |
||
1789 | var fakeRender = oldMomentFormat(date, fakeFormatString); |
||
1790 | var fakeParts = fakeRender.split(PART_SEPARATOR); |
||
1791 | var i, fakePart; |
||
1792 | |||
1793 | for (i = 0; i < fakeParts.length; i++) { |
||
1794 | fakePart = fakeParts[i]; |
||
1795 | |||
1796 | if (fakePart.charAt(0) === SPECIAL_TOKEN_MARKER) { |
||
1797 | parts.push( |
||
1798 | // the literal string IS the token's name. |
||
1799 | // call special token's registered function. |
||
1800 | specialTokens[fakePart.substring(1)](date) |
||
1801 | ); |
||
1802 | } |
||
1803 | else { |
||
1804 | parts.push(fakePart); |
||
1805 | } |
||
1806 | } |
||
1807 | |||
1808 | return parts; |
||
1809 | } |
||
1810 | |||
1811 | /* |
||
1812 | Accepts an almost-finally-formatted string and processes the "maybe" control characters, returning a new string. |
||
1813 | */ |
||
1814 | function processMaybeMarkers(s) { |
||
1815 | return s.replace(MAYBE_REGEXP, function(m0, m1) { // regex assumed to have 'g' flag |
||
1816 | if (m1.match(/[1-9]/)) { // any non-zero numeric characters? |
||
1817 | return m1; |
||
1818 | } |
||
1819 | else { |
||
1820 | return ''; |
||
1821 | } |
||
1822 | }); |
||
1823 | } |
||
1824 | |||
1825 | |||
1826 | // Misc Utils |
||
1827 | // ------------------------------------------------------------------------------------------------- |
||
1828 | |||
1829 | /* |
||
1830 | Returns a unit string, either 'year', 'month', 'day', or null for the most granular formatting token in the string. |
||
1831 | */ |
||
1832 | function queryMostGranularFormatUnit(formatStr) { |
||
1833 | var chunks = chunkFormatString(formatStr); |
||
1834 | var i, chunk; |
||
1835 | var candidate; |
||
1836 | var best; |
||
1837 | |||
1838 | for (i = 0; i < chunks.length; i++) { |
||
1839 | chunk = chunks[i]; |
||
1840 | |||
1841 | if (chunk.token) { |
||
1842 | candidate = largeTokenMap[chunk.token.charAt(0)]; |
||
1843 | if (candidate) { |
||
1844 | if (!best || candidate.value > best.value) { |
||
1845 | best = candidate; |
||
1846 | } |
||
1847 | } |
||
1848 | } |
||
1849 | } |
||
1850 | |||
1851 | if (best) { |
||
1852 | return best.unit; |
||
1853 | } |
||
1854 | |||
1855 | return null; |
||
1856 | }; |
||
1857 | |||
1858 | })(); |
||
1859 | |||
1860 | // quick local references |
||
1861 | var formatDate = FC.formatDate; |
||
1862 | var formatRange = FC.formatRange; |
||
1863 | var oldMomentFormat = FC.oldMomentFormat; |
||
1864 | |||
1865 | ;; |
||
1866 | |||
1867 | FC.Class = Class; // export |
||
1868 | |||
1869 | // Class that all other classes will inherit from |
||
1870 | function Class() { } |
||
1871 | |||
1872 | |||
1873 | // Called on a class to create a subclass. |
||
1874 | // Last argument contains instance methods. Any argument before the last are considered mixins. |
||
1875 | Class.extend = function() { |
||
1876 | var len = arguments.length; |
||
1877 | var i; |
||
1878 | var members; |
||
1879 | |||
1880 | for (i = 0; i < len; i++) { |
||
1881 | members = arguments[i]; |
||
1882 | if (i < len - 1) { // not the last argument? |
||
1883 | mixIntoClass(this, members); |
||
1884 | } |
||
1885 | } |
||
1886 | |||
1887 | return extendClass(this, members || {}); // members will be undefined if no arguments |
||
1888 | }; |
||
1889 | |||
1890 | |||
1891 | // Adds new member variables/methods to the class's prototype. |
||
1892 | // Can be called with another class, or a plain object hash containing new members. |
||
1893 | Class.mixin = function(members) { |
||
1894 | mixIntoClass(this, members); |
||
1895 | }; |
||
1896 | |||
1897 | |||
1898 | function extendClass(superClass, members) { |
||
1899 | var subClass; |
||
1900 | |||
1901 | // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist |
||
1902 | if (hasOwnProp(members, 'constructor')) { |
||
1903 | subClass = members.constructor; |
||
1904 | } |
||
1905 | if (typeof subClass !== 'function') { |
||
1906 | subClass = members.constructor = function() { |
||
1907 | superClass.apply(this, arguments); |
||
1908 | }; |
||
1909 | } |
||
1910 | |||
1911 | // build the base prototype for the subclass, which is an new object chained to the superclass's prototype |
||
1912 | subClass.prototype = createObject(superClass.prototype); |
||
1913 | |||
1914 | // copy each member variable/method onto the the subclass's prototype |
||
1915 | copyOwnProps(members, subClass.prototype); |
||
1916 | |||
1917 | // copy over all class variables/methods to the subclass, such as `extend` and `mixin` |
||
1918 | copyOwnProps(superClass, subClass); |
||
1919 | |||
1920 | return subClass; |
||
1921 | } |
||
1922 | |||
1923 | |||
1924 | function mixIntoClass(theClass, members) { |
||
1925 | copyOwnProps(members, theClass.prototype); |
||
1926 | } |
||
1927 | ;; |
||
1928 | |||
1929 | var Model = Class.extend(EmitterMixin, ListenerMixin, { |
||
1930 | |||
1931 | _props: null, |
||
1932 | _watchers: null, |
||
1933 | _globalWatchArgs: null, |
||
1934 | |||
1935 | constructor: function() { |
||
1936 | this._watchers = {}; |
||
1937 | this._props = {}; |
||
1938 | this.applyGlobalWatchers(); |
||
1939 | }, |
||
1940 | |||
1941 | applyGlobalWatchers: function() { |
||
1942 | var argSets = this._globalWatchArgs || []; |
||
1943 | var i; |
||
1944 | |||
1945 | for (i = 0; i < argSets.length; i++) { |
||
1946 | this.watch.apply(this, argSets[i]); |
||
1947 | } |
||
1948 | }, |
||
1949 | |||
1950 | has: function(name) { |
||
1951 | return name in this._props; |
||
1952 | }, |
||
1953 | |||
1954 | get: function(name) { |
||
1955 | if (name === undefined) { |
||
1956 | return this._props; |
||
1957 | } |
||
1958 | |||
1959 | return this._props[name]; |
||
1960 | }, |
||
1961 | |||
1962 | set: function(name, val) { |
||
1963 | var newProps; |
||
1964 | |||
1965 | if (typeof name === 'string') { |
||
1966 | newProps = {}; |
||
1967 | newProps[name] = val === undefined ? null : val; |
||
1968 | } |
||
1969 | else { |
||
1970 | newProps = name; |
||
1971 | } |
||
1972 | |||
1973 | this.setProps(newProps); |
||
1974 | }, |
||
1975 | |||
1976 | reset: function(newProps) { |
||
1977 | var oldProps = this._props; |
||
1978 | var changeset = {}; // will have undefined's to signal unsets |
||
1979 | var name; |
||
1980 | |||
1981 | for (name in oldProps) { |
||
1982 | changeset[name] = undefined; |
||
1983 | } |
||
1984 | |||
1985 | for (name in newProps) { |
||
1986 | changeset[name] = newProps[name]; |
||
1987 | } |
||
1988 | |||
1989 | this.setProps(changeset); |
||
1990 | }, |
||
1991 | |||
1992 | unset: function(name) { // accepts a string or array of strings |
||
1993 | var newProps = {}; |
||
1994 | var names; |
||
1995 | var i; |
||
1996 | |||
1997 | if (typeof name === 'string') { |
||
1998 | names = [ name ]; |
||
1999 | } |
||
2000 | else { |
||
2001 | names = name; |
||
2002 | } |
||
2003 | |||
2004 | for (i = 0; i < names.length; i++) { |
||
2005 | newProps[names[i]] = undefined; |
||
2006 | } |
||
2007 | |||
2008 | this.setProps(newProps); |
||
2009 | }, |
||
2010 | |||
2011 | setProps: function(newProps) { |
||
2012 | var changedProps = {}; |
||
2013 | var changedCnt = 0; |
||
2014 | var name, val; |
||
2015 | |||
2016 | for (name in newProps) { |
||
2017 | val = newProps[name]; |
||
2018 | |||
2019 | // a change in value? |
||
2020 | // if an object, don't check equality, because might have been mutated internally. |
||
2021 | // TODO: eventually enforce immutability. |
||
2022 | if ( |
||
2023 | typeof val === 'object' || |
||
2024 | val !== this._props[name] |
||
2025 | ) { |
||
2026 | changedProps[name] = val; |
||
2027 | changedCnt++; |
||
2028 | } |
||
2029 | } |
||
2030 | |||
2031 | if (changedCnt) { |
||
2032 | |||
2033 | this.trigger('before:batchChange', changedProps); |
||
2034 | |||
2035 | for (name in changedProps) { |
||
2036 | val = changedProps[name]; |
||
2037 | |||
2038 | this.trigger('before:change', name, val); |
||
2039 | this.trigger('before:change:' + name, val); |
||
2040 | } |
||
2041 | |||
2042 | for (name in changedProps) { |
||
2043 | val = changedProps[name]; |
||
2044 | |||
2045 | if (val === undefined) { |
||
2046 | delete this._props[name]; |
||
2047 | } |
||
2048 | else { |
||
2049 | this._props[name] = val; |
||
2050 | } |
||
2051 | |||
2052 | this.trigger('change:' + name, val); |
||
2053 | this.trigger('change', name, val); |
||
2054 | } |
||
2055 | |||
2056 | this.trigger('batchChange', changedProps); |
||
2057 | } |
||
2058 | }, |
||
2059 | |||
2060 | watch: function(name, depList, startFunc, stopFunc) { |
||
2061 | var _this = this; |
||
2062 | |||
2063 | this.unwatch(name); |
||
2064 | |||
2065 | this._watchers[name] = this._watchDeps(depList, function(deps) { |
||
2066 | var res = startFunc.call(_this, deps); |
||
2067 | |||
2068 | if (res && res.then) { |
||
2069 | _this.unset(name); // put in an unset state while resolving |
||
2070 | res.then(function(val) { |
||
2071 | _this.set(name, val); |
||
2072 | }); |
||
2073 | } |
||
2074 | else { |
||
2075 | _this.set(name, res); |
||
2076 | } |
||
2077 | }, function() { |
||
2078 | _this.unset(name); |
||
2079 | |||
2080 | if (stopFunc) { |
||
2081 | stopFunc.call(_this); |
||
2082 | } |
||
2083 | }); |
||
2084 | }, |
||
2085 | |||
2086 | unwatch: function(name) { |
||
2087 | var watcher = this._watchers[name]; |
||
2088 | |||
2089 | if (watcher) { |
||
2090 | delete this._watchers[name]; |
||
2091 | watcher.teardown(); |
||
2092 | } |
||
2093 | }, |
||
2094 | |||
2095 | _watchDeps: function(depList, startFunc, stopFunc) { |
||
2096 | var _this = this; |
||
2097 | var queuedChangeCnt = 0; |
||
2098 | var depCnt = depList.length; |
||
2099 | var satisfyCnt = 0; |
||
2100 | var values = {}; // what's passed as the `deps` arguments |
||
2101 | var bindTuples = []; // array of [ eventName, handlerFunc ] arrays |
||
2102 | var isCallingStop = false; |
||
2103 | |||
2104 | function onBeforeDepChange(depName, val, isOptional) { |
||
2105 | queuedChangeCnt++; |
||
2106 | if (queuedChangeCnt === 1) { // first change to cause a "stop" ? |
||
2107 | if (satisfyCnt === depCnt) { // all deps previously satisfied? |
||
2108 | isCallingStop = true; |
||
2109 | stopFunc(); |
||
2110 | isCallingStop = false; |
||
2111 | } |
||
2112 | } |
||
2113 | } |
||
2114 | |||
2115 | function onDepChange(depName, val, isOptional) { |
||
2116 | |||
2117 | if (val === undefined) { // unsetting a value? |
||
2118 | |||
2119 | // required dependency that was previously set? |
||
2120 | if (!isOptional && values[depName] !== undefined) { |
||
2121 | satisfyCnt--; |
||
2122 | } |
||
2123 | |||
2124 | delete values[depName]; |
||
2125 | } |
||
2126 | else { // setting a value? |
||
2127 | |||
2128 | // required dependency that was previously unset? |
||
2129 | if (!isOptional && values[depName] === undefined) { |
||
2130 | satisfyCnt++; |
||
2131 | } |
||
2132 | |||
2133 | values[depName] = val; |
||
2134 | } |
||
2135 | |||
2136 | queuedChangeCnt--; |
||
2137 | if (!queuedChangeCnt) { // last change to cause a "start"? |
||
2138 | |||
2139 | // now finally satisfied or satisfied all along? |
||
2140 | if (satisfyCnt === depCnt) { |
||
2141 | |||
2142 | // if the stopFunc initiated another value change, ignore it. |
||
2143 | // it will be processed by another change event anyway. |
||
2144 | if (!isCallingStop) { |
||
2145 | startFunc(values); |
||
2146 | } |
||
2147 | } |
||
2148 | } |
||
2149 | } |
||
2150 | |||
2151 | // intercept for .on() that remembers handlers |
||
2152 | function bind(eventName, handler) { |
||
2153 | _this.on(eventName, handler); |
||
2154 | bindTuples.push([ eventName, handler ]); |
||
2155 | } |
||
2156 | |||
2157 | // listen to dependency changes |
||
2158 | depList.forEach(function(depName) { |
||
2159 | var isOptional = false; |
||
2160 | |||
2161 | if (depName.charAt(0) === '?') { // TODO: more DRY |
||
2162 | depName = depName.substring(1); |
||
2163 | isOptional = true; |
||
2164 | } |
||
2165 | |||
2166 | bind('before:change:' + depName, function(val) { |
||
2167 | onBeforeDepChange(depName, val, isOptional); |
||
2168 | }); |
||
2169 | |||
2170 | bind('change:' + depName, function(val) { |
||
2171 | onDepChange(depName, val, isOptional); |
||
2172 | }); |
||
2173 | }); |
||
2174 | |||
2175 | // process current dependency values |
||
2176 | depList.forEach(function(depName) { |
||
2177 | var isOptional = false; |
||
2178 | |||
2179 | if (depName.charAt(0) === '?') { // TODO: more DRY |
||
2180 | depName = depName.substring(1); |
||
2181 | isOptional = true; |
||
2182 | } |
||
2183 | |||
2184 | if (_this.has(depName)) { |
||
2185 | values[depName] = _this.get(depName); |
||
2186 | satisfyCnt++; |
||
2187 | } |
||
2188 | else if (isOptional) { |
||
2189 | satisfyCnt++; |
||
2190 | } |
||
2191 | }); |
||
2192 | |||
2193 | // initially satisfied |
||
2194 | if (satisfyCnt === depCnt) { |
||
2195 | startFunc(values); |
||
2196 | } |
||
2197 | |||
2198 | return { |
||
2199 | teardown: function() { |
||
2200 | // remove all handlers |
||
2201 | for (var i = 0; i < bindTuples.length; i++) { |
||
2202 | _this.off(bindTuples[i][0], bindTuples[i][1]); |
||
2203 | } |
||
2204 | bindTuples = null; |
||
2205 | |||
2206 | // was satisfied, so call stopFunc |
||
2207 | if (satisfyCnt === depCnt) { |
||
2208 | stopFunc(); |
||
2209 | } |
||
2210 | }, |
||
2211 | flash: function() { |
||
2212 | if (satisfyCnt === depCnt) { |
||
2213 | stopFunc(); |
||
2214 | startFunc(values); |
||
2215 | } |
||
2216 | } |
||
2217 | }; |
||
2218 | }, |
||
2219 | |||
2220 | flash: function(name) { |
||
2221 | var watcher = this._watchers[name]; |
||
2222 | |||
2223 | if (watcher) { |
||
2224 | watcher.flash(); |
||
2225 | } |
||
2226 | } |
||
2227 | |||
2228 | }); |
||
2229 | |||
2230 | |||
2231 | Model.watch = function(/* same arguments as this.watch() */) { |
||
2232 | var proto = this.prototype; |
||
2233 | |||
2234 | if (!proto._globalWatchArgs) { |
||
2235 | proto._globalWatchArgs = []; |
||
2236 | } |
||
2237 | |||
2238 | proto._globalWatchArgs.push(arguments); |
||
2239 | }; |
||
2240 | |||
2241 | |||
2242 | FC.Model = Model; |
||
2243 | |||
2244 | |||
2245 | ;; |
||
2246 | |||
2247 | var Promise = { |
||
2248 | |||
2249 | construct: function(executor) { |
||
2250 | var deferred = $.Deferred(); |
||
2251 | var promise = deferred.promise(); |
||
2252 | |||
2253 | if (typeof executor === 'function') { |
||
2254 | executor( |
||
2255 | function(val) { // resolve |
||
2256 | deferred.resolve(val); |
||
2257 | attachImmediatelyResolvingThen(promise, val); |
||
2258 | }, |
||
2259 | function() { // reject |
||
2260 | deferred.reject(); |
||
2261 | attachImmediatelyRejectingThen(promise); |
||
2262 | } |
||
2263 | ); |
||
2264 | } |
||
2265 | |||
2266 | return promise; |
||
2267 | }, |
||
2268 | |||
2269 | resolve: function(val) { |
||
2270 | var deferred = $.Deferred().resolve(val); |
||
2271 | var promise = deferred.promise(); |
||
2272 | |||
2273 | attachImmediatelyResolvingThen(promise, val); |
||
2274 | |||
2275 | return promise; |
||
2276 | }, |
||
2277 | |||
2278 | reject: function() { |
||
2279 | var deferred = $.Deferred().reject(); |
||
2280 | var promise = deferred.promise(); |
||
2281 | |||
2282 | attachImmediatelyRejectingThen(promise); |
||
2283 | |||
2284 | return promise; |
||
2285 | } |
||
2286 | |||
2287 | }; |
||
2288 | |||
2289 | |||
2290 | function attachImmediatelyResolvingThen(promise, val) { |
||
2291 | promise.then = function(onResolve) { |
||
2292 | if (typeof onResolve === 'function') { |
||
2293 | onResolve(val); |
||
2294 | } |
||
2295 | return promise; // for chaining |
||
2296 | }; |
||
2297 | } |
||
2298 | |||
2299 | |||
2300 | function attachImmediatelyRejectingThen(promise) { |
||
2301 | promise.then = function(onResolve, onReject) { |
||
2302 | if (typeof onReject === 'function') { |
||
2303 | onReject(); |
||
2304 | } |
||
2305 | return promise; // for chaining |
||
2306 | }; |
||
2307 | } |
||
2308 | |||
2309 | |||
2310 | FC.Promise = Promise; |
||
2311 | |||
2312 | ;; |
||
2313 | |||
2314 | var TaskQueue = Class.extend(EmitterMixin, { |
||
2315 | |||
2316 | q: null, |
||
2317 | isPaused: false, |
||
2318 | isRunning: false, |
||
2319 | |||
2320 | |||
2321 | constructor: function() { |
||
2322 | this.q = []; |
||
2323 | }, |
||
2324 | |||
2325 | |||
2326 | queue: function(/* taskFunc, taskFunc... */) { |
||
2327 | this.q.push.apply(this.q, arguments); // append |
||
2328 | this.tryStart(); |
||
2329 | }, |
||
2330 | |||
2331 | |||
2332 | pause: function() { |
||
2333 | this.isPaused = true; |
||
2334 | }, |
||
2335 | |||
2336 | |||
2337 | resume: function() { |
||
2338 | this.isPaused = false; |
||
2339 | this.tryStart(); |
||
2340 | }, |
||
2341 | |||
2342 | |||
2343 | tryStart: function() { |
||
2344 | if (!this.isRunning && this.canRunNext()) { |
||
2345 | this.isRunning = true; |
||
2346 | this.trigger('start'); |
||
2347 | this.runNext(); |
||
2348 | } |
||
2349 | }, |
||
2350 | |||
2351 | |||
2352 | canRunNext: function() { |
||
2353 | return !this.isPaused && this.q.length; |
||
2354 | }, |
||
2355 | |||
2356 | |||
2357 | runNext: function() { // does not check canRunNext |
||
2358 | this.runTask(this.q.shift()); |
||
2359 | }, |
||
2360 | |||
2361 | |||
2362 | runTask: function(task) { |
||
2363 | this.runTaskFunc(task); |
||
2364 | }, |
||
2365 | |||
2366 | |||
2367 | runTaskFunc: function(taskFunc) { |
||
2368 | var _this = this; |
||
2369 | var res = taskFunc(); |
||
2370 | |||
2371 | if (res && res.then) { |
||
2372 | res.then(done); |
||
2373 | } |
||
2374 | else { |
||
2375 | done(); |
||
2376 | } |
||
2377 | |||
2378 | function done() { |
||
2379 | if (_this.canRunNext()) { |
||
2380 | _this.runNext(); |
||
2381 | } |
||
2382 | else { |
||
2383 | _this.isRunning = false; |
||
2384 | _this.trigger('stop'); |
||
2385 | } |
||
2386 | } |
||
2387 | } |
||
2388 | |||
2389 | }); |
||
2390 | |||
2391 | FC.TaskQueue = TaskQueue; |
||
2392 | |||
2393 | ;; |
||
2394 | |||
2395 | var RenderQueue = TaskQueue.extend({ |
||
2396 | |||
2397 | waitsByNamespace: null, |
||
2398 | waitNamespace: null, |
||
2399 | waitId: null, |
||
2400 | |||
2401 | |||
2402 | constructor: function(waitsByNamespace) { |
||
2403 | TaskQueue.call(this); // super-constructor |
||
2404 | |||
2405 | this.waitsByNamespace = waitsByNamespace || {}; |
||
2406 | }, |
||
2407 | |||
2408 | |||
2409 | queue: function(taskFunc, namespace, type) { |
||
2410 | var task = { |
||
2411 | func: taskFunc, |
||
2412 | namespace: namespace, |
||
2413 | type: type |
||
2414 | }; |
||
2415 | var waitMs; |
||
2416 | |||
2417 | if (namespace) { |
||
2418 | waitMs = this.waitsByNamespace[namespace]; |
||
2419 | } |
||
2420 | |||
2421 | if (this.waitNamespace) { |
||
2422 | if (namespace === this.waitNamespace && waitMs != null) { |
||
2423 | this.delayWait(waitMs); |
||
2424 | } |
||
2425 | else { |
||
2426 | this.clearWait(); |
||
2427 | this.tryStart(); |
||
2428 | } |
||
2429 | } |
||
2430 | |||
2431 | if (this.compoundTask(task)) { // appended to queue? |
||
2432 | |||
2433 | if (!this.waitNamespace && waitMs != null) { |
||
2434 | this.startWait(namespace, waitMs); |
||
2435 | } |
||
2436 | else { |
||
2437 | this.tryStart(); |
||
2438 | } |
||
2439 | } |
||
2440 | }, |
||
2441 | |||
2442 | |||
2443 | startWait: function(namespace, waitMs) { |
||
2444 | this.waitNamespace = namespace; |
||
2445 | this.spawnWait(waitMs); |
||
2446 | }, |
||
2447 | |||
2448 | |||
2449 | delayWait: function(waitMs) { |
||
2450 | clearTimeout(this.waitId); |
||
2451 | this.spawnWait(waitMs); |
||
2452 | }, |
||
2453 | |||
2454 | |||
2455 | spawnWait: function(waitMs) { |
||
2456 | var _this = this; |
||
2457 | |||
2458 | this.waitId = setTimeout(function() { |
||
2459 | _this.waitNamespace = null; |
||
2460 | _this.tryStart(); |
||
2461 | }, waitMs); |
||
2462 | }, |
||
2463 | |||
2464 | |||
2465 | clearWait: function() { |
||
2466 | if (this.waitNamespace) { |
||
2467 | clearTimeout(this.waitId); |
||
2468 | this.waitId = null; |
||
2469 | this.waitNamespace = null; |
||
2470 | } |
||
2471 | }, |
||
2472 | |||
2473 | |||
2474 | canRunNext: function() { |
||
2475 | if (!TaskQueue.prototype.canRunNext.apply(this, arguments)) { |
||
2476 | return false; |
||
2477 | } |
||
2478 | |||
2479 | // waiting for a certain namespace to stop receiving tasks? |
||
2480 | if (this.waitNamespace) { |
||
2481 | |||
2482 | // if there was a different namespace task in the meantime, |
||
2483 | // that forces all previously-waiting tasks to suddenly execute. |
||
2484 | // TODO: find a way to do this in constant time. |
||
2485 | for (var q = this.q, i = 0; i < q.length; i++) { |
||
2486 | if (q[i].namespace !== this.waitNamespace) { |
||
2487 | return true; // allow execution |
||
2488 | } |
||
2489 | } |
||
2490 | |||
2491 | return false; |
||
2492 | } |
||
2493 | |||
2494 | return true; |
||
2495 | }, |
||
2496 | |||
2497 | |||
2498 | runTask: function(task) { |
||
2499 | this.runTaskFunc(task.func); |
||
2500 | }, |
||
2501 | |||
2502 | |||
2503 | compoundTask: function(newTask) { |
||
2504 | var q = this.q; |
||
2505 | var shouldAppend = true; |
||
2506 | var i, task; |
||
2507 | |||
2508 | if (newTask.namespace) { |
||
2509 | |||
2510 | if (newTask.type === 'destroy' || newTask.type === 'init') { |
||
2511 | |||
2512 | // remove all add/remove ops with same namespace, regardless of order |
||
2513 | for (i = q.length - 1; i >= 0; i--) { |
||
2514 | task = q[i]; |
||
2515 | |||
2516 | if ( |
||
2517 | task.namespace === newTask.namespace && |
||
2518 | (task.type === 'add' || task.type === 'remove') |
||
2519 | ) { |
||
2520 | q.splice(i, 1); // remove task |
||
2521 | } |
||
2522 | } |
||
2523 | |||
2524 | if (newTask.type === 'destroy') { |
||
2525 | // eat away final init/destroy operation |
||
2526 | if (q.length) { |
||
2527 | task = q[q.length - 1]; // last task |
||
2528 | |||
2529 | if (task.namespace === newTask.namespace) { |
||
2530 | |||
2531 | // the init and our destroy cancel each other out |
||
2532 | if (task.type === 'init') { |
||
2533 | shouldAppend = false; |
||
2534 | q.pop(); |
||
2535 | } |
||
2536 | // prefer to use the destroy operation that's already present |
||
2537 | else if (task.type === 'destroy') { |
||
2538 | shouldAppend = false; |
||
2539 | } |
||
2540 | } |
||
2541 | } |
||
2542 | } |
||
2543 | else if (newTask.type === 'init') { |
||
2544 | // eat away final init operation |
||
2545 | if (q.length) { |
||
2546 | task = q[q.length - 1]; // last task |
||
2547 | |||
2548 | if ( |
||
2549 | task.namespace === newTask.namespace && |
||
2550 | task.type === 'init' |
||
2551 | ) { |
||
2552 | // our init operation takes precedence |
||
2553 | q.pop(); |
||
2554 | } |
||
2555 | } |
||
2556 | } |
||
2557 | } |
||
2558 | } |
||
2559 | |||
2560 | if (shouldAppend) { |
||
2561 | q.push(newTask); |
||
2562 | } |
||
2563 | |||
2564 | return shouldAppend; |
||
2565 | } |
||
2566 | |||
2567 | }); |
||
2568 | |||
2569 | FC.RenderQueue = RenderQueue; |
||
2570 | |||
2571 | ;; |
||
2572 | |||
2573 | var EmitterMixin = FC.EmitterMixin = { |
||
2574 | |||
2575 | // jQuery-ification via $(this) allows a non-DOM object to have |
||
2576 | // the same event handling capabilities (including namespaces). |
||
2577 | |||
2578 | |||
2579 | on: function(types, handler) { |
||
2580 | $(this).on(types, this._prepareIntercept(handler)); |
||
2581 | return this; // for chaining |
||
2582 | }, |
||
2583 | |||
2584 | |||
2585 | one: function(types, handler) { |
||
2586 | $(this).one(types, this._prepareIntercept(handler)); |
||
2587 | return this; // for chaining |
||
2588 | }, |
||
2589 | |||
2590 | |||
2591 | _prepareIntercept: function(handler) { |
||
2592 | // handlers are always called with an "event" object as their first param. |
||
2593 | // sneak the `this` context and arguments into the extra parameter object |
||
2594 | // and forward them on to the original handler. |
||
2595 | var intercept = function(ev, extra) { |
||
2596 | return handler.apply( |
||
2597 | extra.context || this, |
||
2598 | extra.args || [] |
||
2599 | ); |
||
2600 | }; |
||
2601 | |||
2602 | // mimick jQuery's internal "proxy" system (risky, I know) |
||
2603 | // causing all functions with the same .guid to appear to be the same. |
||
2604 | // https://github.com/jquery/jquery/blob/2.2.4/src/core.js#L448 |
||
2605 | // this is needed for calling .off with the original non-intercept handler. |
||
2606 | if (!handler.guid) { |
||
2607 | handler.guid = $.guid++; |
||
2608 | } |
||
2609 | intercept.guid = handler.guid; |
||
2610 | |||
2611 | return intercept; |
||
2612 | }, |
||
2613 | |||
2614 | |||
2615 | off: function(types, handler) { |
||
2616 | $(this).off(types, handler); |
||
2617 | |||
2618 | return this; // for chaining |
||
2619 | }, |
||
2620 | |||
2621 | |||
2622 | trigger: function(types) { |
||
2623 | var args = Array.prototype.slice.call(arguments, 1); // arguments after the first |
||
2624 | |||
2625 | // pass in "extra" info to the intercept |
||
2626 | $(this).triggerHandler(types, { args: args }); |
||
2627 | |||
2628 | return this; // for chaining |
||
2629 | }, |
||
2630 | |||
2631 | |||
2632 | triggerWith: function(types, context, args) { |
||
2633 | |||
2634 | // `triggerHandler` is less reliant on the DOM compared to `trigger`. |
||
2635 | // pass in "extra" info to the intercept. |
||
2636 | $(this).triggerHandler(types, { context: context, args: args }); |
||
2637 | |||
2638 | return this; // for chaining |
||
2639 | } |
||
2640 | |||
2641 | }; |
||
2642 | |||
2643 | ;; |
||
2644 | |||
2645 | /* |
||
2646 | Utility methods for easily listening to events on another object, |
||
2647 | and more importantly, easily unlistening from them. |
||
2648 | */ |
||
2649 | var ListenerMixin = FC.ListenerMixin = (function() { |
||
2650 | var guid = 0; |
||
2651 | var ListenerMixin = { |
||
2652 | |||
2653 | listenerId: null, |
||
2654 | |||
2655 | /* |
||
2656 | Given an `other` object that has on/off methods, bind the given `callback` to an event by the given name. |
||
2657 | The `callback` will be called with the `this` context of the object that .listenTo is being called on. |
||
2658 | Can be called: |
||
2659 | .listenTo(other, eventName, callback) |
||
2660 | OR |
||
2661 | .listenTo(other, { |
||
2662 | eventName1: callback1, |
||
2663 | eventName2: callback2 |
||
2664 | }) |
||
2665 | */ |
||
2666 | listenTo: function(other, arg, callback) { |
||
2667 | if (typeof arg === 'object') { // given dictionary of callbacks |
||
2668 | for (var eventName in arg) { |
||
2669 | if (arg.hasOwnProperty(eventName)) { |
||
2670 | this.listenTo(other, eventName, arg[eventName]); |
||
2671 | } |
||
2672 | } |
||
2673 | } |
||
2674 | else if (typeof arg === 'string') { |
||
2675 | other.on( |
||
2676 | arg + '.' + this.getListenerNamespace(), // use event namespacing to identify this object |
||
2677 | $.proxy(callback, this) // always use `this` context |
||
2678 | // the usually-undesired jQuery guid behavior doesn't matter, |
||
2679 | // because we always unbind via namespace |
||
2680 | ); |
||
2681 | } |
||
2682 | }, |
||
2683 | |||
2684 | /* |
||
2685 | Causes the current object to stop listening to events on the `other` object. |
||
2686 | `eventName` is optional. If omitted, will stop listening to ALL events on `other`. |
||
2687 | */ |
||
2688 | stopListeningTo: function(other, eventName) { |
||
2689 | other.off((eventName || '') + '.' + this.getListenerNamespace()); |
||
2690 | }, |
||
2691 | |||
2692 | /* |
||
2693 | Returns a string, unique to this object, to be used for event namespacing |
||
2694 | */ |
||
2695 | getListenerNamespace: function() { |
||
2696 | if (this.listenerId == null) { |
||
2697 | this.listenerId = guid++; |
||
2698 | } |
||
2699 | return '_listener' + this.listenerId; |
||
2700 | } |
||
2701 | |||
2702 | }; |
||
2703 | return ListenerMixin; |
||
2704 | })(); |
||
2705 | ;; |
||
2706 | |||
2707 | /* A rectangular panel that is absolutely positioned over other content |
||
2708 | ------------------------------------------------------------------------------------------------------------------------ |
||
2709 | Options: |
||
2710 | - className (string) |
||
2711 | - content (HTML string or jQuery element set) |
||
2712 | - parentEl |
||
2713 | - top |
||
2714 | - left |
||
2715 | - right (the x coord of where the right edge should be. not a "CSS" right) |
||
2716 | - autoHide (boolean) |
||
2717 | - show (callback) |
||
2718 | - hide (callback) |
||
2719 | */ |
||
2720 | |||
2721 | var Popover = Class.extend(ListenerMixin, { |
||
2722 | |||
2723 | isHidden: true, |
||
2724 | options: null, |
||
2725 | el: null, // the container element for the popover. generated by this object |
||
2726 | margin: 10, // the space required between the popover and the edges of the scroll container |
||
2727 | |||
2728 | |||
2729 | constructor: function(options) { |
||
2730 | this.options = options || {}; |
||
2731 | }, |
||
2732 | |||
2733 | |||
2734 | // Shows the popover on the specified position. Renders it if not already |
||
2735 | show: function() { |
||
2736 | if (this.isHidden) { |
||
2737 | if (!this.el) { |
||
2738 | this.render(); |
||
2739 | } |
||
2740 | this.el.show(); |
||
2741 | this.position(); |
||
2742 | this.isHidden = false; |
||
2743 | this.trigger('show'); |
||
2744 | } |
||
2745 | }, |
||
2746 | |||
2747 | |||
2748 | // Hides the popover, through CSS, but does not remove it from the DOM |
||
2749 | hide: function() { |
||
2750 | if (!this.isHidden) { |
||
2751 | this.el.hide(); |
||
2752 | this.isHidden = true; |
||
2753 | this.trigger('hide'); |
||
2754 | } |
||
2755 | }, |
||
2756 | |||
2757 | |||
2758 | // Creates `this.el` and renders content inside of it |
||
2759 | render: function() { |
||
2760 | var _this = this; |
||
2761 | var options = this.options; |
||
2762 | |||
2763 | this.el = $('<div class="fc-popover"/>') |
||
2764 | .addClass(options.className || '') |
||
2765 | .css({ |
||
2766 | // position initially to the top left to avoid creating scrollbars |
||
2767 | top: 0, |
||
2768 | left: 0 |
||
2769 | }) |
||
2770 | .append(options.content) |
||
2771 | .appendTo(options.parentEl); |
||
2772 | |||
2773 | // when a click happens on anything inside with a 'fc-close' className, hide the popover |
||
2774 | this.el.on('click', '.fc-close', function() { |
||
2775 | _this.hide(); |
||
2776 | }); |
||
2777 | |||
2778 | if (options.autoHide) { |
||
2779 | this.listenTo($(document), 'mousedown', this.documentMousedown); |
||
2780 | } |
||
2781 | }, |
||
2782 | |||
2783 | |||
2784 | // Triggered when the user clicks *anywhere* in the document, for the autoHide feature |
||
2785 | documentMousedown: function(ev) { |
||
2786 | // only hide the popover if the click happened outside the popover |
||
2787 | if (this.el && !$(ev.target).closest(this.el).length) { |
||
2788 | this.hide(); |
||
2789 | } |
||
2790 | }, |
||
2791 | |||
2792 | |||
2793 | // Hides and unregisters any handlers |
||
2794 | removeElement: function() { |
||
2795 | this.hide(); |
||
2796 | |||
2797 | if (this.el) { |
||
2798 | this.el.remove(); |
||
2799 | this.el = null; |
||
2800 | } |
||
2801 | |||
2802 | this.stopListeningTo($(document), 'mousedown'); |
||
2803 | }, |
||
2804 | |||
2805 | |||
2806 | // Positions the popover optimally, using the top/left/right options |
||
2807 | position: function() { |
||
2808 | var options = this.options; |
||
2809 | var origin = this.el.offsetParent().offset(); |
||
2810 | var width = this.el.outerWidth(); |
||
2811 | var height = this.el.outerHeight(); |
||
2812 | var windowEl = $(window); |
||
2813 | var viewportEl = getScrollParent(this.el); |
||
2814 | var viewportTop; |
||
2815 | var viewportLeft; |
||
2816 | var viewportOffset; |
||
2817 | var top; // the "position" (not "offset") values for the popover |
||
2818 | var left; // |
||
2819 | |||
2820 | // compute top and left |
||
2821 | top = options.top || 0; |
||
2822 | if (options.left !== undefined) { |
||
2823 | left = options.left; |
||
2824 | } |
||
2825 | else if (options.right !== undefined) { |
||
2826 | left = options.right - width; // derive the left value from the right value |
||
2827 | } |
||
2828 | else { |
||
2829 | left = 0; |
||
2830 | } |
||
2831 | |||
2832 | if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result |
||
2833 | viewportEl = windowEl; |
||
2834 | viewportTop = 0; // the window is always at the top left |
||
2835 | viewportLeft = 0; // (and .offset() won't work if called here) |
||
2836 | } |
||
2837 | else { |
||
2838 | viewportOffset = viewportEl.offset(); |
||
2839 | viewportTop = viewportOffset.top; |
||
2840 | viewportLeft = viewportOffset.left; |
||
2841 | } |
||
2842 | |||
2843 | // if the window is scrolled, it causes the visible area to be further down |
||
2844 | viewportTop += windowEl.scrollTop(); |
||
2845 | viewportLeft += windowEl.scrollLeft(); |
||
2846 | |||
2847 | // constrain to the view port. if constrained by two edges, give precedence to top/left |
||
2848 | if (options.viewportConstrain !== false) { |
||
2849 | top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin); |
||
2850 | top = Math.max(top, viewportTop + this.margin); |
||
2851 | left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin); |
||
2852 | left = Math.max(left, viewportLeft + this.margin); |
||
2853 | } |
||
2854 | |||
2855 | this.el.css({ |
||
2856 | top: top - origin.top, |
||
2857 | left: left - origin.left |
||
2858 | }); |
||
2859 | }, |
||
2860 | |||
2861 | |||
2862 | // Triggers a callback. Calls a function in the option hash of the same name. |
||
2863 | // Arguments beyond the first `name` are forwarded on. |
||
2864 | // TODO: better code reuse for this. Repeat code |
||
2865 | trigger: function(name) { |
||
2866 | if (this.options[name]) { |
||
2867 | this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); |
||
2868 | } |
||
2869 | } |
||
2870 | |||
2871 | }); |
||
2872 | |||
2873 | ;; |
||
2874 | |||
2875 | /* |
||
2876 | A cache for the left/right/top/bottom/width/height values for one or more elements. |
||
2877 | Works with both offset (from topleft document) and position (from offsetParent). |
||
2878 | |||
2879 | options: |
||
2880 | - els |
||
2881 | - isHorizontal |
||
2882 | - isVertical |
||
2883 | */ |
||
2884 | var CoordCache = FC.CoordCache = Class.extend({ |
||
2885 | |||
2886 | els: null, // jQuery set (assumed to be siblings) |
||
2887 | forcedOffsetParentEl: null, // options can override the natural offsetParent |
||
2888 | origin: null, // {left,top} position of offsetParent of els |
||
2889 | boundingRect: null, // constrain cordinates to this rectangle. {left,right,top,bottom} or null |
||
2890 | isHorizontal: false, // whether to query for left/right/width |
||
2891 | isVertical: false, // whether to query for top/bottom/height |
||
2892 | |||
2893 | // arrays of coordinates (offsets from topleft of document) |
||
2894 | lefts: null, |
||
2895 | rights: null, |
||
2896 | tops: null, |
||
2897 | bottoms: null, |
||
2898 | |||
2899 | |||
2900 | constructor: function(options) { |
||
2901 | this.els = $(options.els); |
||
2902 | this.isHorizontal = options.isHorizontal; |
||
2903 | this.isVertical = options.isVertical; |
||
2904 | this.forcedOffsetParentEl = options.offsetParent ? $(options.offsetParent) : null; |
||
2905 | }, |
||
2906 | |||
2907 | |||
2908 | // Queries the els for coordinates and stores them. |
||
2909 | // Call this method before using and of the get* methods below. |
||
2910 | build: function() { |
||
2911 | var offsetParentEl = this.forcedOffsetParentEl; |
||
2912 | if (!offsetParentEl && this.els.length > 0) { |
||
2913 | offsetParentEl = this.els.eq(0).offsetParent(); |
||
2914 | } |
||
2915 | |||
2916 | this.origin = offsetParentEl ? |
||
2917 | offsetParentEl.offset() : |
||
2918 | null; |
||
2919 | |||
2920 | this.boundingRect = this.queryBoundingRect(); |
||
2921 | |||
2922 | if (this.isHorizontal) { |
||
2923 | this.buildElHorizontals(); |
||
2924 | } |
||
2925 | if (this.isVertical) { |
||
2926 | this.buildElVerticals(); |
||
2927 | } |
||
2928 | }, |
||
2929 | |||
2930 | |||
2931 | // Destroys all internal data about coordinates, freeing memory |
||
2932 | clear: function() { |
||
2933 | this.origin = null; |
||
2934 | this.boundingRect = null; |
||
2935 | this.lefts = null; |
||
2936 | this.rights = null; |
||
2937 | this.tops = null; |
||
2938 | this.bottoms = null; |
||
2939 | }, |
||
2940 | |||
2941 | |||
2942 | // When called, if coord caches aren't built, builds them |
||
2943 | ensureBuilt: function() { |
||
2944 | if (!this.origin) { |
||
2945 | this.build(); |
||
2946 | } |
||
2947 | }, |
||
2948 | |||
2949 | |||
2950 | // Populates the left/right internal coordinate arrays |
||
2951 | buildElHorizontals: function() { |
||
2952 | var lefts = []; |
||
2953 | var rights = []; |
||
2954 | |||
2955 | this.els.each(function(i, node) { |
||
2956 | var el = $(node); |
||
2957 | var left = el.offset().left; |
||
2958 | var width = el.outerWidth(); |
||
2959 | |||
2960 | lefts.push(left); |
||
2961 | rights.push(left + width); |
||
2962 | }); |
||
2963 | |||
2964 | this.lefts = lefts; |
||
2965 | this.rights = rights; |
||
2966 | }, |
||
2967 | |||
2968 | |||
2969 | // Populates the top/bottom internal coordinate arrays |
||
2970 | buildElVerticals: function() { |
||
2971 | var tops = []; |
||
2972 | var bottoms = []; |
||
2973 | |||
2974 | this.els.each(function(i, node) { |
||
2975 | var el = $(node); |
||
2976 | var top = el.offset().top; |
||
2977 | var height = el.outerHeight(); |
||
2978 | |||
2979 | tops.push(top); |
||
2980 | bottoms.push(top + height); |
||
2981 | }); |
||
2982 | |||
2983 | this.tops = tops; |
||
2984 | this.bottoms = bottoms; |
||
2985 | }, |
||
2986 | |||
2987 | |||
2988 | // Given a left offset (from document left), returns the index of the el that it horizontally intersects. |
||
2989 | // If no intersection is made, returns undefined. |
||
2990 | getHorizontalIndex: function(leftOffset) { |
||
2991 | this.ensureBuilt(); |
||
2992 | |||
2993 | var lefts = this.lefts; |
||
2994 | var rights = this.rights; |
||
2995 | var len = lefts.length; |
||
2996 | var i; |
||
2997 | |||
2998 | for (i = 0; i < len; i++) { |
||
2999 | if (leftOffset >= lefts[i] && leftOffset < rights[i]) { |
||
3000 | return i; |
||
3001 | } |
||
3002 | } |
||
3003 | }, |
||
3004 | |||
3005 | |||
3006 | // Given a top offset (from document top), returns the index of the el that it vertically intersects. |
||
3007 | // If no intersection is made, returns undefined. |
||
3008 | getVerticalIndex: function(topOffset) { |
||
3009 | this.ensureBuilt(); |
||
3010 | |||
3011 | var tops = this.tops; |
||
3012 | var bottoms = this.bottoms; |
||
3013 | var len = tops.length; |
||
3014 | var i; |
||
3015 | |||
3016 | for (i = 0; i < len; i++) { |
||
3017 | if (topOffset >= tops[i] && topOffset < bottoms[i]) { |
||
3018 | return i; |
||
3019 | } |
||
3020 | } |
||
3021 | }, |
||
3022 | |||
3023 | |||
3024 | // Gets the left offset (from document left) of the element at the given index |
||
3025 | getLeftOffset: function(leftIndex) { |
||
3026 | this.ensureBuilt(); |
||
3027 | return this.lefts[leftIndex]; |
||
3028 | }, |
||
3029 | |||
3030 | |||
3031 | // Gets the left position (from offsetParent left) of the element at the given index |
||
3032 | getLeftPosition: function(leftIndex) { |
||
3033 | this.ensureBuilt(); |
||
3034 | return this.lefts[leftIndex] - this.origin.left; |
||
3035 | }, |
||
3036 | |||
3037 | |||
3038 | // Gets the right offset (from document left) of the element at the given index. |
||
3039 | // This value is NOT relative to the document's right edge, like the CSS concept of "right" would be. |
||
3040 | getRightOffset: function(leftIndex) { |
||
3041 | this.ensureBuilt(); |
||
3042 | return this.rights[leftIndex]; |
||
3043 | }, |
||
3044 | |||
3045 | |||
3046 | // Gets the right position (from offsetParent left) of the element at the given index. |
||
3047 | // This value is NOT relative to the offsetParent's right edge, like the CSS concept of "right" would be. |
||
3048 | getRightPosition: function(leftIndex) { |
||
3049 | this.ensureBuilt(); |
||
3050 | return this.rights[leftIndex] - this.origin.left; |
||
3051 | }, |
||
3052 | |||
3053 | |||
3054 | // Gets the width of the element at the given index |
||
3055 | getWidth: function(leftIndex) { |
||
3056 | this.ensureBuilt(); |
||
3057 | return this.rights[leftIndex] - this.lefts[leftIndex]; |
||
3058 | }, |
||
3059 | |||
3060 | |||
3061 | // Gets the top offset (from document top) of the element at the given index |
||
3062 | getTopOffset: function(topIndex) { |
||
3063 | this.ensureBuilt(); |
||
3064 | return this.tops[topIndex]; |
||
3065 | }, |
||
3066 | |||
3067 | |||
3068 | // Gets the top position (from offsetParent top) of the element at the given position |
||
3069 | getTopPosition: function(topIndex) { |
||
3070 | this.ensureBuilt(); |
||
3071 | return this.tops[topIndex] - this.origin.top; |
||
3072 | }, |
||
3073 | |||
3074 | // Gets the bottom offset (from the document top) of the element at the given index. |
||
3075 | // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be. |
||
3076 | getBottomOffset: function(topIndex) { |
||
3077 | this.ensureBuilt(); |
||
3078 | return this.bottoms[topIndex]; |
||
3079 | }, |
||
3080 | |||
3081 | |||
3082 | // Gets the bottom position (from the offsetParent top) of the element at the given index. |
||
3083 | // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be. |
||
3084 | getBottomPosition: function(topIndex) { |
||
3085 | this.ensureBuilt(); |
||
3086 | return this.bottoms[topIndex] - this.origin.top; |
||
3087 | }, |
||
3088 | |||
3089 | |||
3090 | // Gets the height of the element at the given index |
||
3091 | getHeight: function(topIndex) { |
||
3092 | this.ensureBuilt(); |
||
3093 | return this.bottoms[topIndex] - this.tops[topIndex]; |
||
3094 | }, |
||
3095 | |||
3096 | |||
3097 | // Bounding Rect |
||
3098 | // TODO: decouple this from CoordCache |
||
3099 | |||
3100 | // Compute and return what the elements' bounding rectangle is, from the user's perspective. |
||
3101 | // Right now, only returns a rectangle if constrained by an overflow:scroll element. |
||
3102 | // Returns null if there are no elements |
||
3103 | queryBoundingRect: function() { |
||
3104 | var scrollParentEl; |
||
3105 | |||
3106 | if (this.els.length > 0) { |
||
3107 | scrollParentEl = getScrollParent(this.els.eq(0)); |
||
3108 | |||
3109 | if (!scrollParentEl.is(document)) { |
||
3110 | return getClientRect(scrollParentEl); |
||
3111 | } |
||
3112 | } |
||
3113 | |||
3114 | return null; |
||
3115 | }, |
||
3116 | |||
3117 | isPointInBounds: function(leftOffset, topOffset) { |
||
3118 | return this.isLeftInBounds(leftOffset) && this.isTopInBounds(topOffset); |
||
3119 | }, |
||
3120 | |||
3121 | isLeftInBounds: function(leftOffset) { |
||
3122 | return !this.boundingRect || (leftOffset >= this.boundingRect.left && leftOffset < this.boundingRect.right); |
||
3123 | }, |
||
3124 | |||
3125 | isTopInBounds: function(topOffset) { |
||
3126 | return !this.boundingRect || (topOffset >= this.boundingRect.top && topOffset < this.boundingRect.bottom); |
||
3127 | } |
||
3128 | |||
3129 | }); |
||
3130 | |||
3131 | ;; |
||
3132 | |||
3133 | /* Tracks a drag's mouse movement, firing various handlers |
||
3134 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
3135 | // TODO: use Emitter |
||
3136 | |||
3137 | var DragListener = FC.DragListener = Class.extend(ListenerMixin, { |
||
3138 | |||
3139 | options: null, |
||
3140 | subjectEl: null, |
||
3141 | |||
3142 | // coordinates of the initial mousedown |
||
3143 | originX: null, |
||
3144 | originY: null, |
||
3145 | |||
3146 | // the wrapping element that scrolls, or MIGHT scroll if there's overflow. |
||
3147 | // TODO: do this for wrappers that have overflow:hidden as well. |
||
3148 | scrollEl: null, |
||
3149 | |||
3150 | isInteracting: false, |
||
3151 | isDistanceSurpassed: false, |
||
3152 | isDelayEnded: false, |
||
3153 | isDragging: false, |
||
3154 | isTouch: false, |
||
3155 | isGeneric: false, // initiated by 'dragstart' (jqui) |
||
3156 | |||
3157 | delay: null, |
||
3158 | delayTimeoutId: null, |
||
3159 | minDistance: null, |
||
3160 | |||
3161 | shouldCancelTouchScroll: true, |
||
3162 | scrollAlwaysKills: false, |
||
3163 | |||
3164 | |||
3165 | constructor: function(options) { |
||
3166 | this.options = options || {}; |
||
3167 | }, |
||
3168 | |||
3169 | |||
3170 | // Interaction (high-level) |
||
3171 | // ----------------------------------------------------------------------------------------------------------------- |
||
3172 | |||
3173 | |||
3174 | startInteraction: function(ev, extraOptions) { |
||
3175 | |||
3176 | if (ev.type === 'mousedown') { |
||
3177 | if (GlobalEmitter.get().shouldIgnoreMouse()) { |
||
3178 | return; |
||
3179 | } |
||
3180 | else if (!isPrimaryMouseButton(ev)) { |
||
3181 | return; |
||
3182 | } |
||
3183 | else { |
||
3184 | ev.preventDefault(); // prevents native selection in most browsers |
||
3185 | } |
||
3186 | } |
||
3187 | |||
3188 | if (!this.isInteracting) { |
||
3189 | |||
3190 | // process options |
||
3191 | extraOptions = extraOptions || {}; |
||
3192 | this.delay = firstDefined(extraOptions.delay, this.options.delay, 0); |
||
3193 | this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0); |
||
3194 | this.subjectEl = this.options.subjectEl; |
||
3195 | |||
3196 | preventSelection($('body')); |
||
3197 | |||
3198 | this.isInteracting = true; |
||
3199 | this.isTouch = getEvIsTouch(ev); |
||
3200 | this.isGeneric = ev.type === 'dragstart'; |
||
3201 | this.isDelayEnded = false; |
||
3202 | this.isDistanceSurpassed = false; |
||
3203 | |||
3204 | this.originX = getEvX(ev); |
||
3205 | this.originY = getEvY(ev); |
||
3206 | this.scrollEl = getScrollParent($(ev.target)); |
||
3207 | |||
3208 | this.bindHandlers(); |
||
3209 | this.initAutoScroll(); |
||
3210 | this.handleInteractionStart(ev); |
||
3211 | this.startDelay(ev); |
||
3212 | |||
3213 | if (!this.minDistance) { |
||
3214 | this.handleDistanceSurpassed(ev); |
||
3215 | } |
||
3216 | } |
||
3217 | }, |
||
3218 | |||
3219 | |||
3220 | handleInteractionStart: function(ev) { |
||
3221 | this.trigger('interactionStart', ev); |
||
3222 | }, |
||
3223 | |||
3224 | |||
3225 | endInteraction: function(ev, isCancelled) { |
||
3226 | if (this.isInteracting) { |
||
3227 | this.endDrag(ev); |
||
3228 | |||
3229 | if (this.delayTimeoutId) { |
||
3230 | clearTimeout(this.delayTimeoutId); |
||
3231 | this.delayTimeoutId = null; |
||
3232 | } |
||
3233 | |||
3234 | this.destroyAutoScroll(); |
||
3235 | this.unbindHandlers(); |
||
3236 | |||
3237 | this.isInteracting = false; |
||
3238 | this.handleInteractionEnd(ev, isCancelled); |
||
3239 | |||
3240 | allowSelection($('body')); |
||
3241 | } |
||
3242 | }, |
||
3243 | |||
3244 | |||
3245 | handleInteractionEnd: function(ev, isCancelled) { |
||
3246 | this.trigger('interactionEnd', ev, isCancelled || false); |
||
3247 | }, |
||
3248 | |||
3249 | |||
3250 | // Binding To DOM |
||
3251 | // ----------------------------------------------------------------------------------------------------------------- |
||
3252 | |||
3253 | |||
3254 | bindHandlers: function() { |
||
3255 | // some browsers (Safari in iOS 10) don't allow preventDefault on touch events that are bound after touchstart, |
||
3256 | // so listen to the GlobalEmitter singleton, which is always bound, instead of the document directly. |
||
3257 | var globalEmitter = GlobalEmitter.get(); |
||
3258 | |||
3259 | if (this.isGeneric) { |
||
3260 | this.listenTo($(document), { // might only work on iOS because of GlobalEmitter's bind :( |
||
3261 | drag: this.handleMove, |
||
3262 | dragstop: this.endInteraction |
||
3263 | }); |
||
3264 | } |
||
3265 | else if (this.isTouch) { |
||
3266 | this.listenTo(globalEmitter, { |
||
3267 | touchmove: this.handleTouchMove, |
||
3268 | touchend: this.endInteraction, |
||
3269 | scroll: this.handleTouchScroll |
||
3270 | }); |
||
3271 | } |
||
3272 | else { |
||
3273 | this.listenTo(globalEmitter, { |
||
3274 | mousemove: this.handleMouseMove, |
||
3275 | mouseup: this.endInteraction |
||
3276 | }); |
||
3277 | } |
||
3278 | |||
3279 | this.listenTo(globalEmitter, { |
||
3280 | selectstart: preventDefault, // don't allow selection while dragging |
||
3281 | contextmenu: preventDefault // long taps would open menu on Chrome dev tools |
||
3282 | }); |
||
3283 | }, |
||
3284 | |||
3285 | |||
3286 | unbindHandlers: function() { |
||
3287 | this.stopListeningTo(GlobalEmitter.get()); |
||
3288 | this.stopListeningTo($(document)); // for isGeneric |
||
3289 | }, |
||
3290 | |||
3291 | |||
3292 | // Drag (high-level) |
||
3293 | // ----------------------------------------------------------------------------------------------------------------- |
||
3294 | |||
3295 | |||
3296 | // extraOptions ignored if drag already started |
||
3297 | startDrag: function(ev, extraOptions) { |
||
3298 | this.startInteraction(ev, extraOptions); // ensure interaction began |
||
3299 | |||
3300 | if (!this.isDragging) { |
||
3301 | this.isDragging = true; |
||
3302 | this.handleDragStart(ev); |
||
3303 | } |
||
3304 | }, |
||
3305 | |||
3306 | |||
3307 | handleDragStart: function(ev) { |
||
3308 | this.trigger('dragStart', ev); |
||
3309 | }, |
||
3310 | |||
3311 | |||
3312 | handleMove: function(ev) { |
||
3313 | var dx = getEvX(ev) - this.originX; |
||
3314 | var dy = getEvY(ev) - this.originY; |
||
3315 | var minDistance = this.minDistance; |
||
3316 | var distanceSq; // current distance from the origin, squared |
||
3317 | |||
3318 | if (!this.isDistanceSurpassed) { |
||
3319 | distanceSq = dx * dx + dy * dy; |
||
3320 | if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem |
||
3321 | this.handleDistanceSurpassed(ev); |
||
3322 | } |
||
3323 | } |
||
3324 | |||
3325 | if (this.isDragging) { |
||
3326 | this.handleDrag(dx, dy, ev); |
||
3327 | } |
||
3328 | }, |
||
3329 | |||
3330 | |||
3331 | // Called while the mouse is being moved and when we know a legitimate drag is taking place |
||
3332 | handleDrag: function(dx, dy, ev) { |
||
3333 | this.trigger('drag', dx, dy, ev); |
||
3334 | this.updateAutoScroll(ev); // will possibly cause scrolling |
||
3335 | }, |
||
3336 | |||
3337 | |||
3338 | endDrag: function(ev) { |
||
3339 | if (this.isDragging) { |
||
3340 | this.isDragging = false; |
||
3341 | this.handleDragEnd(ev); |
||
3342 | } |
||
3343 | }, |
||
3344 | |||
3345 | |||
3346 | handleDragEnd: function(ev) { |
||
3347 | this.trigger('dragEnd', ev); |
||
3348 | }, |
||
3349 | |||
3350 | |||
3351 | // Delay |
||
3352 | // ----------------------------------------------------------------------------------------------------------------- |
||
3353 | |||
3354 | |||
3355 | startDelay: function(initialEv) { |
||
3356 | var _this = this; |
||
3357 | |||
3358 | if (this.delay) { |
||
3359 | this.delayTimeoutId = setTimeout(function() { |
||
3360 | _this.handleDelayEnd(initialEv); |
||
3361 | }, this.delay); |
||
3362 | } |
||
3363 | else { |
||
3364 | this.handleDelayEnd(initialEv); |
||
3365 | } |
||
3366 | }, |
||
3367 | |||
3368 | |||
3369 | handleDelayEnd: function(initialEv) { |
||
3370 | this.isDelayEnded = true; |
||
3371 | |||
3372 | if (this.isDistanceSurpassed) { |
||
3373 | this.startDrag(initialEv); |
||
3374 | } |
||
3375 | }, |
||
3376 | |||
3377 | |||
3378 | // Distance |
||
3379 | // ----------------------------------------------------------------------------------------------------------------- |
||
3380 | |||
3381 | |||
3382 | handleDistanceSurpassed: function(ev) { |
||
3383 | this.isDistanceSurpassed = true; |
||
3384 | |||
3385 | if (this.isDelayEnded) { |
||
3386 | this.startDrag(ev); |
||
3387 | } |
||
3388 | }, |
||
3389 | |||
3390 | |||
3391 | // Mouse / Touch |
||
3392 | // ----------------------------------------------------------------------------------------------------------------- |
||
3393 | |||
3394 | |||
3395 | handleTouchMove: function(ev) { |
||
3396 | |||
3397 | // prevent inertia and touchmove-scrolling while dragging |
||
3398 | if (this.isDragging && this.shouldCancelTouchScroll) { |
||
3399 | ev.preventDefault(); |
||
3400 | } |
||
3401 | |||
3402 | this.handleMove(ev); |
||
3403 | }, |
||
3404 | |||
3405 | |||
3406 | handleMouseMove: function(ev) { |
||
3407 | this.handleMove(ev); |
||
3408 | }, |
||
3409 | |||
3410 | |||
3411 | // Scrolling (unrelated to auto-scroll) |
||
3412 | // ----------------------------------------------------------------------------------------------------------------- |
||
3413 | |||
3414 | |||
3415 | handleTouchScroll: function(ev) { |
||
3416 | // if the drag is being initiated by touch, but a scroll happens before |
||
3417 | // the drag-initiating delay is over, cancel the drag |
||
3418 | if (!this.isDragging || this.scrollAlwaysKills) { |
||
3419 | this.endInteraction(ev, true); // isCancelled=true |
||
3420 | } |
||
3421 | }, |
||
3422 | |||
3423 | |||
3424 | // Utils |
||
3425 | // ----------------------------------------------------------------------------------------------------------------- |
||
3426 | |||
3427 | |||
3428 | // Triggers a callback. Calls a function in the option hash of the same name. |
||
3429 | // Arguments beyond the first `name` are forwarded on. |
||
3430 | trigger: function(name) { |
||
3431 | if (this.options[name]) { |
||
3432 | this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); |
||
3433 | } |
||
3434 | // makes _methods callable by event name. TODO: kill this |
||
3435 | if (this['_' + name]) { |
||
3436 | this['_' + name].apply(this, Array.prototype.slice.call(arguments, 1)); |
||
3437 | } |
||
3438 | } |
||
3439 | |||
3440 | |||
3441 | }); |
||
3442 | |||
3443 | ;; |
||
3444 | /* |
||
3445 | this.scrollEl is set in DragListener |
||
3446 | */ |
||
3447 | DragListener.mixin({ |
||
3448 | |||
3449 | isAutoScroll: false, |
||
3450 | |||
3451 | scrollBounds: null, // { top, bottom, left, right } |
||
3452 | scrollTopVel: null, // pixels per second |
||
3453 | scrollLeftVel: null, // pixels per second |
||
3454 | scrollIntervalId: null, // ID of setTimeout for scrolling animation loop |
||
3455 | |||
3456 | // defaults |
||
3457 | scrollSensitivity: 30, // pixels from edge for scrolling to start |
||
3458 | scrollSpeed: 200, // pixels per second, at maximum speed |
||
3459 | scrollIntervalMs: 50, // millisecond wait between scroll increment |
||
3460 | |||
3461 | |||
3462 | initAutoScroll: function() { |
||
3463 | var scrollEl = this.scrollEl; |
||
3464 | |||
3465 | this.isAutoScroll = |
||
3466 | this.options.scroll && |
||
3467 | scrollEl && |
||
3468 | !scrollEl.is(window) && |
||
3469 | !scrollEl.is(document); |
||
3470 | |||
3471 | if (this.isAutoScroll) { |
||
3472 | // debounce makes sure rapid calls don't happen |
||
3473 | this.listenTo(scrollEl, 'scroll', debounce(this.handleDebouncedScroll, 100)); |
||
3474 | } |
||
3475 | }, |
||
3476 | |||
3477 | |||
3478 | destroyAutoScroll: function() { |
||
3479 | this.endAutoScroll(); // kill any animation loop |
||
3480 | |||
3481 | // remove the scroll handler if there is a scrollEl |
||
3482 | if (this.isAutoScroll) { |
||
3483 | this.stopListeningTo(this.scrollEl, 'scroll'); // will probably get removed by unbindHandlers too :( |
||
3484 | } |
||
3485 | }, |
||
3486 | |||
3487 | |||
3488 | // Computes and stores the bounding rectangle of scrollEl |
||
3489 | computeScrollBounds: function() { |
||
3490 | if (this.isAutoScroll) { |
||
3491 | this.scrollBounds = getOuterRect(this.scrollEl); |
||
3492 | // TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars |
||
3493 | } |
||
3494 | }, |
||
3495 | |||
3496 | |||
3497 | // Called when the dragging is in progress and scrolling should be updated |
||
3498 | updateAutoScroll: function(ev) { |
||
3499 | var sensitivity = this.scrollSensitivity; |
||
3500 | var bounds = this.scrollBounds; |
||
3501 | var topCloseness, bottomCloseness; |
||
3502 | var leftCloseness, rightCloseness; |
||
3503 | var topVel = 0; |
||
3504 | var leftVel = 0; |
||
3505 | |||
3506 | if (bounds) { // only scroll if scrollEl exists |
||
3507 | |||
3508 | // compute closeness to edges. valid range is from 0.0 - 1.0 |
||
3509 | topCloseness = (sensitivity - (getEvY(ev) - bounds.top)) / sensitivity; |
||
3510 | bottomCloseness = (sensitivity - (bounds.bottom - getEvY(ev))) / sensitivity; |
||
3511 | leftCloseness = (sensitivity - (getEvX(ev) - bounds.left)) / sensitivity; |
||
3512 | rightCloseness = (sensitivity - (bounds.right - getEvX(ev))) / sensitivity; |
||
3513 | |||
3514 | // translate vertical closeness into velocity. |
||
3515 | // mouse must be completely in bounds for velocity to happen. |
||
3516 | if (topCloseness >= 0 && topCloseness <= 1) { |
||
3517 | topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up |
||
3518 | } |
||
3519 | else if (bottomCloseness >= 0 && bottomCloseness <= 1) { |
||
3520 | topVel = bottomCloseness * this.scrollSpeed; |
||
3521 | } |
||
3522 | |||
3523 | // translate horizontal closeness into velocity |
||
3524 | if (leftCloseness >= 0 && leftCloseness <= 1) { |
||
3525 | leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left |
||
3526 | } |
||
3527 | else if (rightCloseness >= 0 && rightCloseness <= 1) { |
||
3528 | leftVel = rightCloseness * this.scrollSpeed; |
||
3529 | } |
||
3530 | } |
||
3531 | |||
3532 | this.setScrollVel(topVel, leftVel); |
||
3533 | }, |
||
3534 | |||
3535 | |||
3536 | // Sets the speed-of-scrolling for the scrollEl |
||
3537 | setScrollVel: function(topVel, leftVel) { |
||
3538 | |||
3539 | this.scrollTopVel = topVel; |
||
3540 | this.scrollLeftVel = leftVel; |
||
3541 | |||
3542 | this.constrainScrollVel(); // massages into realistic values |
||
3543 | |||
3544 | // if there is non-zero velocity, and an animation loop hasn't already started, then START |
||
3545 | if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) { |
||
3546 | this.scrollIntervalId = setInterval( |
||
3547 | proxy(this, 'scrollIntervalFunc'), // scope to `this` |
||
3548 | this.scrollIntervalMs |
||
3549 | ); |
||
3550 | } |
||
3551 | }, |
||
3552 | |||
3553 | |||
3554 | // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way |
||
3555 | constrainScrollVel: function() { |
||
3556 | var el = this.scrollEl; |
||
3557 | |||
3558 | if (this.scrollTopVel < 0) { // scrolling up? |
||
3559 | if (el.scrollTop() <= 0) { // already scrolled all the way up? |
||
3560 | this.scrollTopVel = 0; |
||
3561 | } |
||
3562 | } |
||
3563 | else if (this.scrollTopVel > 0) { // scrolling down? |
||
3564 | if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down? |
||
3565 | this.scrollTopVel = 0; |
||
3566 | } |
||
3567 | } |
||
3568 | |||
3569 | if (this.scrollLeftVel < 0) { // scrolling left? |
||
3570 | if (el.scrollLeft() <= 0) { // already scrolled all the left? |
||
3571 | this.scrollLeftVel = 0; |
||
3572 | } |
||
3573 | } |
||
3574 | else if (this.scrollLeftVel > 0) { // scrolling right? |
||
3575 | if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right? |
||
3576 | this.scrollLeftVel = 0; |
||
3577 | } |
||
3578 | } |
||
3579 | }, |
||
3580 | |||
3581 | |||
3582 | // This function gets called during every iteration of the scrolling animation loop |
||
3583 | scrollIntervalFunc: function() { |
||
3584 | var el = this.scrollEl; |
||
3585 | var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by |
||
3586 | |||
3587 | // change the value of scrollEl's scroll |
||
3588 | if (this.scrollTopVel) { |
||
3589 | el.scrollTop(el.scrollTop() + this.scrollTopVel * frac); |
||
3590 | } |
||
3591 | if (this.scrollLeftVel) { |
||
3592 | el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac); |
||
3593 | } |
||
3594 | |||
3595 | this.constrainScrollVel(); // since the scroll values changed, recompute the velocities |
||
3596 | |||
3597 | // if scrolled all the way, which causes the vels to be zero, stop the animation loop |
||
3598 | if (!this.scrollTopVel && !this.scrollLeftVel) { |
||
3599 | this.endAutoScroll(); |
||
3600 | } |
||
3601 | }, |
||
3602 | |||
3603 | |||
3604 | // Kills any existing scrolling animation loop |
||
3605 | endAutoScroll: function() { |
||
3606 | if (this.scrollIntervalId) { |
||
3607 | clearInterval(this.scrollIntervalId); |
||
3608 | this.scrollIntervalId = null; |
||
3609 | |||
3610 | this.handleScrollEnd(); |
||
3611 | } |
||
3612 | }, |
||
3613 | |||
3614 | |||
3615 | // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce) |
||
3616 | handleDebouncedScroll: function() { |
||
3617 | // recompute all coordinates, but *only* if this is *not* part of our scrolling animation |
||
3618 | if (!this.scrollIntervalId) { |
||
3619 | this.handleScrollEnd(); |
||
3620 | } |
||
3621 | }, |
||
3622 | |||
3623 | |||
3624 | // Called when scrolling has stopped, whether through auto scroll, or the user scrolling |
||
3625 | handleScrollEnd: function() { |
||
3626 | } |
||
3627 | |||
3628 | }); |
||
3629 | ;; |
||
3630 | |||
3631 | /* Tracks mouse movements over a component and raises events about which hit the mouse is over. |
||
3632 | ------------------------------------------------------------------------------------------------------------------------ |
||
3633 | options: |
||
3634 | - subjectEl |
||
3635 | - subjectCenter |
||
3636 | */ |
||
3637 | |||
3638 | var HitDragListener = DragListener.extend({ |
||
3639 | |||
3640 | component: null, // converts coordinates to hits |
||
3641 | // methods: hitsNeeded, hitsNotNeeded, queryHit |
||
3642 | |||
3643 | origHit: null, // the hit the mouse was over when listening started |
||
3644 | hit: null, // the hit the mouse is over |
||
3645 | coordAdjust: null, // delta that will be added to the mouse coordinates when computing collisions |
||
3646 | |||
3647 | |||
3648 | constructor: function(component, options) { |
||
3649 | DragListener.call(this, options); // call the super-constructor |
||
3650 | |||
3651 | this.component = component; |
||
3652 | }, |
||
3653 | |||
3654 | |||
3655 | // Called when drag listening starts (but a real drag has not necessarily began). |
||
3656 | // ev might be undefined if dragging was started manually. |
||
3657 | handleInteractionStart: function(ev) { |
||
3658 | var subjectEl = this.subjectEl; |
||
3659 | var subjectRect; |
||
3660 | var origPoint; |
||
3661 | var point; |
||
3662 | |||
3663 | this.component.hitsNeeded(); |
||
3664 | this.computeScrollBounds(); // for autoscroll |
||
3665 | |||
3666 | if (ev) { |
||
3667 | origPoint = { left: getEvX(ev), top: getEvY(ev) }; |
||
3668 | point = origPoint; |
||
3669 | |||
3670 | // constrain the point to bounds of the element being dragged |
||
3671 | if (subjectEl) { |
||
3672 | subjectRect = getOuterRect(subjectEl); // used for centering as well |
||
3673 | point = constrainPoint(point, subjectRect); |
||
3674 | } |
||
3675 | |||
3676 | this.origHit = this.queryHit(point.left, point.top); |
||
3677 | |||
3678 | // treat the center of the subject as the collision point? |
||
3679 | if (subjectEl && this.options.subjectCenter) { |
||
3680 | |||
3681 | // only consider the area the subject overlaps the hit. best for large subjects. |
||
3682 | // TODO: skip this if hit didn't supply left/right/top/bottom |
||
3683 | if (this.origHit) { |
||
3684 | subjectRect = intersectRects(this.origHit, subjectRect) || |
||
3685 | subjectRect; // in case there is no intersection |
||
3686 | } |
||
3687 | |||
3688 | point = getRectCenter(subjectRect); |
||
3689 | } |
||
3690 | |||
3691 | this.coordAdjust = diffPoints(point, origPoint); // point - origPoint |
||
3692 | } |
||
3693 | else { |
||
3694 | this.origHit = null; |
||
3695 | this.coordAdjust = null; |
||
3696 | } |
||
3697 | |||
3698 | // call the super-method. do it after origHit has been computed |
||
3699 | DragListener.prototype.handleInteractionStart.apply(this, arguments); |
||
3700 | }, |
||
3701 | |||
3702 | |||
3703 | // Called when the actual drag has started |
||
3704 | handleDragStart: function(ev) { |
||
3705 | var hit; |
||
3706 | |||
3707 | DragListener.prototype.handleDragStart.apply(this, arguments); // call the super-method |
||
3708 | |||
3709 | // might be different from this.origHit if the min-distance is large |
||
3710 | hit = this.queryHit(getEvX(ev), getEvY(ev)); |
||
3711 | |||
3712 | // report the initial hit the mouse is over |
||
3713 | // especially important if no min-distance and drag starts immediately |
||
3714 | if (hit) { |
||
3715 | this.handleHitOver(hit); |
||
3716 | } |
||
3717 | }, |
||
3718 | |||
3719 | |||
3720 | // Called when the drag moves |
||
3721 | handleDrag: function(dx, dy, ev) { |
||
3722 | var hit; |
||
3723 | |||
3724 | DragListener.prototype.handleDrag.apply(this, arguments); // call the super-method |
||
3725 | |||
3726 | hit = this.queryHit(getEvX(ev), getEvY(ev)); |
||
3727 | |||
3728 | if (!isHitsEqual(hit, this.hit)) { // a different hit than before? |
||
3729 | if (this.hit) { |
||
3730 | this.handleHitOut(); |
||
3731 | } |
||
3732 | if (hit) { |
||
3733 | this.handleHitOver(hit); |
||
3734 | } |
||
3735 | } |
||
3736 | }, |
||
3737 | |||
3738 | |||
3739 | // Called when dragging has been stopped |
||
3740 | handleDragEnd: function() { |
||
3741 | this.handleHitDone(); |
||
3742 | DragListener.prototype.handleDragEnd.apply(this, arguments); // call the super-method |
||
3743 | }, |
||
3744 | |||
3745 | |||
3746 | // Called when a the mouse has just moved over a new hit |
||
3747 | handleHitOver: function(hit) { |
||
3748 | var isOrig = isHitsEqual(hit, this.origHit); |
||
3749 | |||
3750 | this.hit = hit; |
||
3751 | |||
3752 | this.trigger('hitOver', this.hit, isOrig, this.origHit); |
||
3753 | }, |
||
3754 | |||
3755 | |||
3756 | // Called when the mouse has just moved out of a hit |
||
3757 | handleHitOut: function() { |
||
3758 | if (this.hit) { |
||
3759 | this.trigger('hitOut', this.hit); |
||
3760 | this.handleHitDone(); |
||
3761 | this.hit = null; |
||
3762 | } |
||
3763 | }, |
||
3764 | |||
3765 | |||
3766 | // Called after a hitOut. Also called before a dragStop |
||
3767 | handleHitDone: function() { |
||
3768 | if (this.hit) { |
||
3769 | this.trigger('hitDone', this.hit); |
||
3770 | } |
||
3771 | }, |
||
3772 | |||
3773 | |||
3774 | // Called when the interaction ends, whether there was a real drag or not |
||
3775 | handleInteractionEnd: function() { |
||
3776 | DragListener.prototype.handleInteractionEnd.apply(this, arguments); // call the super-method |
||
3777 | |||
3778 | this.origHit = null; |
||
3779 | this.hit = null; |
||
3780 | |||
3781 | this.component.hitsNotNeeded(); |
||
3782 | }, |
||
3783 | |||
3784 | |||
3785 | // Called when scrolling has stopped, whether through auto scroll, or the user scrolling |
||
3786 | handleScrollEnd: function() { |
||
3787 | DragListener.prototype.handleScrollEnd.apply(this, arguments); // call the super-method |
||
3788 | |||
3789 | // hits' absolute positions will be in new places after a user's scroll. |
||
3790 | // HACK for recomputing. |
||
3791 | if (this.isDragging) { |
||
3792 | this.component.releaseHits(); |
||
3793 | this.component.prepareHits(); |
||
3794 | } |
||
3795 | }, |
||
3796 | |||
3797 | |||
3798 | // Gets the hit underneath the coordinates for the given mouse event |
||
3799 | queryHit: function(left, top) { |
||
3800 | |||
3801 | if (this.coordAdjust) { |
||
3802 | left += this.coordAdjust.left; |
||
3803 | top += this.coordAdjust.top; |
||
3804 | } |
||
3805 | |||
3806 | return this.component.queryHit(left, top); |
||
3807 | } |
||
3808 | |||
3809 | }); |
||
3810 | |||
3811 | |||
3812 | // Returns `true` if the hits are identically equal. `false` otherwise. Must be from the same component. |
||
3813 | // Two null values will be considered equal, as two "out of the component" states are the same. |
||
3814 | function isHitsEqual(hit0, hit1) { |
||
3815 | |||
3816 | if (!hit0 && !hit1) { |
||
3817 | return true; |
||
3818 | } |
||
3819 | |||
3820 | if (hit0 && hit1) { |
||
3821 | return hit0.component === hit1.component && |
||
3822 | isHitPropsWithin(hit0, hit1) && |
||
3823 | isHitPropsWithin(hit1, hit0); // ensures all props are identical |
||
3824 | } |
||
3825 | |||
3826 | return false; |
||
3827 | } |
||
3828 | |||
3829 | |||
3830 | // Returns true if all of subHit's non-standard properties are within superHit |
||
3831 | function isHitPropsWithin(subHit, superHit) { |
||
3832 | for (var propName in subHit) { |
||
3833 | if (!/^(component|left|right|top|bottom)$/.test(propName)) { |
||
3834 | if (subHit[propName] !== superHit[propName]) { |
||
3835 | return false; |
||
3836 | } |
||
3837 | } |
||
3838 | } |
||
3839 | return true; |
||
3840 | } |
||
3841 | |||
3842 | ;; |
||
3843 | |||
3844 | /* |
||
3845 | Listens to document and window-level user-interaction events, like touch events and mouse events, |
||
3846 | and fires these events as-is to whoever is observing a GlobalEmitter. |
||
3847 | Best when used as a singleton via GlobalEmitter.get() |
||
3848 | |||
3849 | Normalizes mouse/touch events. For examples: |
||
3850 | - ignores the the simulated mouse events that happen after a quick tap: mousemove+mousedown+mouseup+click |
||
3851 | - compensates for various buggy scenarios where a touchend does not fire |
||
3852 | */ |
||
3853 | |||
3854 | FC.touchMouseIgnoreWait = 500; |
||
3855 | |||
3856 | var GlobalEmitter = Class.extend(ListenerMixin, EmitterMixin, { |
||
3857 | |||
3858 | isTouching: false, |
||
3859 | mouseIgnoreDepth: 0, |
||
3860 | handleScrollProxy: null, |
||
3861 | |||
3862 | |||
3863 | bind: function() { |
||
3864 | var _this = this; |
||
3865 | |||
3866 | this.listenTo($(document), { |
||
3867 | touchstart: this.handleTouchStart, |
||
3868 | touchcancel: this.handleTouchCancel, |
||
3869 | touchend: this.handleTouchEnd, |
||
3870 | mousedown: this.handleMouseDown, |
||
3871 | mousemove: this.handleMouseMove, |
||
3872 | mouseup: this.handleMouseUp, |
||
3873 | click: this.handleClick, |
||
3874 | selectstart: this.handleSelectStart, |
||
3875 | contextmenu: this.handleContextMenu |
||
3876 | }); |
||
3877 | |||
3878 | // because we need to call preventDefault |
||
3879 | // because https://www.chromestatus.com/features/5093566007214080 |
||
3880 | // TODO: investigate performance because this is a global handler |
||
3881 | window.addEventListener( |
||
3882 | 'touchmove', |
||
3883 | this.handleTouchMoveProxy = function(ev) { |
||
3884 | _this.handleTouchMove($.Event(ev)); |
||
3885 | }, |
||
3886 | { passive: false } // allows preventDefault() |
||
3887 | ); |
||
3888 | |||
3889 | // attach a handler to get called when ANY scroll action happens on the page. |
||
3890 | // this was impossible to do with normal on/off because 'scroll' doesn't bubble. |
||
3891 | // http://stackoverflow.com/a/32954565/96342 |
||
3892 | window.addEventListener( |
||
3893 | 'scroll', |
||
3894 | this.handleScrollProxy = function(ev) { |
||
3895 | _this.handleScroll($.Event(ev)); |
||
3896 | }, |
||
3897 | true // useCapture |
||
3898 | ); |
||
3899 | }, |
||
3900 | |||
3901 | unbind: function() { |
||
3902 | this.stopListeningTo($(document)); |
||
3903 | |||
3904 | window.removeEventListener( |
||
3905 | 'touchmove', |
||
3906 | this.handleTouchMoveProxy |
||
3907 | ); |
||
3908 | |||
3909 | window.removeEventListener( |
||
3910 | 'scroll', |
||
3911 | this.handleScrollProxy, |
||
3912 | true // useCapture |
||
3913 | ); |
||
3914 | }, |
||
3915 | |||
3916 | |||
3917 | // Touch Handlers |
||
3918 | // ----------------------------------------------------------------------------------------------------------------- |
||
3919 | |||
3920 | handleTouchStart: function(ev) { |
||
3921 | |||
3922 | // if a previous touch interaction never ended with a touchend, then implicitly end it, |
||
3923 | // but since a new touch interaction is about to begin, don't start the mouse ignore period. |
||
3924 | this.stopTouch(ev, true); // skipMouseIgnore=true |
||
3925 | |||
3926 | this.isTouching = true; |
||
3927 | this.trigger('touchstart', ev); |
||
3928 | }, |
||
3929 | |||
3930 | handleTouchMove: function(ev) { |
||
3931 | if (this.isTouching) { |
||
3932 | this.trigger('touchmove', ev); |
||
3933 | } |
||
3934 | }, |
||
3935 | |||
3936 | handleTouchCancel: function(ev) { |
||
3937 | if (this.isTouching) { |
||
3938 | this.trigger('touchcancel', ev); |
||
3939 | |||
3940 | // Have touchcancel fire an artificial touchend. That way, handlers won't need to listen to both. |
||
3941 | // If touchend fires later, it won't have any effect b/c isTouching will be false. |
||
3942 | this.stopTouch(ev); |
||
3943 | } |
||
3944 | }, |
||
3945 | |||
3946 | handleTouchEnd: function(ev) { |
||
3947 | this.stopTouch(ev); |
||
3948 | }, |
||
3949 | |||
3950 | |||
3951 | // Mouse Handlers |
||
3952 | // ----------------------------------------------------------------------------------------------------------------- |
||
3953 | |||
3954 | handleMouseDown: function(ev) { |
||
3955 | if (!this.shouldIgnoreMouse()) { |
||
3956 | this.trigger('mousedown', ev); |
||
3957 | } |
||
3958 | }, |
||
3959 | |||
3960 | handleMouseMove: function(ev) { |
||
3961 | if (!this.shouldIgnoreMouse()) { |
||
3962 | this.trigger('mousemove', ev); |
||
3963 | } |
||
3964 | }, |
||
3965 | |||
3966 | handleMouseUp: function(ev) { |
||
3967 | if (!this.shouldIgnoreMouse()) { |
||
3968 | this.trigger('mouseup', ev); |
||
3969 | } |
||
3970 | }, |
||
3971 | |||
3972 | handleClick: function(ev) { |
||
3973 | if (!this.shouldIgnoreMouse()) { |
||
3974 | this.trigger('click', ev); |
||
3975 | } |
||
3976 | }, |
||
3977 | |||
3978 | |||
3979 | // Misc Handlers |
||
3980 | // ----------------------------------------------------------------------------------------------------------------- |
||
3981 | |||
3982 | handleSelectStart: function(ev) { |
||
3983 | this.trigger('selectstart', ev); |
||
3984 | }, |
||
3985 | |||
3986 | handleContextMenu: function(ev) { |
||
3987 | this.trigger('contextmenu', ev); |
||
3988 | }, |
||
3989 | |||
3990 | handleScroll: function(ev) { |
||
3991 | this.trigger('scroll', ev); |
||
3992 | }, |
||
3993 | |||
3994 | |||
3995 | // Utils |
||
3996 | // ----------------------------------------------------------------------------------------------------------------- |
||
3997 | |||
3998 | stopTouch: function(ev, skipMouseIgnore) { |
||
3999 | if (this.isTouching) { |
||
4000 | this.isTouching = false; |
||
4001 | this.trigger('touchend', ev); |
||
4002 | |||
4003 | if (!skipMouseIgnore) { |
||
4004 | this.startTouchMouseIgnore(); |
||
4005 | } |
||
4006 | } |
||
4007 | }, |
||
4008 | |||
4009 | startTouchMouseIgnore: function() { |
||
4010 | var _this = this; |
||
4011 | var wait = FC.touchMouseIgnoreWait; |
||
4012 | |||
4013 | if (wait) { |
||
4014 | this.mouseIgnoreDepth++; |
||
4015 | setTimeout(function() { |
||
4016 | _this.mouseIgnoreDepth--; |
||
4017 | }, wait); |
||
4018 | } |
||
4019 | }, |
||
4020 | |||
4021 | shouldIgnoreMouse: function() { |
||
4022 | return this.isTouching || Boolean(this.mouseIgnoreDepth); |
||
4023 | } |
||
4024 | |||
4025 | }); |
||
4026 | |||
4027 | |||
4028 | // Singleton |
||
4029 | // --------------------------------------------------------------------------------------------------------------------- |
||
4030 | |||
4031 | (function() { |
||
4032 | var globalEmitter = null; |
||
4033 | var neededCount = 0; |
||
4034 | |||
4035 | |||
4036 | // gets the singleton |
||
4037 | GlobalEmitter.get = function() { |
||
4038 | |||
4039 | if (!globalEmitter) { |
||
4040 | globalEmitter = new GlobalEmitter(); |
||
4041 | globalEmitter.bind(); |
||
4042 | } |
||
4043 | |||
4044 | return globalEmitter; |
||
4045 | }; |
||
4046 | |||
4047 | |||
4048 | // called when an object knows it will need a GlobalEmitter in the near future. |
||
4049 | GlobalEmitter.needed = function() { |
||
4050 | GlobalEmitter.get(); // ensures globalEmitter |
||
4051 | neededCount++; |
||
4052 | }; |
||
4053 | |||
4054 | |||
4055 | // called when the object that originally called needed() doesn't need a GlobalEmitter anymore. |
||
4056 | GlobalEmitter.unneeded = function() { |
||
4057 | neededCount--; |
||
4058 | |||
4059 | if (!neededCount) { // nobody else needs it |
||
4060 | globalEmitter.unbind(); |
||
4061 | globalEmitter = null; |
||
4062 | } |
||
4063 | }; |
||
4064 | |||
4065 | })(); |
||
4066 | |||
4067 | ;; |
||
4068 | |||
4069 | /* Creates a clone of an element and lets it track the mouse as it moves |
||
4070 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
4071 | |||
4072 | var MouseFollower = Class.extend(ListenerMixin, { |
||
4073 | |||
4074 | options: null, |
||
4075 | |||
4076 | sourceEl: null, // the element that will be cloned and made to look like it is dragging |
||
4077 | el: null, // the clone of `sourceEl` that will track the mouse |
||
4078 | parentEl: null, // the element that `el` (the clone) will be attached to |
||
4079 | |||
4080 | // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl |
||
4081 | top0: null, |
||
4082 | left0: null, |
||
4083 | |||
4084 | // the absolute coordinates of the initiating touch/mouse action |
||
4085 | y0: null, |
||
4086 | x0: null, |
||
4087 | |||
4088 | // the number of pixels the mouse has moved from its initial position |
||
4089 | topDelta: null, |
||
4090 | leftDelta: null, |
||
4091 | |||
4092 | isFollowing: false, |
||
4093 | isHidden: false, |
||
4094 | isAnimating: false, // doing the revert animation? |
||
4095 | |||
4096 | constructor: function(sourceEl, options) { |
||
4097 | this.options = options = options || {}; |
||
4098 | this.sourceEl = sourceEl; |
||
4099 | this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent |
||
4100 | }, |
||
4101 | |||
4102 | |||
4103 | // Causes the element to start following the mouse |
||
4104 | start: function(ev) { |
||
4105 | if (!this.isFollowing) { |
||
4106 | this.isFollowing = true; |
||
4107 | |||
4108 | this.y0 = getEvY(ev); |
||
4109 | this.x0 = getEvX(ev); |
||
4110 | this.topDelta = 0; |
||
4111 | this.leftDelta = 0; |
||
4112 | |||
4113 | if (!this.isHidden) { |
||
4114 | this.updatePosition(); |
||
4115 | } |
||
4116 | |||
4117 | if (getEvIsTouch(ev)) { |
||
4118 | this.listenTo($(document), 'touchmove', this.handleMove); |
||
4119 | } |
||
4120 | else { |
||
4121 | this.listenTo($(document), 'mousemove', this.handleMove); |
||
4122 | } |
||
4123 | } |
||
4124 | }, |
||
4125 | |||
4126 | |||
4127 | // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position. |
||
4128 | // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately. |
||
4129 | stop: function(shouldRevert, callback) { |
||
4130 | var _this = this; |
||
4131 | var revertDuration = this.options.revertDuration; |
||
4132 | |||
4133 | function complete() { // might be called by .animate(), which might change `this` context |
||
4134 | _this.isAnimating = false; |
||
4135 | _this.removeElement(); |
||
4136 | |||
4137 | _this.top0 = _this.left0 = null; // reset state for future updatePosition calls |
||
4138 | |||
4139 | if (callback) { |
||
4140 | callback(); |
||
4141 | } |
||
4142 | } |
||
4143 | |||
4144 | if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time |
||
4145 | this.isFollowing = false; |
||
4146 | |||
4147 | this.stopListeningTo($(document)); |
||
4148 | |||
4149 | if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation? |
||
4150 | this.isAnimating = true; |
||
4151 | this.el.animate({ |
||
4152 | top: this.top0, |
||
4153 | left: this.left0 |
||
4154 | }, { |
||
4155 | duration: revertDuration, |
||
4156 | complete: complete |
||
4157 | }); |
||
4158 | } |
||
4159 | else { |
||
4160 | complete(); |
||
4161 | } |
||
4162 | } |
||
4163 | }, |
||
4164 | |||
4165 | |||
4166 | // Gets the tracking element. Create it if necessary |
||
4167 | getEl: function() { |
||
4168 | var el = this.el; |
||
4169 | |||
4170 | if (!el) { |
||
4171 | el = this.el = this.sourceEl.clone() |
||
4172 | .addClass(this.options.additionalClass || '') |
||
4173 | .css({ |
||
4174 | position: 'absolute', |
||
4175 | visibility: '', // in case original element was hidden (commonly through hideEvents()) |
||
4176 | display: this.isHidden ? 'none' : '', // for when initially hidden |
||
4177 | margin: 0, |
||
4178 | right: 'auto', // erase and set width instead |
||
4179 | bottom: 'auto', // erase and set height instead |
||
4180 | width: this.sourceEl.width(), // explicit height in case there was a 'right' value |
||
4181 | height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value |
||
4182 | opacity: this.options.opacity || '', |
||
4183 | zIndex: this.options.zIndex |
||
4184 | }); |
||
4185 | |||
4186 | // we don't want long taps or any mouse interaction causing selection/menus. |
||
4187 | // would use preventSelection(), but that prevents selectstart, causing problems. |
||
4188 | el.addClass('fc-unselectable'); |
||
4189 | |||
4190 | el.appendTo(this.parentEl); |
||
4191 | } |
||
4192 | |||
4193 | return el; |
||
4194 | }, |
||
4195 | |||
4196 | |||
4197 | // Removes the tracking element if it has already been created |
||
4198 | removeElement: function() { |
||
4199 | if (this.el) { |
||
4200 | this.el.remove(); |
||
4201 | this.el = null; |
||
4202 | } |
||
4203 | }, |
||
4204 | |||
4205 | |||
4206 | // Update the CSS position of the tracking element |
||
4207 | updatePosition: function() { |
||
4208 | var sourceOffset; |
||
4209 | var origin; |
||
4210 | |||
4211 | this.getEl(); // ensure this.el |
||
4212 | |||
4213 | // make sure origin info was computed |
||
4214 | if (this.top0 === null) { |
||
4215 | sourceOffset = this.sourceEl.offset(); |
||
4216 | origin = this.el.offsetParent().offset(); |
||
4217 | this.top0 = sourceOffset.top - origin.top; |
||
4218 | this.left0 = sourceOffset.left - origin.left; |
||
4219 | } |
||
4220 | |||
4221 | this.el.css({ |
||
4222 | top: this.top0 + this.topDelta, |
||
4223 | left: this.left0 + this.leftDelta |
||
4224 | }); |
||
4225 | }, |
||
4226 | |||
4227 | |||
4228 | // Gets called when the user moves the mouse |
||
4229 | handleMove: function(ev) { |
||
4230 | this.topDelta = getEvY(ev) - this.y0; |
||
4231 | this.leftDelta = getEvX(ev) - this.x0; |
||
4232 | |||
4233 | if (!this.isHidden) { |
||
4234 | this.updatePosition(); |
||
4235 | } |
||
4236 | }, |
||
4237 | |||
4238 | |||
4239 | // Temporarily makes the tracking element invisible. Can be called before following starts |
||
4240 | hide: function() { |
||
4241 | if (!this.isHidden) { |
||
4242 | this.isHidden = true; |
||
4243 | if (this.el) { |
||
4244 | this.el.hide(); |
||
4245 | } |
||
4246 | } |
||
4247 | }, |
||
4248 | |||
4249 | |||
4250 | // Show the tracking element after it has been temporarily hidden |
||
4251 | show: function() { |
||
4252 | if (this.isHidden) { |
||
4253 | this.isHidden = false; |
||
4254 | this.updatePosition(); |
||
4255 | this.getEl().show(); |
||
4256 | } |
||
4257 | } |
||
4258 | |||
4259 | }); |
||
4260 | |||
4261 | ;; |
||
4262 | |||
4263 | /* An abstract class comprised of a "grid" of areas that each represent a specific datetime |
||
4264 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
4265 | |||
4266 | var Grid = FC.Grid = Class.extend(ListenerMixin, { |
||
4267 | |||
4268 | // self-config, overridable by subclasses |
||
4269 | hasDayInteractions: true, // can user click/select ranges of time? |
||
4270 | |||
4271 | view: null, // a View object |
||
4272 | isRTL: null, // shortcut to the view's isRTL option |
||
4273 | |||
4274 | start: null, |
||
4275 | end: null, |
||
4276 | |||
4277 | el: null, // the containing element |
||
4278 | elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name. |
||
4279 | |||
4280 | // derived from options |
||
4281 | eventTimeFormat: null, |
||
4282 | displayEventTime: null, |
||
4283 | displayEventEnd: null, |
||
4284 | |||
4285 | minResizeDuration: null, // TODO: hack. set by subclasses. minumum event resize duration |
||
4286 | |||
4287 | // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity |
||
4288 | // of the date areas. if not defined, assumes to be day and time granularity. |
||
4289 | // TODO: port isTimeScale into same system? |
||
4290 | largeUnit: null, |
||
4291 | |||
4292 | dayClickListener: null, |
||
4293 | daySelectListener: null, |
||
4294 | segDragListener: null, |
||
4295 | segResizeListener: null, |
||
4296 | externalDragListener: null, |
||
4297 | |||
4298 | |||
4299 | constructor: function(view) { |
||
4300 | this.view = view; |
||
4301 | this.isRTL = view.opt('isRTL'); |
||
4302 | this.elsByFill = {}; |
||
4303 | |||
4304 | this.dayClickListener = this.buildDayClickListener(); |
||
4305 | this.daySelectListener = this.buildDaySelectListener(); |
||
4306 | }, |
||
4307 | |||
4308 | |||
4309 | /* Options |
||
4310 | ------------------------------------------------------------------------------------------------------------------*/ |
||
4311 | |||
4312 | |||
4313 | // Generates the format string used for event time text, if not explicitly defined by 'timeFormat' |
||
4314 | computeEventTimeFormat: function() { |
||
4315 | return this.view.opt('smallTimeFormat'); |
||
4316 | }, |
||
4317 | |||
4318 | |||
4319 | // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventTime'. |
||
4320 | // Only applies to non-all-day events. |
||
4321 | computeDisplayEventTime: function() { |
||
4322 | return true; |
||
4323 | }, |
||
4324 | |||
4325 | |||
4326 | // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd' |
||
4327 | computeDisplayEventEnd: function() { |
||
4328 | return true; |
||
4329 | }, |
||
4330 | |||
4331 | |||
4332 | /* Dates |
||
4333 | ------------------------------------------------------------------------------------------------------------------*/ |
||
4334 | |||
4335 | |||
4336 | // Tells the grid about what period of time to display. |
||
4337 | // Any date-related internal data should be generated. |
||
4338 | setRange: function(range) { |
||
4339 | this.start = range.start.clone(); |
||
4340 | this.end = range.end.clone(); |
||
4341 | |||
4342 | this.rangeUpdated(); |
||
4343 | this.processRangeOptions(); |
||
4344 | }, |
||
4345 | |||
4346 | |||
4347 | // Called when internal variables that rely on the range should be updated |
||
4348 | rangeUpdated: function() { |
||
4349 | }, |
||
4350 | |||
4351 | |||
4352 | // Updates values that rely on options and also relate to range |
||
4353 | processRangeOptions: function() { |
||
4354 | var view = this.view; |
||
4355 | var displayEventTime; |
||
4356 | var displayEventEnd; |
||
4357 | |||
4358 | this.eventTimeFormat = |
||
4359 | view.opt('eventTimeFormat') || |
||
4360 | view.opt('timeFormat') || // deprecated |
||
4361 | this.computeEventTimeFormat(); |
||
4362 | |||
4363 | displayEventTime = view.opt('displayEventTime'); |
||
4364 | if (displayEventTime == null) { |
||
4365 | displayEventTime = this.computeDisplayEventTime(); // might be based off of range |
||
4366 | } |
||
4367 | |||
4368 | displayEventEnd = view.opt('displayEventEnd'); |
||
4369 | if (displayEventEnd == null) { |
||
4370 | displayEventEnd = this.computeDisplayEventEnd(); // might be based off of range |
||
4371 | } |
||
4372 | |||
4373 | this.displayEventTime = displayEventTime; |
||
4374 | this.displayEventEnd = displayEventEnd; |
||
4375 | }, |
||
4376 | |||
4377 | |||
4378 | // Converts a span (has unzoned start/end and any other grid-specific location information) |
||
4379 | // into an array of segments (pieces of events whose format is decided by the grid). |
||
4380 | spanToSegs: function(span) { |
||
4381 | // subclasses must implement |
||
4382 | }, |
||
4383 | |||
4384 | |||
4385 | // Diffs the two dates, returning a duration, based on granularity of the grid |
||
4386 | // TODO: port isTimeScale into this system? |
||
4387 | diffDates: function(a, b) { |
||
4388 | if (this.largeUnit) { |
||
4389 | return diffByUnit(a, b, this.largeUnit); |
||
4390 | } |
||
4391 | else { |
||
4392 | return diffDayTime(a, b); |
||
4393 | } |
||
4394 | }, |
||
4395 | |||
4396 | |||
4397 | /* Hit Area |
||
4398 | ------------------------------------------------------------------------------------------------------------------*/ |
||
4399 | |||
4400 | hitsNeededDepth: 0, // necessary because multiple callers might need the same hits |
||
4401 | |||
4402 | hitsNeeded: function() { |
||
4403 | if (!(this.hitsNeededDepth++)) { |
||
4404 | this.prepareHits(); |
||
4405 | } |
||
4406 | }, |
||
4407 | |||
4408 | hitsNotNeeded: function() { |
||
4409 | if (this.hitsNeededDepth && !(--this.hitsNeededDepth)) { |
||
4410 | this.releaseHits(); |
||
4411 | } |
||
4412 | }, |
||
4413 | |||
4414 | |||
4415 | // Called before one or more queryHit calls might happen. Should prepare any cached coordinates for queryHit |
||
4416 | prepareHits: function() { |
||
4417 | }, |
||
4418 | |||
4419 | |||
4420 | // Called when queryHit calls have subsided. Good place to clear any coordinate caches. |
||
4421 | releaseHits: function() { |
||
4422 | }, |
||
4423 | |||
4424 | |||
4425 | // Given coordinates from the topleft of the document, return data about the date-related area underneath. |
||
4426 | // Can return an object with arbitrary properties (although top/right/left/bottom are encouraged). |
||
4427 | // Must have a `grid` property, a reference to this current grid. TODO: avoid this |
||
4428 | // The returned object will be processed by getHitSpan and getHitEl. |
||
4429 | queryHit: function(leftOffset, topOffset) { |
||
4430 | }, |
||
4431 | |||
4432 | |||
4433 | // like getHitSpan, but returns null if the resulting span's range is invalid |
||
4434 | getSafeHitSpan: function(hit) { |
||
4435 | var hitSpan = this.getHitSpan(hit); |
||
4436 | |||
4437 | if (!isRangeWithinRange(hitSpan, this.view.activeRange)) { |
||
4438 | return null; |
||
4439 | } |
||
4440 | |||
4441 | return hitSpan; |
||
4442 | }, |
||
4443 | |||
4444 | |||
4445 | // Given position-level information about a date-related area within the grid, |
||
4446 | // should return an object with at least a start/end date. Can provide other information as well. |
||
4447 | getHitSpan: function(hit) { |
||
4448 | }, |
||
4449 | |||
4450 | |||
4451 | // Given position-level information about a date-related area within the grid, |
||
4452 | // should return a jQuery element that best represents it. passed to dayClick callback. |
||
4453 | getHitEl: function(hit) { |
||
4454 | }, |
||
4455 | |||
4456 | |||
4457 | /* Rendering |
||
4458 | ------------------------------------------------------------------------------------------------------------------*/ |
||
4459 | |||
4460 | |||
4461 | // Sets the container element that the grid should render inside of. |
||
4462 | // Does other DOM-related initializations. |
||
4463 | setElement: function(el) { |
||
4464 | this.el = el; |
||
4465 | |||
4466 | if (this.hasDayInteractions) { |
||
4467 | preventSelection(el); |
||
4468 | |||
4469 | this.bindDayHandler('touchstart', this.dayTouchStart); |
||
4470 | this.bindDayHandler('mousedown', this.dayMousedown); |
||
4471 | } |
||
4472 | |||
4473 | // attach event-element-related handlers. in Grid.events |
||
4474 | // same garbage collection note as above. |
||
4475 | this.bindSegHandlers(); |
||
4476 | |||
4477 | this.bindGlobalHandlers(); |
||
4478 | }, |
||
4479 | |||
4480 | |||
4481 | bindDayHandler: function(name, handler) { |
||
4482 | var _this = this; |
||
4483 | |||
4484 | // attach a handler to the grid's root element. |
||
4485 | // jQuery will take care of unregistering them when removeElement gets called. |
||
4486 | this.el.on(name, function(ev) { |
||
4487 | if ( |
||
4488 | !$(ev.target).is( |
||
4489 | _this.segSelector + ',' + // directly on an event element |
||
4490 | _this.segSelector + ' *,' + // within an event element |
||
4491 | '.fc-more,' + // a "more.." link |
||
4492 | 'a[data-goto]' // a clickable nav link |
||
4493 | ) |
||
4494 | ) { |
||
4495 | return handler.call(_this, ev); |
||
4496 | } |
||
4497 | }); |
||
4498 | }, |
||
4499 | |||
4500 | |||
4501 | // Removes the grid's container element from the DOM. Undoes any other DOM-related attachments. |
||
4502 | // DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View |
||
4503 | removeElement: function() { |
||
4504 | this.unbindGlobalHandlers(); |
||
4505 | this.clearDragListeners(); |
||
4506 | |||
4507 | this.el.remove(); |
||
4508 | |||
4509 | // NOTE: we don't null-out this.el for the same reasons we don't do it within View::removeElement |
||
4510 | }, |
||
4511 | |||
4512 | |||
4513 | // Renders the basic structure of grid view before any content is rendered |
||
4514 | renderSkeleton: function() { |
||
4515 | // subclasses should implement |
||
4516 | }, |
||
4517 | |||
4518 | |||
4519 | // Renders the grid's date-related content (like areas that represent days/times). |
||
4520 | // Assumes setRange has already been called and the skeleton has already been rendered. |
||
4521 | renderDates: function() { |
||
4522 | // subclasses should implement |
||
4523 | }, |
||
4524 | |||
4525 | |||
4526 | // Unrenders the grid's date-related content |
||
4527 | unrenderDates: function() { |
||
4528 | // subclasses should implement |
||
4529 | }, |
||
4530 | |||
4531 | |||
4532 | /* Handlers |
||
4533 | ------------------------------------------------------------------------------------------------------------------*/ |
||
4534 | |||
4535 | |||
4536 | // Binds DOM handlers to elements that reside outside the grid, such as the document |
||
4537 | bindGlobalHandlers: function() { |
||
4538 | this.listenTo($(document), { |
||
4539 | dragstart: this.externalDragStart, // jqui |
||
4540 | sortstart: this.externalDragStart // jqui |
||
4541 | }); |
||
4542 | }, |
||
4543 | |||
4544 | |||
4545 | // Unbinds DOM handlers from elements that reside outside the grid |
||
4546 | unbindGlobalHandlers: function() { |
||
4547 | this.stopListeningTo($(document)); |
||
4548 | }, |
||
4549 | |||
4550 | |||
4551 | // Process a mousedown on an element that represents a day. For day clicking and selecting. |
||
4552 | dayMousedown: function(ev) { |
||
4553 | var view = this.view; |
||
4554 | |||
4555 | // HACK |
||
4556 | // This will still work even though bindDayHandler doesn't use GlobalEmitter. |
||
4557 | if (GlobalEmitter.get().shouldIgnoreMouse()) { |
||
4558 | return; |
||
4559 | } |
||
4560 | |||
4561 | this.dayClickListener.startInteraction(ev); |
||
4562 | |||
4563 | if (view.opt('selectable')) { |
||
4564 | this.daySelectListener.startInteraction(ev, { |
||
4565 | distance: view.opt('selectMinDistance') |
||
4566 | }); |
||
4567 | } |
||
4568 | }, |
||
4569 | |||
4570 | |||
4571 | dayTouchStart: function(ev) { |
||
4572 | var view = this.view; |
||
4573 | var selectLongPressDelay; |
||
4574 | |||
4575 | // On iOS (and Android?) when a new selection is initiated overtop another selection, |
||
4576 | // the touchend never fires because the elements gets removed mid-touch-interaction (my theory). |
||
4577 | // HACK: simply don't allow this to happen. |
||
4578 | // ALSO: prevent selection when an *event* is already raised. |
||
4579 | if (view.isSelected || view.selectedEvent) { |
||
4580 | return; |
||
4581 | } |
||
4582 | |||
4583 | selectLongPressDelay = view.opt('selectLongPressDelay'); |
||
4584 | if (selectLongPressDelay == null) { |
||
4585 | selectLongPressDelay = view.opt('longPressDelay'); // fallback |
||
4586 | } |
||
4587 | |||
4588 | this.dayClickListener.startInteraction(ev); |
||
4589 | |||
4590 | if (view.opt('selectable')) { |
||
4591 | this.daySelectListener.startInteraction(ev, { |
||
4592 | delay: selectLongPressDelay |
||
4593 | }); |
||
4594 | } |
||
4595 | }, |
||
4596 | |||
4597 | |||
4598 | // Creates a listener that tracks the user's drag across day elements, for day clicking. |
||
4599 | buildDayClickListener: function() { |
||
4600 | var _this = this; |
||
4601 | var view = this.view; |
||
4602 | var dayClickHit; // null if invalid dayClick |
||
4603 | |||
4604 | var dragListener = new HitDragListener(this, { |
||
4605 | scroll: view.opt('dragScroll'), |
||
4606 | interactionStart: function() { |
||
4607 | dayClickHit = dragListener.origHit; |
||
4608 | }, |
||
4609 | hitOver: function(hit, isOrig, origHit) { |
||
4610 | // if user dragged to another cell at any point, it can no longer be a dayClick |
||
4611 | if (!isOrig) { |
||
4612 | dayClickHit = null; |
||
4613 | } |
||
4614 | }, |
||
4615 | hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits |
||
4616 | dayClickHit = null; |
||
4617 | }, |
||
4618 | interactionEnd: function(ev, isCancelled) { |
||
4619 | var hitSpan; |
||
4620 | |||
4621 | if (!isCancelled && dayClickHit) { |
||
4622 | hitSpan = _this.getSafeHitSpan(dayClickHit); |
||
4623 | |||
4624 | if (hitSpan) { |
||
4625 | view.triggerDayClick(hitSpan, _this.getHitEl(dayClickHit), ev); |
||
4626 | } |
||
4627 | } |
||
4628 | } |
||
4629 | }); |
||
4630 | |||
4631 | // because dayClickListener won't be called with any time delay, "dragging" will begin immediately, |
||
4632 | // which will kill any touchmoving/scrolling. Prevent this. |
||
4633 | dragListener.shouldCancelTouchScroll = false; |
||
4634 | |||
4635 | dragListener.scrollAlwaysKills = true; |
||
4636 | |||
4637 | return dragListener; |
||
4638 | }, |
||
4639 | |||
4640 | |||
4641 | // Creates a listener that tracks the user's drag across day elements, for day selecting. |
||
4642 | buildDaySelectListener: function() { |
||
4643 | var _this = this; |
||
4644 | var view = this.view; |
||
4645 | var selectionSpan; // null if invalid selection |
||
4646 | |||
4647 | var dragListener = new HitDragListener(this, { |
||
4648 | scroll: view.opt('dragScroll'), |
||
4649 | interactionStart: function() { |
||
4650 | selectionSpan = null; |
||
4651 | }, |
||
4652 | dragStart: function() { |
||
4653 | view.unselect(); // since we could be rendering a new selection, we want to clear any old one |
||
4654 | }, |
||
4655 | hitOver: function(hit, isOrig, origHit) { |
||
4656 | var origHitSpan; |
||
4657 | var hitSpan; |
||
4658 | |||
4659 | if (origHit) { // click needs to have started on a hit |
||
4660 | |||
4661 | origHitSpan = _this.getSafeHitSpan(origHit); |
||
4662 | hitSpan = _this.getSafeHitSpan(hit); |
||
4663 | |||
4664 | if (origHitSpan && hitSpan) { |
||
4665 | selectionSpan = _this.computeSelection(origHitSpan, hitSpan); |
||
4666 | } |
||
4667 | else { |
||
4668 | selectionSpan = null; |
||
4669 | } |
||
4670 | |||
4671 | if (selectionSpan) { |
||
4672 | _this.renderSelection(selectionSpan); |
||
4673 | } |
||
4674 | else if (selectionSpan === false) { |
||
4675 | disableCursor(); |
||
4676 | } |
||
4677 | } |
||
4678 | }, |
||
4679 | hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits |
||
4680 | selectionSpan = null; |
||
4681 | _this.unrenderSelection(); |
||
4682 | }, |
||
4683 | hitDone: function() { // called after a hitOut OR before a dragEnd |
||
4684 | enableCursor(); |
||
4685 | }, |
||
4686 | interactionEnd: function(ev, isCancelled) { |
||
4687 | if (!isCancelled && selectionSpan) { |
||
4688 | // the selection will already have been rendered. just report it |
||
4689 | view.reportSelection(selectionSpan, ev); |
||
4690 | } |
||
4691 | } |
||
4692 | }); |
||
4693 | |||
4694 | return dragListener; |
||
4695 | }, |
||
4696 | |||
4697 | |||
4698 | // Kills all in-progress dragging. |
||
4699 | // Useful for when public API methods that result in re-rendering are invoked during a drag. |
||
4700 | // Also useful for when touch devices misbehave and don't fire their touchend. |
||
4701 | clearDragListeners: function() { |
||
4702 | this.dayClickListener.endInteraction(); |
||
4703 | this.daySelectListener.endInteraction(); |
||
4704 | |||
4705 | if (this.segDragListener) { |
||
4706 | this.segDragListener.endInteraction(); // will clear this.segDragListener |
||
4707 | } |
||
4708 | if (this.segResizeListener) { |
||
4709 | this.segResizeListener.endInteraction(); // will clear this.segResizeListener |
||
4710 | } |
||
4711 | if (this.externalDragListener) { |
||
4712 | this.externalDragListener.endInteraction(); // will clear this.externalDragListener |
||
4713 | } |
||
4714 | }, |
||
4715 | |||
4716 | |||
4717 | /* Event Helper |
||
4718 | ------------------------------------------------------------------------------------------------------------------*/ |
||
4719 | // TODO: should probably move this to Grid.events, like we did event dragging / resizing |
||
4720 | |||
4721 | |||
4722 | // Renders a mock event at the given event location, which contains zoned start/end properties. |
||
4723 | // Returns all mock event elements. |
||
4724 | renderEventLocationHelper: function(eventLocation, sourceSeg) { |
||
4725 | var fakeEvent = this.fabricateHelperEvent(eventLocation, sourceSeg); |
||
4726 | |||
4727 | return this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering |
||
4728 | }, |
||
4729 | |||
4730 | |||
4731 | // Builds a fake event given zoned event date properties and a segment is should be inspired from. |
||
4732 | // The range's end can be null, in which case the mock event that is rendered will have a null end time. |
||
4733 | // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging. |
||
4734 | fabricateHelperEvent: function(eventLocation, sourceSeg) { |
||
4735 | var fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible |
||
4736 | |||
4737 | fakeEvent.start = eventLocation.start.clone(); |
||
4738 | fakeEvent.end = eventLocation.end ? eventLocation.end.clone() : null; |
||
4739 | fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventDates |
||
4740 | this.view.calendar.normalizeEventDates(fakeEvent); |
||
4741 | |||
4742 | // this extra className will be useful for differentiating real events from mock events in CSS |
||
4743 | fakeEvent.className = (fakeEvent.className || []).concat('fc-helper'); |
||
4744 | |||
4745 | // if something external is being dragged in, don't render a resizer |
||
4746 | if (!sourceSeg) { |
||
4747 | fakeEvent.editable = false; |
||
4748 | } |
||
4749 | |||
4750 | return fakeEvent; |
||
4751 | }, |
||
4752 | |||
4753 | |||
4754 | // Renders a mock event. Given zoned event date properties. |
||
4755 | // Must return all mock event elements. |
||
4756 | renderHelper: function(eventLocation, sourceSeg) { |
||
4757 | // subclasses must implement |
||
4758 | }, |
||
4759 | |||
4760 | |||
4761 | // Unrenders a mock event |
||
4762 | unrenderHelper: function() { |
||
4763 | // subclasses must implement |
||
4764 | }, |
||
4765 | |||
4766 | |||
4767 | /* Selection |
||
4768 | ------------------------------------------------------------------------------------------------------------------*/ |
||
4769 | |||
4770 | |||
4771 | // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses. |
||
4772 | // Given a span (unzoned start/end and other misc data) |
||
4773 | renderSelection: function(span) { |
||
4774 | this.renderHighlight(span); |
||
4775 | }, |
||
4776 | |||
4777 | |||
4778 | // Unrenders any visual indications of a selection. Will unrender a highlight by default. |
||
4779 | unrenderSelection: function() { |
||
4780 | this.unrenderHighlight(); |
||
4781 | }, |
||
4782 | |||
4783 | |||
4784 | // Given the first and last date-spans of a selection, returns another date-span object. |
||
4785 | // Subclasses can override and provide additional data in the span object. Will be passed to renderSelection(). |
||
4786 | // Will return false if the selection is invalid and this should be indicated to the user. |
||
4787 | // Will return null/undefined if a selection invalid but no error should be reported. |
||
4788 | computeSelection: function(span0, span1) { |
||
4789 | var span = this.computeSelectionSpan(span0, span1); |
||
4790 | |||
4791 | if (span && !this.view.calendar.isSelectionSpanAllowed(span)) { |
||
4792 | return false; |
||
4793 | } |
||
4794 | |||
4795 | return span; |
||
4796 | }, |
||
4797 | |||
4798 | |||
4799 | // Given two spans, must return the combination of the two. |
||
4800 | // TODO: do this separation of concerns (combining VS validation) for event dnd/resize too. |
||
4801 | computeSelectionSpan: function(span0, span1) { |
||
4802 | var dates = [ span0.start, span0.end, span1.start, span1.end ]; |
||
4803 | |||
4804 | dates.sort(compareNumbers); // sorts chronologically. works with Moments |
||
4805 | |||
4806 | return { start: dates[0].clone(), end: dates[3].clone() }; |
||
4807 | }, |
||
4808 | |||
4809 | |||
4810 | /* Highlight |
||
4811 | ------------------------------------------------------------------------------------------------------------------*/ |
||
4812 | |||
4813 | |||
4814 | // Renders an emphasis on the given date range. Given a span (unzoned start/end and other misc data) |
||
4815 | renderHighlight: function(span) { |
||
4816 | this.renderFill('highlight', this.spanToSegs(span)); |
||
4817 | }, |
||
4818 | |||
4819 | |||
4820 | // Unrenders the emphasis on a date range |
||
4821 | unrenderHighlight: function() { |
||
4822 | this.unrenderFill('highlight'); |
||
4823 | }, |
||
4824 | |||
4825 | |||
4826 | // Generates an array of classNames for rendering the highlight. Used by the fill system. |
||
4827 | highlightSegClasses: function() { |
||
4828 | return [ 'fc-highlight' ]; |
||
4829 | }, |
||
4830 | |||
4831 | |||
4832 | /* Business Hours |
||
4833 | ------------------------------------------------------------------------------------------------------------------*/ |
||
4834 | |||
4835 | |||
4836 | renderBusinessHours: function() { |
||
4837 | }, |
||
4838 | |||
4839 | |||
4840 | unrenderBusinessHours: function() { |
||
4841 | }, |
||
4842 | |||
4843 | |||
4844 | /* Now Indicator |
||
4845 | ------------------------------------------------------------------------------------------------------------------*/ |
||
4846 | |||
4847 | |||
4848 | getNowIndicatorUnit: function() { |
||
4849 | }, |
||
4850 | |||
4851 | |||
4852 | renderNowIndicator: function(date) { |
||
4853 | }, |
||
4854 | |||
4855 | |||
4856 | unrenderNowIndicator: function() { |
||
4857 | }, |
||
4858 | |||
4859 | |||
4860 | /* Fill System (highlight, background events, business hours) |
||
4861 | -------------------------------------------------------------------------------------------------------------------- |
||
4862 | TODO: remove this system. like we did in TimeGrid |
||
4863 | */ |
||
4864 | |||
4865 | |||
4866 | // Renders a set of rectangles over the given segments of time. |
||
4867 | // MUST RETURN a subset of segs, the segs that were actually rendered. |
||
4868 | // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement |
||
4869 | renderFill: function(type, segs) { |
||
4870 | // subclasses must implement |
||
4871 | }, |
||
4872 | |||
4873 | |||
4874 | // Unrenders a specific type of fill that is currently rendered on the grid |
||
4875 | unrenderFill: function(type) { |
||
4876 | var el = this.elsByFill[type]; |
||
4877 | |||
4878 | if (el) { |
||
4879 | el.remove(); |
||
4880 | delete this.elsByFill[type]; |
||
4881 | } |
||
4882 | }, |
||
4883 | |||
4884 | |||
4885 | // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types. |
||
4886 | // Only returns segments that successfully rendered. |
||
4887 | // To be harnessed by renderFill (implemented by subclasses). |
||
4888 | // Analagous to renderFgSegEls. |
||
4889 | renderFillSegEls: function(type, segs) { |
||
4890 | var _this = this; |
||
4891 | var segElMethod = this[type + 'SegEl']; |
||
4892 | var html = ''; |
||
4893 | var renderedSegs = []; |
||
4894 | var i; |
||
4895 | |||
4896 | if (segs.length) { |
||
4897 | |||
4898 | // build a large concatenation of segment HTML |
||
4899 | for (i = 0; i < segs.length; i++) { |
||
4900 | html += this.fillSegHtml(type, segs[i]); |
||
4901 | } |
||
4902 | |||
4903 | // Grab individual elements from the combined HTML string. Use each as the default rendering. |
||
4904 | // Then, compute the 'el' for each segment. |
||
4905 | $(html).each(function(i, node) { |
||
4906 | var seg = segs[i]; |
||
4907 | var el = $(node); |
||
4908 | |||
4909 | // allow custom filter methods per-type |
||
4910 | if (segElMethod) { |
||
4911 | el = segElMethod.call(_this, seg, el); |
||
4912 | } |
||
4913 | |||
4914 | if (el) { // custom filters did not cancel the render |
||
4915 | el = $(el); // allow custom filter to return raw DOM node |
||
4916 | |||
4917 | // correct element type? (would be bad if a non-TD were inserted into a table for example) |
||
4918 | if (el.is(_this.fillSegTag)) { |
||
4919 | seg.el = el; |
||
4920 | renderedSegs.push(seg); |
||
4921 | } |
||
4922 | } |
||
4923 | }); |
||
4924 | } |
||
4925 | |||
4926 | return renderedSegs; |
||
4927 | }, |
||
4928 | |||
4929 | |||
4930 | fillSegTag: 'div', // subclasses can override |
||
4931 | |||
4932 | |||
4933 | // Builds the HTML needed for one fill segment. Generic enough to work with different types. |
||
4934 | fillSegHtml: function(type, seg) { |
||
4935 | |||
4936 | // custom hooks per-type |
||
4937 | var classesMethod = this[type + 'SegClasses']; |
||
4938 | var cssMethod = this[type + 'SegCss']; |
||
4939 | |||
4940 | var classes = classesMethod ? classesMethod.call(this, seg) : []; |
||
4941 | var css = cssToStr(cssMethod ? cssMethod.call(this, seg) : {}); |
||
4942 | |||
4943 | return '<' + this.fillSegTag + |
||
4944 | (classes.length ? ' class="' + classes.join(' ') + '"' : '') + |
||
4945 | (css ? ' style="' + css + '"' : '') + |
||
4946 | ' />'; |
||
4947 | }, |
||
4948 | |||
4949 | |||
4950 | |||
4951 | /* Generic rendering utilities for subclasses |
||
4952 | ------------------------------------------------------------------------------------------------------------------*/ |
||
4953 | |||
4954 | |||
4955 | // Computes HTML classNames for a single-day element |
||
4956 | getDayClasses: function(date, noThemeHighlight) { |
||
4957 | var view = this.view; |
||
4958 | var classes = []; |
||
4959 | var today; |
||
4960 | |||
4961 | if (!isDateWithinRange(date, view.activeRange)) { |
||
4962 | classes.push('fc-disabled-day'); // TODO: jQuery UI theme? |
||
4963 | } |
||
4964 | else { |
||
4965 | classes.push('fc-' + dayIDs[date.day()]); |
||
4966 | |||
4967 | if ( |
||
4968 | view.currentRangeAs('months') == 1 && // TODO: somehow get into MonthView |
||
4969 | date.month() != view.currentRange.start.month() |
||
4970 | ) { |
||
4971 | classes.push('fc-other-month'); |
||
4972 | } |
||
4973 | |||
4974 | today = view.calendar.getNow(); |
||
4975 | |||
4976 | if (date.isSame(today, 'day')) { |
||
4977 | classes.push('fc-today'); |
||
4978 | |||
4979 | if (noThemeHighlight !== true) { |
||
4980 | classes.push(view.highlightStateClass); |
||
4981 | } |
||
4982 | } |
||
4983 | else if (date < today) { |
||
4984 | classes.push('fc-past'); |
||
4985 | } |
||
4986 | else { |
||
4987 | classes.push('fc-future'); |
||
4988 | } |
||
4989 | } |
||
4990 | |||
4991 | return classes; |
||
4992 | } |
||
4993 | |||
4994 | }); |
||
4995 | |||
4996 | ;; |
||
4997 | |||
4998 | /* Event-rendering and event-interaction methods for the abstract Grid class |
||
4999 | ---------------------------------------------------------------------------------------------------------------------- |
||
5000 | |||
5001 | Data Types: |
||
5002 | event - { title, id, start, (end), whatever } |
||
5003 | location - { start, (end), allDay } |
||
5004 | rawEventRange - { start, end } |
||
5005 | eventRange - { start, end, isStart, isEnd } |
||
5006 | eventSpan - { start, end, isStart, isEnd, whatever } |
||
5007 | eventSeg - { event, whatever } |
||
5008 | seg - { whatever } |
||
5009 | */ |
||
5010 | |||
5011 | Grid.mixin({ |
||
5012 | |||
5013 | // self-config, overridable by subclasses |
||
5014 | segSelector: '.fc-event-container > *', // what constitutes an event element? |
||
5015 | |||
5016 | mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing |
||
5017 | isDraggingSeg: false, // is a segment being dragged? boolean |
||
5018 | isResizingSeg: false, // is a segment being resized? boolean |
||
5019 | isDraggingExternal: false, // jqui-dragging an external element? boolean |
||
5020 | segs: null, // the *event* segments currently rendered in the grid. TODO: rename to `eventSegs` |
||
5021 | |||
5022 | |||
5023 | // Renders the given events onto the grid |
||
5024 | renderEvents: function(events) { |
||
5025 | var bgEvents = []; |
||
5026 | var fgEvents = []; |
||
5027 | var i; |
||
5028 | |||
5029 | for (i = 0; i < events.length; i++) { |
||
5030 | (isBgEvent(events[i]) ? bgEvents : fgEvents).push(events[i]); |
||
5031 | } |
||
5032 | |||
5033 | this.segs = [].concat( // record all segs |
||
5034 | this.renderBgEvents(bgEvents), |
||
5035 | this.renderFgEvents(fgEvents) |
||
5036 | ); |
||
5037 | }, |
||
5038 | |||
5039 | |||
5040 | renderBgEvents: function(events) { |
||
5041 | var segs = this.eventsToSegs(events); |
||
5042 | |||
5043 | // renderBgSegs might return a subset of segs, segs that were actually rendered |
||
5044 | return this.renderBgSegs(segs) || segs; |
||
5045 | }, |
||
5046 | |||
5047 | |||
5048 | renderFgEvents: function(events) { |
||
5049 | var segs = this.eventsToSegs(events); |
||
5050 | |||
5051 | // renderFgSegs might return a subset of segs, segs that were actually rendered |
||
5052 | return this.renderFgSegs(segs) || segs; |
||
5053 | }, |
||
5054 | |||
5055 | |||
5056 | // Unrenders all events currently rendered on the grid |
||
5057 | unrenderEvents: function() { |
||
5058 | this.handleSegMouseout(); // trigger an eventMouseout if user's mouse is over an event |
||
5059 | this.clearDragListeners(); |
||
5060 | |||
5061 | this.unrenderFgSegs(); |
||
5062 | this.unrenderBgSegs(); |
||
5063 | |||
5064 | this.segs = null; |
||
5065 | }, |
||
5066 | |||
5067 | |||
5068 | // Retrieves all rendered segment objects currently rendered on the grid |
||
5069 | getEventSegs: function() { |
||
5070 | return this.segs || []; |
||
5071 | }, |
||
5072 | |||
5073 | |||
5074 | /* Foreground Segment Rendering |
||
5075 | ------------------------------------------------------------------------------------------------------------------*/ |
||
5076 | |||
5077 | |||
5078 | // Renders foreground event segments onto the grid. May return a subset of segs that were rendered. |
||
5079 | renderFgSegs: function(segs) { |
||
5080 | // subclasses must implement |
||
5081 | }, |
||
5082 | |||
5083 | |||
5084 | // Unrenders all currently rendered foreground segments |
||
5085 | unrenderFgSegs: function() { |
||
5086 | // subclasses must implement |
||
5087 | }, |
||
5088 | |||
5089 | |||
5090 | // Renders and assigns an `el` property for each foreground event segment. |
||
5091 | // Only returns segments that successfully rendered. |
||
5092 | // A utility that subclasses may use. |
||
5093 | renderFgSegEls: function(segs, disableResizing) { |
||
5094 | var view = this.view; |
||
5095 | var html = ''; |
||
5096 | var renderedSegs = []; |
||
5097 | var i; |
||
5098 | |||
5099 | if (segs.length) { // don't build an empty html string |
||
5100 | |||
5101 | // build a large concatenation of event segment HTML |
||
5102 | for (i = 0; i < segs.length; i++) { |
||
5103 | html += this.fgSegHtml(segs[i], disableResizing); |
||
5104 | } |
||
5105 | |||
5106 | // Grab individual elements from the combined HTML string. Use each as the default rendering. |
||
5107 | // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false. |
||
5108 | $(html).each(function(i, node) { |
||
5109 | var seg = segs[i]; |
||
5110 | var el = view.resolveEventEl(seg.event, $(node)); |
||
5111 | |||
5112 | if (el) { |
||
5113 | el.data('fc-seg', seg); // used by handlers |
||
5114 | seg.el = el; |
||
5115 | renderedSegs.push(seg); |
||
5116 | } |
||
5117 | }); |
||
5118 | } |
||
5119 | |||
5120 | return renderedSegs; |
||
5121 | }, |
||
5122 | |||
5123 | |||
5124 | // Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls() |
||
5125 | fgSegHtml: function(seg, disableResizing) { |
||
5126 | // subclasses should implement |
||
5127 | }, |
||
5128 | |||
5129 | |||
5130 | /* Background Segment Rendering |
||
5131 | ------------------------------------------------------------------------------------------------------------------*/ |
||
5132 | |||
5133 | |||
5134 | // Renders the given background event segments onto the grid. |
||
5135 | // Returns a subset of the segs that were actually rendered. |
||
5136 | renderBgSegs: function(segs) { |
||
5137 | return this.renderFill('bgEvent', segs); |
||
5138 | }, |
||
5139 | |||
5140 | |||
5141 | // Unrenders all the currently rendered background event segments |
||
5142 | unrenderBgSegs: function() { |
||
5143 | this.unrenderFill('bgEvent'); |
||
5144 | }, |
||
5145 | |||
5146 | |||
5147 | // Renders a background event element, given the default rendering. Called by the fill system. |
||
5148 | bgEventSegEl: function(seg, el) { |
||
5149 | return this.view.resolveEventEl(seg.event, el); // will filter through eventRender |
||
5150 | }, |
||
5151 | |||
5152 | |||
5153 | // Generates an array of classNames to be used for the default rendering of a background event. |
||
5154 | // Called by fillSegHtml. |
||
5155 | bgEventSegClasses: function(seg) { |
||
5156 | var event = seg.event; |
||
5157 | var source = event.source || {}; |
||
5158 | |||
5159 | return [ 'fc-bgevent' ].concat( |
||
5160 | event.className, |
||
5161 | source.className || [] |
||
5162 | ); |
||
5163 | }, |
||
5164 | |||
5165 | |||
5166 | // Generates a semicolon-separated CSS string to be used for the default rendering of a background event. |
||
5167 | // Called by fillSegHtml. |
||
5168 | bgEventSegCss: function(seg) { |
||
5169 | return { |
||
5170 | 'background-color': this.getSegSkinCss(seg)['background-color'] |
||
5171 | }; |
||
5172 | }, |
||
5173 | |||
5174 | |||
5175 | // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system. |
||
5176 | // Called by fillSegHtml. |
||
5177 | businessHoursSegClasses: function(seg) { |
||
5178 | return [ 'fc-nonbusiness', 'fc-bgevent' ]; |
||
5179 | }, |
||
5180 | |||
5181 | |||
5182 | /* Business Hours |
||
5183 | ------------------------------------------------------------------------------------------------------------------*/ |
||
5184 | |||
5185 | |||
5186 | // Compute business hour segs for the grid's current date range. |
||
5187 | // Caller must ask if whole-day business hours are needed. |
||
5188 | // If no `businessHours` configuration value is specified, assumes the calendar default. |
||
5189 | buildBusinessHourSegs: function(wholeDay, businessHours) { |
||
5190 | return this.eventsToSegs( |
||
5191 | this.buildBusinessHourEvents(wholeDay, businessHours) |
||
5192 | ); |
||
5193 | }, |
||
5194 | |||
5195 | |||
5196 | // Compute business hour *events* for the grid's current date range. |
||
5197 | // Caller must ask if whole-day business hours are needed. |
||
5198 | // If no `businessHours` configuration value is specified, assumes the calendar default. |
||
5199 | buildBusinessHourEvents: function(wholeDay, businessHours) { |
||
5200 | var calendar = this.view.calendar; |
||
5201 | var events; |
||
5202 | |||
5203 | if (businessHours == null) { |
||
5204 | // fallback |
||
5205 | // access from calendawr. don't access from view. doesn't update with dynamic options. |
||
5206 | businessHours = calendar.opt('businessHours'); |
||
5207 | } |
||
5208 | |||
5209 | events = calendar.computeBusinessHourEvents(wholeDay, businessHours); |
||
5210 | |||
5211 | // HACK. Eventually refactor business hours "events" system. |
||
5212 | // If no events are given, but businessHours is activated, this means the entire visible range should be |
||
5213 | // marked as *not* business-hours, via inverse-background rendering. |
||
5214 | if (!events.length && businessHours) { |
||
5215 | events = [ |
||
5216 | $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, { |
||
5217 | start: this.view.activeRange.end, // guaranteed out-of-range |
||
5218 | end: this.view.activeRange.end, // " |
||
5219 | dow: null |
||
5220 | }) |
||
5221 | ]; |
||
5222 | } |
||
5223 | |||
5224 | return events; |
||
5225 | }, |
||
5226 | |||
5227 | |||
5228 | /* Handlers |
||
5229 | ------------------------------------------------------------------------------------------------------------------*/ |
||
5230 | |||
5231 | |||
5232 | // Attaches event-element-related handlers for *all* rendered event segments of the view. |
||
5233 | bindSegHandlers: function() { |
||
5234 | this.bindSegHandlersToEl(this.el); |
||
5235 | }, |
||
5236 | |||
5237 | |||
5238 | // Attaches event-element-related handlers to an arbitrary container element. leverages bubbling. |
||
5239 | bindSegHandlersToEl: function(el) { |
||
5240 | this.bindSegHandlerToEl(el, 'touchstart', this.handleSegTouchStart); |
||
5241 | this.bindSegHandlerToEl(el, 'mouseenter', this.handleSegMouseover); |
||
5242 | this.bindSegHandlerToEl(el, 'mouseleave', this.handleSegMouseout); |
||
5243 | this.bindSegHandlerToEl(el, 'mousedown', this.handleSegMousedown); |
||
5244 | this.bindSegHandlerToEl(el, 'click', this.handleSegClick); |
||
5245 | }, |
||
5246 | |||
5247 | |||
5248 | // Executes a handler for any a user-interaction on a segment. |
||
5249 | // Handler gets called with (seg, ev), and with the `this` context of the Grid |
||
5250 | bindSegHandlerToEl: function(el, name, handler) { |
||
5251 | var _this = this; |
||
5252 | |||
5253 | el.on(name, this.segSelector, function(ev) { |
||
5254 | var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents |
||
5255 | |||
5256 | // only call the handlers if there is not a drag/resize in progress |
||
5257 | if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) { |
||
5258 | return handler.call(_this, seg, ev); // context will be the Grid |
||
5259 | } |
||
5260 | }); |
||
5261 | }, |
||
5262 | |||
5263 | |||
5264 | handleSegClick: function(seg, ev) { |
||
5265 | var res = this.view.publiclyTrigger('eventClick', seg.el[0], seg.event, ev); // can return `false` to cancel |
||
5266 | if (res === false) { |
||
5267 | ev.preventDefault(); |
||
5268 | } |
||
5269 | }, |
||
5270 | |||
5271 | |||
5272 | // Updates internal state and triggers handlers for when an event element is moused over |
||
5273 | handleSegMouseover: function(seg, ev) { |
||
5274 | if ( |
||
5275 | !GlobalEmitter.get().shouldIgnoreMouse() && |
||
5276 | !this.mousedOverSeg |
||
5277 | ) { |
||
5278 | this.mousedOverSeg = seg; |
||
5279 | if (this.view.isEventResizable(seg.event)) { |
||
5280 | seg.el.addClass('fc-allow-mouse-resize'); |
||
5281 | } |
||
5282 | this.view.publiclyTrigger('eventMouseover', seg.el[0], seg.event, ev); |
||
5283 | } |
||
5284 | }, |
||
5285 | |||
5286 | |||
5287 | // Updates internal state and triggers handlers for when an event element is moused out. |
||
5288 | // Can be given no arguments, in which case it will mouseout the segment that was previously moused over. |
||
5289 | handleSegMouseout: function(seg, ev) { |
||
5290 | ev = ev || {}; // if given no args, make a mock mouse event |
||
5291 | |||
5292 | if (this.mousedOverSeg) { |
||
5293 | seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment |
||
5294 | this.mousedOverSeg = null; |
||
5295 | if (this.view.isEventResizable(seg.event)) { |
||
5296 | seg.el.removeClass('fc-allow-mouse-resize'); |
||
5297 | } |
||
5298 | this.view.publiclyTrigger('eventMouseout', seg.el[0], seg.event, ev); |
||
5299 | } |
||
5300 | }, |
||
5301 | |||
5302 | |||
5303 | handleSegMousedown: function(seg, ev) { |
||
5304 | var isResizing = this.startSegResize(seg, ev, { distance: 5 }); |
||
5305 | |||
5306 | if (!isResizing && this.view.isEventDraggable(seg.event)) { |
||
5307 | this.buildSegDragListener(seg) |
||
5308 | .startInteraction(ev, { |
||
5309 | distance: 5 |
||
5310 | }); |
||
5311 | } |
||
5312 | }, |
||
5313 | |||
5314 | |||
5315 | handleSegTouchStart: function(seg, ev) { |
||
5316 | var view = this.view; |
||
5317 | var event = seg.event; |
||
5318 | var isSelected = view.isEventSelected(event); |
||
5319 | var isDraggable = view.isEventDraggable(event); |
||
5320 | var isResizable = view.isEventResizable(event); |
||
5321 | var isResizing = false; |
||
5322 | var dragListener; |
||
5323 | var eventLongPressDelay; |
||
5324 | |||
5325 | if (isSelected && isResizable) { |
||
5326 | // only allow resizing of the event is selected |
||
5327 | isResizing = this.startSegResize(seg, ev); |
||
5328 | } |
||
5329 | |||
5330 | if (!isResizing && (isDraggable || isResizable)) { // allowed to be selected? |
||
5331 | |||
5332 | eventLongPressDelay = view.opt('eventLongPressDelay'); |
||
5333 | if (eventLongPressDelay == null) { |
||
5334 | eventLongPressDelay = view.opt('longPressDelay'); // fallback |
||
5335 | } |
||
5336 | |||
5337 | dragListener = isDraggable ? |
||
5338 | this.buildSegDragListener(seg) : |
||
5339 | this.buildSegSelectListener(seg); // seg isn't draggable, but still needs to be selected |
||
5340 | |||
5341 | dragListener.startInteraction(ev, { // won't start if already started |
||
5342 | delay: isSelected ? 0 : eventLongPressDelay // do delay if not already selected |
||
5343 | }); |
||
5344 | } |
||
5345 | }, |
||
5346 | |||
5347 | |||
5348 | // returns boolean whether resizing actually started or not. |
||
5349 | // assumes the seg allows resizing. |
||
5350 | // `dragOptions` are optional. |
||
5351 | startSegResize: function(seg, ev, dragOptions) { |
||
5352 | if ($(ev.target).is('.fc-resizer')) { |
||
5353 | this.buildSegResizeListener(seg, $(ev.target).is('.fc-start-resizer')) |
||
5354 | .startInteraction(ev, dragOptions); |
||
5355 | return true; |
||
5356 | } |
||
5357 | return false; |
||
5358 | }, |
||
5359 | |||
5360 | |||
5361 | |||
5362 | /* Event Dragging |
||
5363 | ------------------------------------------------------------------------------------------------------------------*/ |
||
5364 | |||
5365 | |||
5366 | // Builds a listener that will track user-dragging on an event segment. |
||
5367 | // Generic enough to work with any type of Grid. |
||
5368 | // Has side effect of setting/unsetting `segDragListener` |
||
5369 | buildSegDragListener: function(seg) { |
||
5370 | var _this = this; |
||
5371 | var view = this.view; |
||
5372 | var el = seg.el; |
||
5373 | var event = seg.event; |
||
5374 | var isDragging; |
||
5375 | var mouseFollower; // A clone of the original element that will move with the mouse |
||
5376 | var dropLocation; // zoned event date properties |
||
5377 | |||
5378 | if (this.segDragListener) { |
||
5379 | return this.segDragListener; |
||
5380 | } |
||
5381 | |||
5382 | // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents |
||
5383 | // of the view. |
||
5384 | var dragListener = this.segDragListener = new HitDragListener(view, { |
||
5385 | scroll: view.opt('dragScroll'), |
||
5386 | subjectEl: el, |
||
5387 | subjectCenter: true, |
||
5388 | interactionStart: function(ev) { |
||
5389 | seg.component = _this; // for renderDrag |
||
5390 | isDragging = false; |
||
5391 | mouseFollower = new MouseFollower(seg.el, { |
||
5392 | additionalClass: 'fc-dragging', |
||
5393 | parentEl: view.el, |
||
5394 | opacity: dragListener.isTouch ? null : view.opt('dragOpacity'), |
||
5395 | revertDuration: view.opt('dragRevertDuration'), |
||
5396 | zIndex: 2 // one above the .fc-view |
||
5397 | }); |
||
5398 | mouseFollower.hide(); // don't show until we know this is a real drag |
||
5399 | mouseFollower.start(ev); |
||
5400 | }, |
||
5401 | dragStart: function(ev) { |
||
5402 | if (dragListener.isTouch && !view.isEventSelected(event)) { |
||
5403 | // if not previously selected, will fire after a delay. then, select the event |
||
5404 | view.selectEvent(event); |
||
5405 | } |
||
5406 | isDragging = true; |
||
5407 | _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported |
||
5408 | _this.segDragStart(seg, ev); |
||
5409 | view.hideEvent(event); // hide all event segments. our mouseFollower will take over |
||
5410 | }, |
||
5411 | hitOver: function(hit, isOrig, origHit) { |
||
5412 | var isAllowed = true; |
||
5413 | var origHitSpan; |
||
5414 | var hitSpan; |
||
5415 | var dragHelperEls; |
||
5416 | |||
5417 | // starting hit could be forced (DayGrid.limit) |
||
5418 | if (seg.hit) { |
||
5419 | origHit = seg.hit; |
||
5420 | } |
||
5421 | |||
5422 | // hit might not belong to this grid, so query origin grid |
||
5423 | origHitSpan = origHit.component.getSafeHitSpan(origHit); |
||
5424 | hitSpan = hit.component.getSafeHitSpan(hit); |
||
5425 | |||
5426 | if (origHitSpan && hitSpan) { |
||
5427 | dropLocation = _this.computeEventDrop(origHitSpan, hitSpan, event); |
||
5428 | isAllowed = dropLocation && _this.isEventLocationAllowed(dropLocation, event); |
||
5429 | } |
||
5430 | else { |
||
5431 | isAllowed = false; |
||
5432 | } |
||
5433 | |||
5434 | if (!isAllowed) { |
||
5435 | dropLocation = null; |
||
5436 | disableCursor(); |
||
5437 | } |
||
5438 | |||
5439 | // if a valid drop location, have the subclass render a visual indication |
||
5440 | if (dropLocation && (dragHelperEls = view.renderDrag(dropLocation, seg))) { |
||
5441 | |||
5442 | dragHelperEls.addClass('fc-dragging'); |
||
5443 | if (!dragListener.isTouch) { |
||
5444 | _this.applyDragOpacity(dragHelperEls); |
||
5445 | } |
||
5446 | |||
5447 | mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own |
||
5448 | } |
||
5449 | else { |
||
5450 | mouseFollower.show(); // otherwise, have the helper follow the mouse (no snapping) |
||
5451 | } |
||
5452 | |||
5453 | if (isOrig) { |
||
5454 | dropLocation = null; // needs to have moved hits to be a valid drop |
||
5455 | } |
||
5456 | }, |
||
5457 | hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits |
||
5458 | view.unrenderDrag(); // unrender whatever was done in renderDrag |
||
5459 | mouseFollower.show(); // show in case we are moving out of all hits |
||
5460 | dropLocation = null; |
||
5461 | }, |
||
5462 | hitDone: function() { // Called after a hitOut OR before a dragEnd |
||
5463 | enableCursor(); |
||
5464 | }, |
||
5465 | interactionEnd: function(ev) { |
||
5466 | delete seg.component; // prevent side effects |
||
5467 | |||
5468 | // do revert animation if hasn't changed. calls a callback when finished (whether animation or not) |
||
5469 | mouseFollower.stop(!dropLocation, function() { |
||
5470 | if (isDragging) { |
||
5471 | view.unrenderDrag(); |
||
5472 | _this.segDragStop(seg, ev); |
||
5473 | } |
||
5474 | |||
5475 | if (dropLocation) { |
||
5476 | // no need to re-show original, will rerender all anyways. esp important if eventRenderWait |
||
5477 | view.reportSegDrop(seg, dropLocation, _this.largeUnit, el, ev); |
||
5478 | } |
||
5479 | else { |
||
5480 | view.showEvent(event); |
||
5481 | } |
||
5482 | }); |
||
5483 | _this.segDragListener = null; |
||
5484 | } |
||
5485 | }); |
||
5486 | |||
5487 | return dragListener; |
||
5488 | }, |
||
5489 | |||
5490 | |||
5491 | // seg isn't draggable, but let's use a generic DragListener |
||
5492 | // simply for the delay, so it can be selected. |
||
5493 | // Has side effect of setting/unsetting `segDragListener` |
||
5494 | buildSegSelectListener: function(seg) { |
||
5495 | var _this = this; |
||
5496 | var view = this.view; |
||
5497 | var event = seg.event; |
||
5498 | |||
5499 | if (this.segDragListener) { |
||
5500 | return this.segDragListener; |
||
5501 | } |
||
5502 | |||
5503 | var dragListener = this.segDragListener = new DragListener({ |
||
5504 | dragStart: function(ev) { |
||
5505 | if (dragListener.isTouch && !view.isEventSelected(event)) { |
||
5506 | // if not previously selected, will fire after a delay. then, select the event |
||
5507 | view.selectEvent(event); |
||
5508 | } |
||
5509 | }, |
||
5510 | interactionEnd: function(ev) { |
||
5511 | _this.segDragListener = null; |
||
5512 | } |
||
5513 | }); |
||
5514 | |||
5515 | return dragListener; |
||
5516 | }, |
||
5517 | |||
5518 | |||
5519 | // Called before event segment dragging starts |
||
5520 | segDragStart: function(seg, ev) { |
||
5521 | this.isDraggingSeg = true; |
||
5522 | this.view.publiclyTrigger('eventDragStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy |
||
5523 | }, |
||
5524 | |||
5525 | |||
5526 | // Called after event segment dragging stops |
||
5527 | segDragStop: function(seg, ev) { |
||
5528 | this.isDraggingSeg = false; |
||
5529 | this.view.publiclyTrigger('eventDragStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy |
||
5530 | }, |
||
5531 | |||
5532 | |||
5533 | // Given the spans an event drag began, and the span event was dropped, calculates the new zoned start/end/allDay |
||
5534 | // values for the event. Subclasses may override and set additional properties to be used by renderDrag. |
||
5535 | // A falsy returned value indicates an invalid drop. |
||
5536 | // DOES NOT consider overlap/constraint. |
||
5537 | computeEventDrop: function(startSpan, endSpan, event) { |
||
5538 | var calendar = this.view.calendar; |
||
5539 | var dragStart = startSpan.start; |
||
5540 | var dragEnd = endSpan.start; |
||
5541 | var delta; |
||
5542 | var dropLocation; // zoned event date properties |
||
5543 | |||
5544 | if (dragStart.hasTime() === dragEnd.hasTime()) { |
||
5545 | delta = this.diffDates(dragEnd, dragStart); |
||
5546 | |||
5547 | // if an all-day event was in a timed area and it was dragged to a different time, |
||
5548 | // guarantee an end and adjust start/end to have times |
||
5549 | if (event.allDay && durationHasTime(delta)) { |
||
5550 | dropLocation = { |
||
5551 | start: event.start.clone(), |
||
5552 | end: calendar.getEventEnd(event), // will be an ambig day |
||
5553 | allDay: false // for normalizeEventTimes |
||
5554 | }; |
||
5555 | calendar.normalizeEventTimes(dropLocation); |
||
5556 | } |
||
5557 | // othewise, work off existing values |
||
5558 | else { |
||
5559 | dropLocation = pluckEventDateProps(event); |
||
5560 | } |
||
5561 | |||
5562 | dropLocation.start.add(delta); |
||
5563 | if (dropLocation.end) { |
||
5564 | dropLocation.end.add(delta); |
||
5565 | } |
||
5566 | } |
||
5567 | else { |
||
5568 | // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared |
||
5569 | dropLocation = { |
||
5570 | start: dragEnd.clone(), |
||
5571 | end: null, // end should be cleared |
||
5572 | allDay: !dragEnd.hasTime() |
||
5573 | }; |
||
5574 | } |
||
5575 | |||
5576 | return dropLocation; |
||
5577 | }, |
||
5578 | |||
5579 | |||
5580 | // Utility for apply dragOpacity to a jQuery set |
||
5581 | applyDragOpacity: function(els) { |
||
5582 | var opacity = this.view.opt('dragOpacity'); |
||
5583 | |||
5584 | if (opacity != null) { |
||
5585 | els.css('opacity', opacity); |
||
5586 | } |
||
5587 | }, |
||
5588 | |||
5589 | |||
5590 | /* External Element Dragging |
||
5591 | ------------------------------------------------------------------------------------------------------------------*/ |
||
5592 | |||
5593 | |||
5594 | // Called when a jQuery UI drag is initiated anywhere in the DOM |
||
5595 | externalDragStart: function(ev, ui) { |
||
5596 | var view = this.view; |
||
5597 | var el; |
||
5598 | var accept; |
||
5599 | |||
5600 | if (view.opt('droppable')) { // only listen if this setting is on |
||
5601 | el = $((ui ? ui.item : null) || ev.target); |
||
5602 | |||
5603 | // Test that the dragged element passes the dropAccept selector or filter function. |
||
5604 | // FYI, the default is "*" (matches all) |
||
5605 | accept = view.opt('dropAccept'); |
||
5606 | if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) { |
||
5607 | if (!this.isDraggingExternal) { // prevent double-listening if fired twice |
||
5608 | this.listenToExternalDrag(el, ev, ui); |
||
5609 | } |
||
5610 | } |
||
5611 | } |
||
5612 | }, |
||
5613 | |||
5614 | |||
5615 | // Called when a jQuery UI drag starts and it needs to be monitored for dropping |
||
5616 | listenToExternalDrag: function(el, ev, ui) { |
||
5617 | var _this = this; |
||
5618 | var view = this.view; |
||
5619 | var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create |
||
5620 | var dropLocation; // a null value signals an unsuccessful drag |
||
5621 | |||
5622 | // listener that tracks mouse movement over date-associated pixel regions |
||
5623 | var dragListener = _this.externalDragListener = new HitDragListener(this, { |
||
5624 | interactionStart: function() { |
||
5625 | _this.isDraggingExternal = true; |
||
5626 | }, |
||
5627 | hitOver: function(hit) { |
||
5628 | var isAllowed = true; |
||
5629 | var hitSpan = hit.component.getSafeHitSpan(hit); // hit might not belong to this grid |
||
5630 | |||
5631 | if (hitSpan) { |
||
5632 | dropLocation = _this.computeExternalDrop(hitSpan, meta); |
||
5633 | isAllowed = dropLocation && _this.isExternalLocationAllowed(dropLocation, meta.eventProps); |
||
5634 | } |
||
5635 | else { |
||
5636 | isAllowed = false; |
||
5637 | } |
||
5638 | |||
5639 | if (!isAllowed) { |
||
5640 | dropLocation = null; |
||
5641 | disableCursor(); |
||
5642 | } |
||
5643 | |||
5644 | if (dropLocation) { |
||
5645 | _this.renderDrag(dropLocation); // called without a seg parameter |
||
5646 | } |
||
5647 | }, |
||
5648 | hitOut: function() { |
||
5649 | dropLocation = null; // signal unsuccessful |
||
5650 | }, |
||
5651 | hitDone: function() { // Called after a hitOut OR before a dragEnd |
||
5652 | enableCursor(); |
||
5653 | _this.unrenderDrag(); |
||
5654 | }, |
||
5655 | interactionEnd: function(ev) { |
||
5656 | if (dropLocation) { // element was dropped on a valid hit |
||
5657 | view.reportExternalDrop(meta, dropLocation, el, ev, ui); |
||
5658 | } |
||
5659 | _this.isDraggingExternal = false; |
||
5660 | _this.externalDragListener = null; |
||
5661 | } |
||
5662 | }); |
||
5663 | |||
5664 | dragListener.startDrag(ev); // start listening immediately |
||
5665 | }, |
||
5666 | |||
5667 | |||
5668 | // Given a hit to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object), |
||
5669 | // returns the zoned start/end dates for the event that would result from the hypothetical drop. end might be null. |
||
5670 | // Returning a null value signals an invalid drop hit. |
||
5671 | // DOES NOT consider overlap/constraint. |
||
5672 | computeExternalDrop: function(span, meta) { |
||
5673 | var calendar = this.view.calendar; |
||
5674 | var dropLocation = { |
||
5675 | start: calendar.applyTimezone(span.start), // simulate a zoned event start date |
||
5676 | end: null |
||
5677 | }; |
||
5678 | |||
5679 | // if dropped on an all-day span, and element's metadata specified a time, set it |
||
5680 | if (meta.startTime && !dropLocation.start.hasTime()) { |
||
5681 | dropLocation.start.time(meta.startTime); |
||
5682 | } |
||
5683 | |||
5684 | if (meta.duration) { |
||
5685 | dropLocation.end = dropLocation.start.clone().add(meta.duration); |
||
5686 | } |
||
5687 | |||
5688 | return dropLocation; |
||
5689 | }, |
||
5690 | |||
5691 | |||
5692 | |||
5693 | /* Drag Rendering (for both events and an external elements) |
||
5694 | ------------------------------------------------------------------------------------------------------------------*/ |
||
5695 | |||
5696 | |||
5697 | // Renders a visual indication of an event or external element being dragged. |
||
5698 | // `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null. |
||
5699 | // `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null. |
||
5700 | // A truthy returned value indicates this method has rendered a helper element. |
||
5701 | // Must return elements used for any mock events. |
||
5702 | renderDrag: function(dropLocation, seg) { |
||
5703 | // subclasses must implement |
||
5704 | }, |
||
5705 | |||
5706 | |||
5707 | // Unrenders a visual indication of an event or external element being dragged |
||
5708 | unrenderDrag: function() { |
||
5709 | // subclasses must implement |
||
5710 | }, |
||
5711 | |||
5712 | |||
5713 | /* Resizing |
||
5714 | ------------------------------------------------------------------------------------------------------------------*/ |
||
5715 | |||
5716 | |||
5717 | // Creates a listener that tracks the user as they resize an event segment. |
||
5718 | // Generic enough to work with any type of Grid. |
||
5719 | buildSegResizeListener: function(seg, isStart) { |
||
5720 | var _this = this; |
||
5721 | var view = this.view; |
||
5722 | var calendar = view.calendar; |
||
5723 | var el = seg.el; |
||
5724 | var event = seg.event; |
||
5725 | var eventEnd = calendar.getEventEnd(event); |
||
5726 | var isDragging; |
||
5727 | var resizeLocation; // zoned event date properties. falsy if invalid resize |
||
5728 | |||
5729 | // Tracks mouse movement over the *grid's* coordinate map |
||
5730 | var dragListener = this.segResizeListener = new HitDragListener(this, { |
||
5731 | scroll: view.opt('dragScroll'), |
||
5732 | subjectEl: el, |
||
5733 | interactionStart: function() { |
||
5734 | isDragging = false; |
||
5735 | }, |
||
5736 | dragStart: function(ev) { |
||
5737 | isDragging = true; |
||
5738 | _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported |
||
5739 | _this.segResizeStart(seg, ev); |
||
5740 | }, |
||
5741 | hitOver: function(hit, isOrig, origHit) { |
||
5742 | var isAllowed = true; |
||
5743 | var origHitSpan = _this.getSafeHitSpan(origHit); |
||
5744 | var hitSpan = _this.getSafeHitSpan(hit); |
||
5745 | |||
5746 | if (origHitSpan && hitSpan) { |
||
5747 | resizeLocation = isStart ? |
||
5748 | _this.computeEventStartResize(origHitSpan, hitSpan, event) : |
||
5749 | _this.computeEventEndResize(origHitSpan, hitSpan, event); |
||
5750 | |||
5751 | isAllowed = resizeLocation && _this.isEventLocationAllowed(resizeLocation, event); |
||
5752 | } |
||
5753 | else { |
||
5754 | isAllowed = false; |
||
5755 | } |
||
5756 | |||
5757 | if (!isAllowed) { |
||
5758 | resizeLocation = null; |
||
5759 | disableCursor(); |
||
5760 | } |
||
5761 | else { |
||
5762 | if ( |
||
5763 | resizeLocation.start.isSame(event.start.clone().stripZone()) && |
||
5764 | resizeLocation.end.isSame(eventEnd.clone().stripZone()) |
||
5765 | ) { |
||
5766 | // no change. (FYI, event dates might have zones) |
||
5767 | resizeLocation = null; |
||
5768 | } |
||
5769 | } |
||
5770 | |||
5771 | if (resizeLocation) { |
||
5772 | view.hideEvent(event); |
||
5773 | _this.renderEventResize(resizeLocation, seg); |
||
5774 | } |
||
5775 | }, |
||
5776 | hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits |
||
5777 | resizeLocation = null; |
||
5778 | view.showEvent(event); // for when out-of-bounds. show original |
||
5779 | }, |
||
5780 | hitDone: function() { // resets the rendering to show the original event |
||
5781 | _this.unrenderEventResize(); |
||
5782 | enableCursor(); |
||
5783 | }, |
||
5784 | interactionEnd: function(ev) { |
||
5785 | if (isDragging) { |
||
5786 | _this.segResizeStop(seg, ev); |
||
5787 | } |
||
5788 | |||
5789 | if (resizeLocation) { // valid date to resize to? |
||
5790 | // no need to re-show original, will rerender all anyways. esp important if eventRenderWait |
||
5791 | view.reportSegResize(seg, resizeLocation, _this.largeUnit, el, ev); |
||
5792 | } |
||
5793 | else { |
||
5794 | view.showEvent(event); |
||
5795 | } |
||
5796 | _this.segResizeListener = null; |
||
5797 | } |
||
5798 | }); |
||
5799 | |||
5800 | return dragListener; |
||
5801 | }, |
||
5802 | |||
5803 | |||
5804 | // Called before event segment resizing starts |
||
5805 | segResizeStart: function(seg, ev) { |
||
5806 | this.isResizingSeg = true; |
||
5807 | this.view.publiclyTrigger('eventResizeStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy |
||
5808 | }, |
||
5809 | |||
5810 | |||
5811 | // Called after event segment resizing stops |
||
5812 | segResizeStop: function(seg, ev) { |
||
5813 | this.isResizingSeg = false; |
||
5814 | this.view.publiclyTrigger('eventResizeStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy |
||
5815 | }, |
||
5816 | |||
5817 | |||
5818 | // Returns new date-information for an event segment being resized from its start |
||
5819 | computeEventStartResize: function(startSpan, endSpan, event) { |
||
5820 | return this.computeEventResize('start', startSpan, endSpan, event); |
||
5821 | }, |
||
5822 | |||
5823 | |||
5824 | // Returns new date-information for an event segment being resized from its end |
||
5825 | computeEventEndResize: function(startSpan, endSpan, event) { |
||
5826 | return this.computeEventResize('end', startSpan, endSpan, event); |
||
5827 | }, |
||
5828 | |||
5829 | |||
5830 | // Returns new zoned date information for an event segment being resized from its start OR end |
||
5831 | // `type` is either 'start' or 'end'. |
||
5832 | // DOES NOT consider overlap/constraint. |
||
5833 | computeEventResize: function(type, startSpan, endSpan, event) { |
||
5834 | var calendar = this.view.calendar; |
||
5835 | var delta = this.diffDates(endSpan[type], startSpan[type]); |
||
5836 | var resizeLocation; // zoned event date properties |
||
5837 | var defaultDuration; |
||
5838 | |||
5839 | // build original values to work from, guaranteeing a start and end |
||
5840 | resizeLocation = { |
||
5841 | start: event.start.clone(), |
||
5842 | end: calendar.getEventEnd(event), |
||
5843 | allDay: event.allDay |
||
5844 | }; |
||
5845 | |||
5846 | // if an all-day event was in a timed area and was resized to a time, adjust start/end to have times |
||
5847 | if (resizeLocation.allDay && durationHasTime(delta)) { |
||
5848 | resizeLocation.allDay = false; |
||
5849 | calendar.normalizeEventTimes(resizeLocation); |
||
5850 | } |
||
5851 | |||
5852 | resizeLocation[type].add(delta); // apply delta to start or end |
||
5853 | |||
5854 | // if the event was compressed too small, find a new reasonable duration for it |
||
5855 | if (!resizeLocation.start.isBefore(resizeLocation.end)) { |
||
5856 | |||
5857 | defaultDuration = |
||
5858 | this.minResizeDuration || // TODO: hack |
||
5859 | (event.allDay ? |
||
5860 | calendar.defaultAllDayEventDuration : |
||
5861 | calendar.defaultTimedEventDuration); |
||
5862 | |||
5863 | if (type == 'start') { // resizing the start? |
||
5864 | resizeLocation.start = resizeLocation.end.clone().subtract(defaultDuration); |
||
5865 | } |
||
5866 | else { // resizing the end? |
||
5867 | resizeLocation.end = resizeLocation.start.clone().add(defaultDuration); |
||
5868 | } |
||
5869 | } |
||
5870 | |||
5871 | return resizeLocation; |
||
5872 | }, |
||
5873 | |||
5874 | |||
5875 | // Renders a visual indication of an event being resized. |
||
5876 | // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag. |
||
5877 | // Must return elements used for any mock events. |
||
5878 | renderEventResize: function(range, seg) { |
||
5879 | // subclasses must implement |
||
5880 | }, |
||
5881 | |||
5882 | |||
5883 | // Unrenders a visual indication of an event being resized. |
||
5884 | unrenderEventResize: function() { |
||
5885 | // subclasses must implement |
||
5886 | }, |
||
5887 | |||
5888 | |||
5889 | /* Rendering Utils |
||
5890 | ------------------------------------------------------------------------------------------------------------------*/ |
||
5891 | |||
5892 | |||
5893 | // Compute the text that should be displayed on an event's element. |
||
5894 | // `range` can be the Event object itself, or something range-like, with at least a `start`. |
||
5895 | // If event times are disabled, or the event has no time, will return a blank string. |
||
5896 | // If not specified, formatStr will default to the eventTimeFormat setting, |
||
5897 | // and displayEnd will default to the displayEventEnd setting. |
||
5898 | getEventTimeText: function(range, formatStr, displayEnd) { |
||
5899 | |||
5900 | if (formatStr == null) { |
||
5901 | formatStr = this.eventTimeFormat; |
||
5902 | } |
||
5903 | |||
5904 | if (displayEnd == null) { |
||
5905 | displayEnd = this.displayEventEnd; |
||
5906 | } |
||
5907 | |||
5908 | if (this.displayEventTime && range.start.hasTime()) { |
||
5909 | if (displayEnd && range.end) { |
||
5910 | return this.view.formatRange(range, formatStr); |
||
5911 | } |
||
5912 | else { |
||
5913 | return range.start.format(formatStr); |
||
5914 | } |
||
5915 | } |
||
5916 | |||
5917 | return ''; |
||
5918 | }, |
||
5919 | |||
5920 | |||
5921 | // Generic utility for generating the HTML classNames for an event segment's element |
||
5922 | getSegClasses: function(seg, isDraggable, isResizable) { |
||
5923 | var view = this.view; |
||
5924 | var classes = [ |
||
5925 | 'fc-event', |
||
5926 | seg.isStart ? 'fc-start' : 'fc-not-start', |
||
5927 | seg.isEnd ? 'fc-end' : 'fc-not-end' |
||
5928 | ].concat(this.getSegCustomClasses(seg)); |
||
5929 | |||
5930 | if (isDraggable) { |
||
5931 | classes.push('fc-draggable'); |
||
5932 | } |
||
5933 | if (isResizable) { |
||
5934 | classes.push('fc-resizable'); |
||
5935 | } |
||
5936 | |||
5937 | // event is currently selected? attach a className. |
||
5938 | if (view.isEventSelected(seg.event)) { |
||
5939 | classes.push('fc-selected'); |
||
5940 | } |
||
5941 | |||
5942 | return classes; |
||
5943 | }, |
||
5944 | |||
5945 | |||
5946 | // List of classes that were defined by the caller of the API in some way |
||
5947 | getSegCustomClasses: function(seg) { |
||
5948 | var event = seg.event; |
||
5949 | |||
5950 | return [].concat( |
||
5951 | event.className, // guaranteed to be an array |
||
5952 | event.source ? event.source.className : [] |
||
5953 | ); |
||
5954 | }, |
||
5955 | |||
5956 | |||
5957 | // Utility for generating event skin-related CSS properties |
||
5958 | getSegSkinCss: function(seg) { |
||
5959 | return { |
||
5960 | 'background-color': this.getSegBackgroundColor(seg), |
||
5961 | 'border-color': this.getSegBorderColor(seg), |
||
5962 | color: this.getSegTextColor(seg) |
||
5963 | }; |
||
5964 | }, |
||
5965 | |||
5966 | |||
5967 | // Queries for caller-specified color, then falls back to default |
||
5968 | getSegBackgroundColor: function(seg) { |
||
5969 | return seg.event.backgroundColor || |
||
5970 | seg.event.color || |
||
5971 | this.getSegDefaultBackgroundColor(seg); |
||
5972 | }, |
||
5973 | |||
5974 | |||
5975 | getSegDefaultBackgroundColor: function(seg) { |
||
5976 | var source = seg.event.source || {}; |
||
5977 | |||
5978 | return source.backgroundColor || |
||
5979 | source.color || |
||
5980 | this.view.opt('eventBackgroundColor') || |
||
5981 | this.view.opt('eventColor'); |
||
5982 | }, |
||
5983 | |||
5984 | |||
5985 | // Queries for caller-specified color, then falls back to default |
||
5986 | getSegBorderColor: function(seg) { |
||
5987 | return seg.event.borderColor || |
||
5988 | seg.event.color || |
||
5989 | this.getSegDefaultBorderColor(seg); |
||
5990 | }, |
||
5991 | |||
5992 | |||
5993 | getSegDefaultBorderColor: function(seg) { |
||
5994 | var source = seg.event.source || {}; |
||
5995 | |||
5996 | return source.borderColor || |
||
5997 | source.color || |
||
5998 | this.view.opt('eventBorderColor') || |
||
5999 | this.view.opt('eventColor'); |
||
6000 | }, |
||
6001 | |||
6002 | |||
6003 | // Queries for caller-specified color, then falls back to default |
||
6004 | getSegTextColor: function(seg) { |
||
6005 | return seg.event.textColor || |
||
6006 | this.getSegDefaultTextColor(seg); |
||
6007 | }, |
||
6008 | |||
6009 | |||
6010 | getSegDefaultTextColor: function(seg) { |
||
6011 | var source = seg.event.source || {}; |
||
6012 | |||
6013 | return source.textColor || |
||
6014 | this.view.opt('eventTextColor'); |
||
6015 | }, |
||
6016 | |||
6017 | |||
6018 | /* Event Location Validation |
||
6019 | ------------------------------------------------------------------------------------------------------------------*/ |
||
6020 | |||
6021 | |||
6022 | isEventLocationAllowed: function(eventLocation, event) { |
||
6023 | if (this.isEventLocationInRange(eventLocation)) { |
||
6024 | var calendar = this.view.calendar; |
||
6025 | var eventSpans = this.eventToSpans(eventLocation); |
||
6026 | var i; |
||
6027 | |||
6028 | if (eventSpans.length) { |
||
6029 | for (i = 0; i < eventSpans.length; i++) { |
||
6030 | if (!calendar.isEventSpanAllowed(eventSpans[i], event)) { |
||
6031 | return false; |
||
6032 | } |
||
6033 | } |
||
6034 | |||
6035 | return true; |
||
6036 | } |
||
6037 | } |
||
6038 | |||
6039 | return false; |
||
6040 | }, |
||
6041 | |||
6042 | |||
6043 | isExternalLocationAllowed: function(eventLocation, metaProps) { // FOR the external element |
||
6044 | if (this.isEventLocationInRange(eventLocation)) { |
||
6045 | var calendar = this.view.calendar; |
||
6046 | var eventSpans = this.eventToSpans(eventLocation); |
||
6047 | var i; |
||
6048 | |||
6049 | if (eventSpans.length) { |
||
6050 | for (i = 0; i < eventSpans.length; i++) { |
||
6051 | if (!calendar.isExternalSpanAllowed(eventSpans[i], eventLocation, metaProps)) { |
||
6052 | return false; |
||
6053 | } |
||
6054 | } |
||
6055 | |||
6056 | return true; |
||
6057 | } |
||
6058 | } |
||
6059 | |||
6060 | return false; |
||
6061 | }, |
||
6062 | |||
6063 | |||
6064 | isEventLocationInRange: function(eventLocation) { |
||
6065 | return isRangeWithinRange( |
||
6066 | this.eventToRawRange(eventLocation), |
||
6067 | this.view.validRange |
||
6068 | ); |
||
6069 | }, |
||
6070 | |||
6071 | |||
6072 | /* Converting events -> eventRange -> eventSpan -> eventSegs |
||
6073 | ------------------------------------------------------------------------------------------------------------------*/ |
||
6074 | |||
6075 | |||
6076 | // Generates an array of segments for the given single event |
||
6077 | // Can accept an event "location" as well (which only has start/end and no allDay) |
||
6078 | eventToSegs: function(event) { |
||
6079 | return this.eventsToSegs([ event ]); |
||
6080 | }, |
||
6081 | |||
6082 | |||
6083 | // Generates spans (always unzoned) for the given event. |
||
6084 | // Does not do any inverting for inverse-background events. |
||
6085 | // Can accept an event "location" as well (which only has start/end and no allDay) |
||
6086 | eventToSpans: function(event) { |
||
6087 | var eventRange = this.eventToRange(event); // { start, end, isStart, isEnd } |
||
6088 | |||
6089 | if (eventRange) { |
||
6090 | return this.eventRangeToSpans(eventRange, event); |
||
6091 | } |
||
6092 | else { // out of view's valid range |
||
6093 | return []; |
||
6094 | } |
||
6095 | }, |
||
6096 | |||
6097 | |||
6098 | |||
6099 | // Converts an array of event objects into an array of event segment objects. |
||
6100 | // A custom `segSliceFunc` may be given for arbitrarily slicing up events. |
||
6101 | // Doesn't guarantee an order for the resulting array. |
||
6102 | eventsToSegs: function(allEvents, segSliceFunc) { |
||
6103 | var _this = this; |
||
6104 | var eventsById = groupEventsById(allEvents); |
||
6105 | var segs = []; |
||
6106 | |||
6107 | $.each(eventsById, function(id, events) { |
||
6108 | var visibleEvents = []; |
||
6109 | var eventRanges = []; |
||
6110 | var eventRange; // { start, end, isStart, isEnd } |
||
6111 | var i; |
||
6112 | |||
6113 | for (i = 0; i < events.length; i++) { |
||
6114 | eventRange = _this.eventToRange(events[i]); // might be null if completely out of range |
||
6115 | |||
6116 | if (eventRange) { |
||
6117 | eventRanges.push(eventRange); |
||
6118 | visibleEvents.push(events[i]); |
||
6119 | } |
||
6120 | } |
||
6121 | |||
6122 | // inverse-background events (utilize only the first event in calculations) |
||
6123 | if (isInverseBgEvent(events[0])) { |
||
6124 | eventRanges = _this.invertRanges(eventRanges); // will lose isStart/isEnd |
||
6125 | |||
6126 | for (i = 0; i < eventRanges.length; i++) { |
||
6127 | segs.push.apply(segs, // append to |
||
6128 | _this.eventRangeToSegs(eventRanges[i], events[0], segSliceFunc) |
||
6129 | ); |
||
6130 | } |
||
6131 | } |
||
6132 | // normal event ranges |
||
6133 | else { |
||
6134 | for (i = 0; i < eventRanges.length; i++) { |
||
6135 | segs.push.apply(segs, // append to |
||
6136 | _this.eventRangeToSegs(eventRanges[i], visibleEvents[i], segSliceFunc) |
||
6137 | ); |
||
6138 | } |
||
6139 | } |
||
6140 | }); |
||
6141 | |||
6142 | return segs; |
||
6143 | }, |
||
6144 | |||
6145 | |||
6146 | // Generates the unzoned start/end dates an event appears to occupy |
||
6147 | // Can accept an event "location" as well (which only has start/end and no allDay) |
||
6148 | // returns { start, end, isStart, isEnd } |
||
6149 | // If the event is completely outside of the grid's valid range, will return undefined. |
||
6150 | eventToRange: function(event) { |
||
6151 | return this.refineRawEventRange( |
||
6152 | this.eventToRawRange(event) |
||
6153 | ); |
||
6154 | }, |
||
6155 | |||
6156 | |||
6157 | // Ensures the given range is within the view's activeRange and is correctly localized. |
||
6158 | // Always returns a result |
||
6159 | refineRawEventRange: function(rawRange) { |
||
6160 | var view = this.view; |
||
6161 | var calendar = view.calendar; |
||
6162 | var range = intersectRanges(rawRange, view.activeRange); |
||
6163 | |||
6164 | if (range) { // otherwise, event doesn't have valid range |
||
6165 | |||
6166 | // hack: dynamic locale change forgets to upate stored event localed |
||
6167 | calendar.localizeMoment(range.start); |
||
6168 | calendar.localizeMoment(range.end); |
||
6169 | |||
6170 | return range; |
||
6171 | } |
||
6172 | }, |
||
6173 | |||
6174 | |||
6175 | // not constrained to valid dates |
||
6176 | // not given localizeMoment hack |
||
6177 | eventToRawRange: function(event) { |
||
6178 | var calendar = this.view.calendar; |
||
6179 | var start = event.start.clone().stripZone(); |
||
6180 | var end = ( |
||
6181 | event.end ? |
||
6182 | event.end.clone() : |
||
6183 | // derive the end from the start and allDay. compute allDay if necessary |
||
6184 | calendar.getDefaultEventEnd( |
||
6185 | event.allDay != null ? |
||
6186 | event.allDay : |
||
6187 | !event.start.hasTime(), |
||
6188 | event.start |
||
6189 | ) |
||
6190 | ).stripZone(); |
||
6191 | |||
6192 | return { start: start, end: end }; |
||
6193 | }, |
||
6194 | |||
6195 | |||
6196 | // Given an event's range (unzoned start/end), and the event itself, |
||
6197 | // slice into segments (using the segSliceFunc function if specified) |
||
6198 | // eventRange - { start, end, isStart, isEnd } |
||
6199 | eventRangeToSegs: function(eventRange, event, segSliceFunc) { |
||
6200 | var eventSpans = this.eventRangeToSpans(eventRange, event); |
||
6201 | var segs = []; |
||
6202 | var i; |
||
6203 | |||
6204 | for (i = 0; i < eventSpans.length; i++) { |
||
6205 | segs.push.apply(segs, // append to |
||
6206 | this.eventSpanToSegs(eventSpans[i], event, segSliceFunc) |
||
6207 | ); |
||
6208 | } |
||
6209 | |||
6210 | return segs; |
||
6211 | }, |
||
6212 | |||
6213 | |||
6214 | // Given an event's unzoned date range, return an array of eventSpan objects. |
||
6215 | // eventSpan - { start, end, isStart, isEnd, otherthings... } |
||
6216 | // Subclasses can override. |
||
6217 | // Subclasses are obligated to forward eventRange.isStart/isEnd to the resulting spans. |
||
6218 | eventRangeToSpans: function(eventRange, event) { |
||
6219 | return [ $.extend({}, eventRange) ]; // copy into a single-item array |
||
6220 | }, |
||
6221 | |||
6222 | |||
6223 | // Given an event's span (unzoned start/end and other misc data), and the event itself, |
||
6224 | // slices into segments and attaches event-derived properties to them. |
||
6225 | // eventSpan - { start, end, isStart, isEnd, otherthings... } |
||
6226 | eventSpanToSegs: function(eventSpan, event, segSliceFunc) { |
||
6227 | var segs = segSliceFunc ? segSliceFunc(eventSpan) : this.spanToSegs(eventSpan); |
||
6228 | var i, seg; |
||
6229 | |||
6230 | for (i = 0; i < segs.length; i++) { |
||
6231 | seg = segs[i]; |
||
6232 | |||
6233 | // the eventSpan's isStart/isEnd takes precedence over the seg's |
||
6234 | if (!eventSpan.isStart) { |
||
6235 | seg.isStart = false; |
||
6236 | } |
||
6237 | if (!eventSpan.isEnd) { |
||
6238 | seg.isEnd = false; |
||
6239 | } |
||
6240 | |||
6241 | seg.event = event; |
||
6242 | seg.eventStartMS = +eventSpan.start; // TODO: not the best name after making spans unzoned |
||
6243 | seg.eventDurationMS = eventSpan.end - eventSpan.start; |
||
6244 | } |
||
6245 | |||
6246 | return segs; |
||
6247 | }, |
||
6248 | |||
6249 | |||
6250 | // Produces a new array of range objects that will cover all the time NOT covered by the given ranges. |
||
6251 | // SIDE EFFECT: will mutate the given array and will use its date references. |
||
6252 | invertRanges: function(ranges) { |
||
6253 | var view = this.view; |
||
6254 | var viewStart = view.activeRange.start.clone(); // need a copy |
||
6255 | var viewEnd = view.activeRange.end.clone(); // need a copy |
||
6256 | var inverseRanges = []; |
||
6257 | var start = viewStart; // the end of the previous range. the start of the new range |
||
6258 | var i, range; |
||
6259 | |||
6260 | // ranges need to be in order. required for our date-walking algorithm |
||
6261 | ranges.sort(compareRanges); |
||
6262 | |||
6263 | for (i = 0; i < ranges.length; i++) { |
||
6264 | range = ranges[i]; |
||
6265 | |||
6266 | // add the span of time before the event (if there is any) |
||
6267 | if (range.start > start) { // compare millisecond time (skip any ambig logic) |
||
6268 | inverseRanges.push({ |
||
6269 | start: start, |
||
6270 | end: range.start |
||
6271 | }); |
||
6272 | } |
||
6273 | |||
6274 | if (range.end > start) { |
||
6275 | start = range.end; |
||
6276 | } |
||
6277 | } |
||
6278 | |||
6279 | // add the span of time after the last event (if there is any) |
||
6280 | if (start < viewEnd) { // compare millisecond time (skip any ambig logic) |
||
6281 | inverseRanges.push({ |
||
6282 | start: start, |
||
6283 | end: viewEnd |
||
6284 | }); |
||
6285 | } |
||
6286 | |||
6287 | return inverseRanges; |
||
6288 | }, |
||
6289 | |||
6290 | |||
6291 | sortEventSegs: function(segs) { |
||
6292 | segs.sort(proxy(this, 'compareEventSegs')); |
||
6293 | }, |
||
6294 | |||
6295 | |||
6296 | // A cmp function for determining which segments should take visual priority |
||
6297 | compareEventSegs: function(seg1, seg2) { |
||
6298 | return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first |
||
6299 | seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first |
||
6300 | seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1) |
||
6301 | compareByFieldSpecs(seg1.event, seg2.event, this.view.eventOrderSpecs); |
||
6302 | } |
||
6303 | |||
6304 | }); |
||
6305 | |||
6306 | |||
6307 | /* Utilities |
||
6308 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
6309 | |||
6310 | |||
6311 | function pluckEventDateProps(event) { |
||
6312 | return { |
||
6313 | start: event.start.clone(), |
||
6314 | end: event.end ? event.end.clone() : null, |
||
6315 | allDay: event.allDay // keep it the same |
||
6316 | }; |
||
6317 | } |
||
6318 | FC.pluckEventDateProps = pluckEventDateProps; |
||
6319 | |||
6320 | |||
6321 | function isBgEvent(event) { // returns true if background OR inverse-background |
||
6322 | var rendering = getEventRendering(event); |
||
6323 | return rendering === 'background' || rendering === 'inverse-background'; |
||
6324 | } |
||
6325 | FC.isBgEvent = isBgEvent; // export |
||
6326 | |||
6327 | |||
6328 | function isInverseBgEvent(event) { |
||
6329 | return getEventRendering(event) === 'inverse-background'; |
||
6330 | } |
||
6331 | |||
6332 | |||
6333 | function getEventRendering(event) { |
||
6334 | return firstDefined((event.source || {}).rendering, event.rendering); |
||
6335 | } |
||
6336 | |||
6337 | |||
6338 | function groupEventsById(events) { |
||
6339 | var eventsById = {}; |
||
6340 | var i, event; |
||
6341 | |||
6342 | for (i = 0; i < events.length; i++) { |
||
6343 | event = events[i]; |
||
6344 | (eventsById[event._id] || (eventsById[event._id] = [])).push(event); |
||
6345 | } |
||
6346 | |||
6347 | return eventsById; |
||
6348 | } |
||
6349 | |||
6350 | |||
6351 | // A cmp function for determining which non-inverted "ranges" (see above) happen earlier |
||
6352 | function compareRanges(range1, range2) { |
||
6353 | return range1.start - range2.start; // earlier ranges go first |
||
6354 | } |
||
6355 | |||
6356 | |||
6357 | /* External-Dragging-Element Data |
||
6358 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
6359 | |||
6360 | // Require all HTML5 data-* attributes used by FullCalendar to have this prefix. |
||
6361 | // A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event. |
||
6362 | FC.dataAttrPrefix = ''; |
||
6363 | |||
6364 | // Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure |
||
6365 | // to be used for Event Object creation. |
||
6366 | // A defined `.eventProps`, even when empty, indicates that an event should be created. |
||
6367 | function getDraggedElMeta(el) { |
||
6368 | var prefix = FC.dataAttrPrefix; |
||
6369 | var eventProps; // properties for creating the event, not related to date/time |
||
6370 | var startTime; // a Duration |
||
6371 | var duration; |
||
6372 | var stick; |
||
6373 | |||
6374 | if (prefix) { prefix += '-'; } |
||
6375 | eventProps = el.data(prefix + 'event') || null; |
||
6376 | |||
6377 | if (eventProps) { |
||
6378 | if (typeof eventProps === 'object') { |
||
6379 | eventProps = $.extend({}, eventProps); // make a copy |
||
6380 | } |
||
6381 | else { // something like 1 or true. still signal event creation |
||
6382 | eventProps = {}; |
||
6383 | } |
||
6384 | |||
6385 | // pluck special-cased date/time properties |
||
6386 | startTime = eventProps.start; |
||
6387 | if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well |
||
6388 | duration = eventProps.duration; |
||
6389 | stick = eventProps.stick; |
||
6390 | delete eventProps.start; |
||
6391 | delete eventProps.time; |
||
6392 | delete eventProps.duration; |
||
6393 | delete eventProps.stick; |
||
6394 | } |
||
6395 | |||
6396 | // fallback to standalone attribute values for each of the date/time properties |
||
6397 | if (startTime == null) { startTime = el.data(prefix + 'start'); } |
||
6398 | if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well |
||
6399 | if (duration == null) { duration = el.data(prefix + 'duration'); } |
||
6400 | if (stick == null) { stick = el.data(prefix + 'stick'); } |
||
6401 | |||
6402 | // massage into correct data types |
||
6403 | startTime = startTime != null ? moment.duration(startTime) : null; |
||
6404 | duration = duration != null ? moment.duration(duration) : null; |
||
6405 | stick = Boolean(stick); |
||
6406 | |||
6407 | return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick }; |
||
6408 | } |
||
6409 | |||
6410 | |||
6411 | ;; |
||
6412 | |||
6413 | /* |
||
6414 | A set of rendering and date-related methods for a visual component comprised of one or more rows of day columns. |
||
6415 | Prerequisite: the object being mixed into needs to be a *Grid* |
||
6416 | */ |
||
6417 | var DayTableMixin = FC.DayTableMixin = { |
||
6418 | |||
6419 | breakOnWeeks: false, // should create a new row for each week? |
||
6420 | dayDates: null, // whole-day dates for each column. left to right |
||
6421 | dayIndices: null, // for each day from start, the offset |
||
6422 | daysPerRow: null, |
||
6423 | rowCnt: null, |
||
6424 | colCnt: null, |
||
6425 | colHeadFormat: null, |
||
6426 | |||
6427 | |||
6428 | // Populates internal variables used for date calculation and rendering |
||
6429 | updateDayTable: function() { |
||
6430 | var view = this.view; |
||
6431 | var date = this.start.clone(); |
||
6432 | var dayIndex = -1; |
||
6433 | var dayIndices = []; |
||
6434 | var dayDates = []; |
||
6435 | var daysPerRow; |
||
6436 | var firstDay; |
||
6437 | var rowCnt; |
||
6438 | |||
6439 | while (date.isBefore(this.end)) { // loop each day from start to end |
||
6440 | if (view.isHiddenDay(date)) { |
||
6441 | dayIndices.push(dayIndex + 0.5); // mark that it's between indices |
||
6442 | } |
||
6443 | else { |
||
6444 | dayIndex++; |
||
6445 | dayIndices.push(dayIndex); |
||
6446 | dayDates.push(date.clone()); |
||
6447 | } |
||
6448 | date.add(1, 'days'); |
||
6449 | } |
||
6450 | |||
6451 | if (this.breakOnWeeks) { |
||
6452 | // count columns until the day-of-week repeats |
||
6453 | firstDay = dayDates[0].day(); |
||
6454 | for (daysPerRow = 1; daysPerRow < dayDates.length; daysPerRow++) { |
||
6455 | if (dayDates[daysPerRow].day() == firstDay) { |
||
6456 | break; |
||
6457 | } |
||
6458 | } |
||
6459 | rowCnt = Math.ceil(dayDates.length / daysPerRow); |
||
6460 | } |
||
6461 | else { |
||
6462 | rowCnt = 1; |
||
6463 | daysPerRow = dayDates.length; |
||
6464 | } |
||
6465 | |||
6466 | this.dayDates = dayDates; |
||
6467 | this.dayIndices = dayIndices; |
||
6468 | this.daysPerRow = daysPerRow; |
||
6469 | this.rowCnt = rowCnt; |
||
6470 | |||
6471 | this.updateDayTableCols(); |
||
6472 | }, |
||
6473 | |||
6474 | |||
6475 | // Computes and assigned the colCnt property and updates any options that may be computed from it |
||
6476 | updateDayTableCols: function() { |
||
6477 | this.colCnt = this.computeColCnt(); |
||
6478 | this.colHeadFormat = this.view.opt('columnFormat') || this.computeColHeadFormat(); |
||
6479 | }, |
||
6480 | |||
6481 | |||
6482 | // Determines how many columns there should be in the table |
||
6483 | computeColCnt: function() { |
||
6484 | return this.daysPerRow; |
||
6485 | }, |
||
6486 | |||
6487 | |||
6488 | // Computes the ambiguously-timed moment for the given cell |
||
6489 | getCellDate: function(row, col) { |
||
6490 | return this.dayDates[ |
||
6491 | this.getCellDayIndex(row, col) |
||
6492 | ].clone(); |
||
6493 | }, |
||
6494 | |||
6495 | |||
6496 | // Computes the ambiguously-timed date range for the given cell |
||
6497 | getCellRange: function(row, col) { |
||
6498 | var start = this.getCellDate(row, col); |
||
6499 | var end = start.clone().add(1, 'days'); |
||
6500 | |||
6501 | return { start: start, end: end }; |
||
6502 | }, |
||
6503 | |||
6504 | |||
6505 | // Returns the number of day cells, chronologically, from the first of the grid (0-based) |
||
6506 | getCellDayIndex: function(row, col) { |
||
6507 | return row * this.daysPerRow + this.getColDayIndex(col); |
||
6508 | }, |
||
6509 | |||
6510 | |||
6511 | // Returns the numner of day cells, chronologically, from the first cell in *any given row* |
||
6512 | getColDayIndex: function(col) { |
||
6513 | if (this.isRTL) { |
||
6514 | return this.colCnt - 1 - col; |
||
6515 | } |
||
6516 | else { |
||
6517 | return col; |
||
6518 | } |
||
6519 | }, |
||
6520 | |||
6521 | |||
6522 | // Given a date, returns its chronolocial cell-index from the first cell of the grid. |
||
6523 | // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets. |
||
6524 | // If before the first offset, returns a negative number. |
||
6525 | // If after the last offset, returns an offset past the last cell offset. |
||
6526 | // Only works for *start* dates of cells. Will not work for exclusive end dates for cells. |
||
6527 | getDateDayIndex: function(date) { |
||
6528 | var dayIndices = this.dayIndices; |
||
6529 | var dayOffset = date.diff(this.start, 'days'); |
||
6530 | |||
6531 | if (dayOffset < 0) { |
||
6532 | return dayIndices[0] - 1; |
||
6533 | } |
||
6534 | else if (dayOffset >= dayIndices.length) { |
||
6535 | return dayIndices[dayIndices.length - 1] + 1; |
||
6536 | } |
||
6537 | else { |
||
6538 | return dayIndices[dayOffset]; |
||
6539 | } |
||
6540 | }, |
||
6541 | |||
6542 | |||
6543 | /* Options |
||
6544 | ------------------------------------------------------------------------------------------------------------------*/ |
||
6545 | |||
6546 | |||
6547 | // Computes a default column header formatting string if `colFormat` is not explicitly defined |
||
6548 | computeColHeadFormat: function() { |
||
6549 | // if more than one week row, or if there are a lot of columns with not much space, |
||
6550 | // put just the day numbers will be in each cell |
||
6551 | if (this.rowCnt > 1 || this.colCnt > 10) { |
||
6552 | return 'ddd'; // "Sat" |
||
6553 | } |
||
6554 | // multiple days, so full single date string WON'T be in title text |
||
6555 | else if (this.colCnt > 1) { |
||
6556 | return this.view.opt('dayOfMonthFormat'); // "Sat 12/10" |
||
6557 | } |
||
6558 | // single day, so full single date string will probably be in title text |
||
6559 | else { |
||
6560 | return 'dddd'; // "Saturday" |
||
6561 | } |
||
6562 | }, |
||
6563 | |||
6564 | |||
6565 | /* Slicing |
||
6566 | ------------------------------------------------------------------------------------------------------------------*/ |
||
6567 | |||
6568 | |||
6569 | // Slices up a date range into a segment for every week-row it intersects with |
||
6570 | sliceRangeByRow: function(range) { |
||
6571 | var daysPerRow = this.daysPerRow; |
||
6572 | var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold |
||
6573 | var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index |
||
6574 | var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index |
||
6575 | var segs = []; |
||
6576 | var row; |
||
6577 | var rowFirst, rowLast; // inclusive day-index range for current row |
||
6578 | var segFirst, segLast; // inclusive day-index range for segment |
||
6579 | |||
6580 | for (row = 0; row < this.rowCnt; row++) { |
||
6581 | rowFirst = row * daysPerRow; |
||
6582 | rowLast = rowFirst + daysPerRow - 1; |
||
6583 | |||
6584 | // intersect segment's offset range with the row's |
||
6585 | segFirst = Math.max(rangeFirst, rowFirst); |
||
6586 | segLast = Math.min(rangeLast, rowLast); |
||
6587 | |||
6588 | // deal with in-between indices |
||
6589 | segFirst = Math.ceil(segFirst); // in-between starts round to next cell |
||
6590 | segLast = Math.floor(segLast); // in-between ends round to prev cell |
||
6591 | |||
6592 | if (segFirst <= segLast) { // was there any intersection with the current row? |
||
6593 | segs.push({ |
||
6594 | row: row, |
||
6595 | |||
6596 | // normalize to start of row |
||
6597 | firstRowDayIndex: segFirst - rowFirst, |
||
6598 | lastRowDayIndex: segLast - rowFirst, |
||
6599 | |||
6600 | // must be matching integers to be the segment's start/end |
||
6601 | isStart: segFirst === rangeFirst, |
||
6602 | isEnd: segLast === rangeLast |
||
6603 | }); |
||
6604 | } |
||
6605 | } |
||
6606 | |||
6607 | return segs; |
||
6608 | }, |
||
6609 | |||
6610 | |||
6611 | // Slices up a date range into a segment for every day-cell it intersects with. |
||
6612 | // TODO: make more DRY with sliceRangeByRow somehow. |
||
6613 | sliceRangeByDay: function(range) { |
||
6614 | var daysPerRow = this.daysPerRow; |
||
6615 | var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold |
||
6616 | var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index |
||
6617 | var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index |
||
6618 | var segs = []; |
||
6619 | var row; |
||
6620 | var rowFirst, rowLast; // inclusive day-index range for current row |
||
6621 | var i; |
||
6622 | var segFirst, segLast; // inclusive day-index range for segment |
||
6623 | |||
6624 | for (row = 0; row < this.rowCnt; row++) { |
||
6625 | rowFirst = row * daysPerRow; |
||
6626 | rowLast = rowFirst + daysPerRow - 1; |
||
6627 | |||
6628 | for (i = rowFirst; i <= rowLast; i++) { |
||
6629 | |||
6630 | // intersect segment's offset range with the row's |
||
6631 | segFirst = Math.max(rangeFirst, i); |
||
6632 | segLast = Math.min(rangeLast, i); |
||
6633 | |||
6634 | // deal with in-between indices |
||
6635 | segFirst = Math.ceil(segFirst); // in-between starts round to next cell |
||
6636 | segLast = Math.floor(segLast); // in-between ends round to prev cell |
||
6637 | |||
6638 | if (segFirst <= segLast) { // was there any intersection with the current row? |
||
6639 | segs.push({ |
||
6640 | row: row, |
||
6641 | |||
6642 | // normalize to start of row |
||
6643 | firstRowDayIndex: segFirst - rowFirst, |
||
6644 | lastRowDayIndex: segLast - rowFirst, |
||
6645 | |||
6646 | // must be matching integers to be the segment's start/end |
||
6647 | isStart: segFirst === rangeFirst, |
||
6648 | isEnd: segLast === rangeLast |
||
6649 | }); |
||
6650 | } |
||
6651 | } |
||
6652 | } |
||
6653 | |||
6654 | return segs; |
||
6655 | }, |
||
6656 | |||
6657 | |||
6658 | /* Header Rendering |
||
6659 | ------------------------------------------------------------------------------------------------------------------*/ |
||
6660 | |||
6661 | |||
6662 | renderHeadHtml: function() { |
||
6663 | var view = this.view; |
||
6664 | |||
6665 | return '' + |
||
6666 | '<div class="fc-row ' + view.widgetHeaderClass + '">' + |
||
6667 | '<table>' + |
||
6668 | '<thead>' + |
||
6669 | this.renderHeadTrHtml() + |
||
6670 | '</thead>' + |
||
6671 | '</table>' + |
||
6672 | '</div>'; |
||
6673 | }, |
||
6674 | |||
6675 | |||
6676 | renderHeadIntroHtml: function() { |
||
6677 | return this.renderIntroHtml(); // fall back to generic |
||
6678 | }, |
||
6679 | |||
6680 | |||
6681 | renderHeadTrHtml: function() { |
||
6682 | return '' + |
||
6683 | '<tr>' + |
||
6684 | (this.isRTL ? '' : this.renderHeadIntroHtml()) + |
||
6685 | this.renderHeadDateCellsHtml() + |
||
6686 | (this.isRTL ? this.renderHeadIntroHtml() : '') + |
||
6687 | '</tr>'; |
||
6688 | }, |
||
6689 | |||
6690 | |||
6691 | renderHeadDateCellsHtml: function() { |
||
6692 | var htmls = []; |
||
6693 | var col, date; |
||
6694 | |||
6695 | for (col = 0; col < this.colCnt; col++) { |
||
6696 | date = this.getCellDate(0, col); |
||
6697 | htmls.push(this.renderHeadDateCellHtml(date)); |
||
6698 | } |
||
6699 | |||
6700 | return htmls.join(''); |
||
6701 | }, |
||
6702 | |||
6703 | |||
6704 | // TODO: when internalApiVersion, accept an object for HTML attributes |
||
6705 | // (colspan should be no different) |
||
6706 | renderHeadDateCellHtml: function(date, colspan, otherAttrs) { |
||
6707 | var view = this.view; |
||
6708 | var isDateValid = isDateWithinRange(date, view.activeRange); // TODO: called too frequently. cache somehow. |
||
6709 | var classNames = [ |
||
6710 | 'fc-day-header', |
||
6711 | view.widgetHeaderClass |
||
6712 | ]; |
||
6713 | var innerHtml = htmlEscape(date.format(this.colHeadFormat)); |
||
6714 | |||
6715 | // if only one row of days, the classNames on the header can represent the specific days beneath |
||
6716 | if (this.rowCnt === 1) { |
||
6717 | classNames = classNames.concat( |
||
6718 | // includes the day-of-week class |
||
6719 | // noThemeHighlight=true (don't highlight the header) |
||
6720 | this.getDayClasses(date, true) |
||
6721 | ); |
||
6722 | } |
||
6723 | else { |
||
6724 | classNames.push('fc-' + dayIDs[date.day()]); // only add the day-of-week class |
||
6725 | } |
||
6726 | |||
6727 | return '' + |
||
6728 | '<th class="' + classNames.join(' ') + '"' + |
||
6729 | ((isDateValid && this.rowCnt) === 1 ? |
||
6730 | ' data-date="' + date.format('YYYY-MM-DD') + '"' : |
||
6731 | '') + |
||
6732 | (colspan > 1 ? |
||
6733 | ' colspan="' + colspan + '"' : |
||
6734 | '') + |
||
6735 | (otherAttrs ? |
||
6736 | ' ' + otherAttrs : |
||
6737 | '') + |
||
6738 | '>' + |
||
6739 | (isDateValid ? |
||
6740 | // don't make a link if the heading could represent multiple days, or if there's only one day (forceOff) |
||
6741 | view.buildGotoAnchorHtml( |
||
6742 | { date: date, forceOff: this.rowCnt > 1 || this.colCnt === 1 }, |
||
6743 | innerHtml |
||
6744 | ) : |
||
6745 | // if not valid, display text, but no link |
||
6746 | innerHtml |
||
6747 | ) + |
||
6748 | '</th>'; |
||
6749 | }, |
||
6750 | |||
6751 | |||
6752 | /* Background Rendering |
||
6753 | ------------------------------------------------------------------------------------------------------------------*/ |
||
6754 | |||
6755 | |||
6756 | renderBgTrHtml: function(row) { |
||
6757 | return '' + |
||
6758 | '<tr>' + |
||
6759 | (this.isRTL ? '' : this.renderBgIntroHtml(row)) + |
||
6760 | this.renderBgCellsHtml(row) + |
||
6761 | (this.isRTL ? this.renderBgIntroHtml(row) : '') + |
||
6762 | '</tr>'; |
||
6763 | }, |
||
6764 | |||
6765 | |||
6766 | renderBgIntroHtml: function(row) { |
||
6767 | return this.renderIntroHtml(); // fall back to generic |
||
6768 | }, |
||
6769 | |||
6770 | |||
6771 | renderBgCellsHtml: function(row) { |
||
6772 | var htmls = []; |
||
6773 | var col, date; |
||
6774 | |||
6775 | for (col = 0; col < this.colCnt; col++) { |
||
6776 | date = this.getCellDate(row, col); |
||
6777 | htmls.push(this.renderBgCellHtml(date)); |
||
6778 | } |
||
6779 | |||
6780 | return htmls.join(''); |
||
6781 | }, |
||
6782 | |||
6783 | |||
6784 | renderBgCellHtml: function(date, otherAttrs) { |
||
6785 | var view = this.view; |
||
6786 | var isDateValid = isDateWithinRange(date, view.activeRange); // TODO: called too frequently. cache somehow. |
||
6787 | var classes = this.getDayClasses(date); |
||
6788 | |||
6789 | classes.unshift('fc-day', view.widgetContentClass); |
||
6790 | |||
6791 | return '<td class="' + classes.join(' ') + '"' + |
||
6792 | (isDateValid ? |
||
6793 | ' data-date="' + date.format('YYYY-MM-DD') + '"' : // if date has a time, won't format it |
||
6794 | '') + |
||
6795 | (otherAttrs ? |
||
6796 | ' ' + otherAttrs : |
||
6797 | '') + |
||
6798 | '></td>'; |
||
6799 | }, |
||
6800 | |||
6801 | |||
6802 | /* Generic |
||
6803 | ------------------------------------------------------------------------------------------------------------------*/ |
||
6804 | |||
6805 | |||
6806 | // Generates the default HTML intro for any row. User classes should override |
||
6807 | renderIntroHtml: function() { |
||
6808 | }, |
||
6809 | |||
6810 | |||
6811 | // TODO: a generic method for dealing with <tr>, RTL, intro |
||
6812 | // when increment internalApiVersion |
||
6813 | // wrapTr (scheduler) |
||
6814 | |||
6815 | |||
6816 | /* Utils |
||
6817 | ------------------------------------------------------------------------------------------------------------------*/ |
||
6818 | |||
6819 | |||
6820 | // Applies the generic "intro" and "outro" HTML to the given cells. |
||
6821 | // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro. |
||
6822 | bookendCells: function(trEl) { |
||
6823 | var introHtml = this.renderIntroHtml(); |
||
6824 | |||
6825 | if (introHtml) { |
||
6826 | if (this.isRTL) { |
||
6827 | trEl.append(introHtml); |
||
6828 | } |
||
6829 | else { |
||
6830 | trEl.prepend(introHtml); |
||
6831 | } |
||
6832 | } |
||
6833 | } |
||
6834 | |||
6835 | }; |
||
6836 | |||
6837 | ;; |
||
6838 | |||
6839 | /* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week. |
||
6840 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
6841 | |||
6842 | var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, { |
||
6843 | |||
6844 | numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal |
||
6845 | bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid |
||
6846 | |||
6847 | rowEls: null, // set of fake row elements |
||
6848 | cellEls: null, // set of whole-day elements comprising the row's background |
||
6849 | helperEls: null, // set of cell skeleton elements for rendering the mock event "helper" |
||
6850 | |||
6851 | rowCoordCache: null, |
||
6852 | colCoordCache: null, |
||
6853 | |||
6854 | |||
6855 | // Renders the rows and columns into the component's `this.el`, which should already be assigned. |
||
6856 | // isRigid determins whether the individual rows should ignore the contents and be a constant height. |
||
6857 | // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient. |
||
6858 | renderDates: function(isRigid) { |
||
6859 | var view = this.view; |
||
6860 | var rowCnt = this.rowCnt; |
||
6861 | var colCnt = this.colCnt; |
||
6862 | var html = ''; |
||
6863 | var row; |
||
6864 | var col; |
||
6865 | |||
6866 | for (row = 0; row < rowCnt; row++) { |
||
6867 | html += this.renderDayRowHtml(row, isRigid); |
||
6868 | } |
||
6869 | this.el.html(html); |
||
6870 | |||
6871 | this.rowEls = this.el.find('.fc-row'); |
||
6872 | this.cellEls = this.el.find('.fc-day, .fc-disabled-day'); |
||
6873 | |||
6874 | this.rowCoordCache = new CoordCache({ |
||
6875 | els: this.rowEls, |
||
6876 | isVertical: true |
||
6877 | }); |
||
6878 | this.colCoordCache = new CoordCache({ |
||
6879 | els: this.cellEls.slice(0, this.colCnt), // only the first row |
||
6880 | isHorizontal: true |
||
6881 | }); |
||
6882 | |||
6883 | // trigger dayRender with each cell's element |
||
6884 | for (row = 0; row < rowCnt; row++) { |
||
6885 | for (col = 0; col < colCnt; col++) { |
||
6886 | view.publiclyTrigger( |
||
6887 | 'dayRender', |
||
6888 | null, |
||
6889 | this.getCellDate(row, col), |
||
6890 | this.getCellEl(row, col) |
||
6891 | ); |
||
6892 | } |
||
6893 | } |
||
6894 | }, |
||
6895 | |||
6896 | |||
6897 | unrenderDates: function() { |
||
6898 | this.removeSegPopover(); |
||
6899 | }, |
||
6900 | |||
6901 | |||
6902 | renderBusinessHours: function() { |
||
6903 | var segs = this.buildBusinessHourSegs(true); // wholeDay=true |
||
6904 | this.renderFill('businessHours', segs, 'bgevent'); |
||
6905 | }, |
||
6906 | |||
6907 | |||
6908 | unrenderBusinessHours: function() { |
||
6909 | this.unrenderFill('businessHours'); |
||
6910 | }, |
||
6911 | |||
6912 | |||
6913 | // Generates the HTML for a single row, which is a div that wraps a table. |
||
6914 | // `row` is the row number. |
||
6915 | renderDayRowHtml: function(row, isRigid) { |
||
6916 | var view = this.view; |
||
6917 | var classes = [ 'fc-row', 'fc-week', view.widgetContentClass ]; |
||
6918 | |||
6919 | if (isRigid) { |
||
6920 | classes.push('fc-rigid'); |
||
6921 | } |
||
6922 | |||
6923 | return '' + |
||
6924 | '<div class="' + classes.join(' ') + '">' + |
||
6925 | '<div class="fc-bg">' + |
||
6926 | '<table>' + |
||
6927 | this.renderBgTrHtml(row) + |
||
6928 | '</table>' + |
||
6929 | '</div>' + |
||
6930 | '<div class="fc-content-skeleton">' + |
||
6931 | '<table>' + |
||
6932 | (this.numbersVisible ? |
||
6933 | '<thead>' + |
||
6934 | this.renderNumberTrHtml(row) + |
||
6935 | '</thead>' : |
||
6936 | '' |
||
6937 | ) + |
||
6938 | '</table>' + |
||
6939 | '</div>' + |
||
6940 | '</div>'; |
||
6941 | }, |
||
6942 | |||
6943 | |||
6944 | /* Grid Number Rendering |
||
6945 | ------------------------------------------------------------------------------------------------------------------*/ |
||
6946 | |||
6947 | |||
6948 | renderNumberTrHtml: function(row) { |
||
6949 | return '' + |
||
6950 | '<tr>' + |
||
6951 | (this.isRTL ? '' : this.renderNumberIntroHtml(row)) + |
||
6952 | this.renderNumberCellsHtml(row) + |
||
6953 | (this.isRTL ? this.renderNumberIntroHtml(row) : '') + |
||
6954 | '</tr>'; |
||
6955 | }, |
||
6956 | |||
6957 | |||
6958 | renderNumberIntroHtml: function(row) { |
||
6959 | return this.renderIntroHtml(); |
||
6960 | }, |
||
6961 | |||
6962 | |||
6963 | renderNumberCellsHtml: function(row) { |
||
6964 | var htmls = []; |
||
6965 | var col, date; |
||
6966 | |||
6967 | for (col = 0; col < this.colCnt; col++) { |
||
6968 | date = this.getCellDate(row, col); |
||
6969 | htmls.push(this.renderNumberCellHtml(date)); |
||
6970 | } |
||
6971 | |||
6972 | return htmls.join(''); |
||
6973 | }, |
||
6974 | |||
6975 | |||
6976 | // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton. |
||
6977 | // The number row will only exist if either day numbers or week numbers are turned on. |
||
6978 | renderNumberCellHtml: function(date) { |
||
6979 | var view = this.view; |
||
6980 | var html = ''; |
||
6981 | var isDateValid = isDateWithinRange(date, view.activeRange); // TODO: called too frequently. cache somehow. |
||
6982 | var isDayNumberVisible = view.dayNumbersVisible && isDateValid; |
||
6983 | var classes; |
||
6984 | var weekCalcFirstDoW; |
||
6985 | |||
6986 | if (!isDayNumberVisible && !view.cellWeekNumbersVisible) { |
||
6987 | // no numbers in day cell (week number must be along the side) |
||
6988 | return '<td/>'; // will create an empty space above events :( |
||
6989 | } |
||
6990 | |||
6991 | classes = this.getDayClasses(date); |
||
6992 | classes.unshift('fc-day-top'); |
||
6993 | |||
6994 | if (view.cellWeekNumbersVisible) { |
||
6995 | // To determine the day of week number change under ISO, we cannot |
||
6996 | // rely on moment.js methods such as firstDayOfWeek() or weekday(), |
||
6997 | // because they rely on the locale's dow (possibly overridden by |
||
6998 | // our firstDay option), which may not be Monday. We cannot change |
||
6999 | // dow, because that would affect the calendar start day as well. |
||
7000 | if (date._locale._fullCalendar_weekCalc === 'ISO') { |
||
7001 | weekCalcFirstDoW = 1; // Monday by ISO 8601 definition |
||
7002 | } |
||
7003 | else { |
||
7004 | weekCalcFirstDoW = date._locale.firstDayOfWeek(); |
||
7005 | } |
||
7006 | } |
||
7007 | |||
7008 | html += '<td class="' + classes.join(' ') + '"' + |
||
7009 | (isDateValid ? |
||
7010 | ' data-date="' + date.format() + '"' : |
||
7011 | '' |
||
7012 | ) + |
||
7013 | '>'; |
||
7014 | |||
7015 | if (view.cellWeekNumbersVisible && (date.day() == weekCalcFirstDoW)) { |
||
7016 | html += view.buildGotoAnchorHtml( |
||
7017 | { date: date, type: 'week' }, |
||
7018 | { 'class': 'fc-week-number' }, |
||
7019 | date.format('w') // inner HTML |
||
7020 | ); |
||
7021 | } |
||
7022 | |||
7023 | if (isDayNumberVisible) { |
||
7024 | html += view.buildGotoAnchorHtml( |
||
7025 | date, |
||
7026 | { 'class': 'fc-day-number' }, |
||
7027 | date.date() // inner HTML |
||
7028 | ); |
||
7029 | } |
||
7030 | |||
7031 | html += '</td>'; |
||
7032 | |||
7033 | return html; |
||
7034 | }, |
||
7035 | |||
7036 | |||
7037 | /* Options |
||
7038 | ------------------------------------------------------------------------------------------------------------------*/ |
||
7039 | |||
7040 | |||
7041 | // Computes a default event time formatting string if `timeFormat` is not explicitly defined |
||
7042 | computeEventTimeFormat: function() { |
||
7043 | return this.view.opt('extraSmallTimeFormat'); // like "6p" or "6:30p" |
||
7044 | }, |
||
7045 | |||
7046 | |||
7047 | // Computes a default `displayEventEnd` value if one is not expliclty defined |
||
7048 | computeDisplayEventEnd: function() { |
||
7049 | return this.colCnt == 1; // we'll likely have space if there's only one day |
||
7050 | }, |
||
7051 | |||
7052 | |||
7053 | /* Dates |
||
7054 | ------------------------------------------------------------------------------------------------------------------*/ |
||
7055 | |||
7056 | |||
7057 | rangeUpdated: function() { |
||
7058 | this.updateDayTable(); |
||
7059 | }, |
||
7060 | |||
7061 | |||
7062 | // Slices up the given span (unzoned start/end with other misc data) into an array of segments |
||
7063 | spanToSegs: function(span) { |
||
7064 | var segs = this.sliceRangeByRow(span); |
||
7065 | var i, seg; |
||
7066 | |||
7067 | for (i = 0; i < segs.length; i++) { |
||
7068 | seg = segs[i]; |
||
7069 | if (this.isRTL) { |
||
7070 | seg.leftCol = this.daysPerRow - 1 - seg.lastRowDayIndex; |
||
7071 | seg.rightCol = this.daysPerRow - 1 - seg.firstRowDayIndex; |
||
7072 | } |
||
7073 | else { |
||
7074 | seg.leftCol = seg.firstRowDayIndex; |
||
7075 | seg.rightCol = seg.lastRowDayIndex; |
||
7076 | } |
||
7077 | } |
||
7078 | |||
7079 | return segs; |
||
7080 | }, |
||
7081 | |||
7082 | |||
7083 | /* Hit System |
||
7084 | ------------------------------------------------------------------------------------------------------------------*/ |
||
7085 | |||
7086 | |||
7087 | prepareHits: function() { |
||
7088 | this.colCoordCache.build(); |
||
7089 | this.rowCoordCache.build(); |
||
7090 | this.rowCoordCache.bottoms[this.rowCnt - 1] += this.bottomCoordPadding; // hack |
||
7091 | }, |
||
7092 | |||
7093 | |||
7094 | releaseHits: function() { |
||
7095 | this.colCoordCache.clear(); |
||
7096 | this.rowCoordCache.clear(); |
||
7097 | }, |
||
7098 | |||
7099 | |||
7100 | queryHit: function(leftOffset, topOffset) { |
||
7101 | if (this.colCoordCache.isLeftInBounds(leftOffset) && this.rowCoordCache.isTopInBounds(topOffset)) { |
||
7102 | var col = this.colCoordCache.getHorizontalIndex(leftOffset); |
||
7103 | var row = this.rowCoordCache.getVerticalIndex(topOffset); |
||
7104 | |||
7105 | if (row != null && col != null) { |
||
7106 | return this.getCellHit(row, col); |
||
7107 | } |
||
7108 | } |
||
7109 | }, |
||
7110 | |||
7111 | |||
7112 | getHitSpan: function(hit) { |
||
7113 | return this.getCellRange(hit.row, hit.col); |
||
7114 | }, |
||
7115 | |||
7116 | |||
7117 | getHitEl: function(hit) { |
||
7118 | return this.getCellEl(hit.row, hit.col); |
||
7119 | }, |
||
7120 | |||
7121 | |||
7122 | /* Cell System |
||
7123 | ------------------------------------------------------------------------------------------------------------------*/ |
||
7124 | // FYI: the first column is the leftmost column, regardless of date |
||
7125 | |||
7126 | |||
7127 | getCellHit: function(row, col) { |
||
7128 | return { |
||
7129 | row: row, |
||
7130 | col: col, |
||
7131 | component: this, // needed unfortunately :( |
||
7132 | left: this.colCoordCache.getLeftOffset(col), |
||
7133 | right: this.colCoordCache.getRightOffset(col), |
||
7134 | top: this.rowCoordCache.getTopOffset(row), |
||
7135 | bottom: this.rowCoordCache.getBottomOffset(row) |
||
7136 | }; |
||
7137 | }, |
||
7138 | |||
7139 | |||
7140 | getCellEl: function(row, col) { |
||
7141 | return this.cellEls.eq(row * this.colCnt + col); |
||
7142 | }, |
||
7143 | |||
7144 | |||
7145 | /* Event Drag Visualization |
||
7146 | ------------------------------------------------------------------------------------------------------------------*/ |
||
7147 | // TODO: move to DayGrid.event, similar to what we did with Grid's drag methods |
||
7148 | |||
7149 | |||
7150 | // Renders a visual indication of an event or external element being dragged. |
||
7151 | // `eventLocation` has zoned start and end (optional) |
||
7152 | renderDrag: function(eventLocation, seg) { |
||
7153 | var eventSpans = this.eventToSpans(eventLocation); |
||
7154 | var i; |
||
7155 | |||
7156 | // always render a highlight underneath |
||
7157 | for (i = 0; i < eventSpans.length; i++) { |
||
7158 | this.renderHighlight(eventSpans[i]); |
||
7159 | } |
||
7160 | |||
7161 | // if a segment from the same calendar but another component is being dragged, render a helper event |
||
7162 | if (seg && seg.component !== this) { |
||
7163 | return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements |
||
7164 | } |
||
7165 | }, |
||
7166 | |||
7167 | |||
7168 | // Unrenders any visual indication of a hovering event |
||
7169 | unrenderDrag: function() { |
||
7170 | this.unrenderHighlight(); |
||
7171 | this.unrenderHelper(); |
||
7172 | }, |
||
7173 | |||
7174 | |||
7175 | /* Event Resize Visualization |
||
7176 | ------------------------------------------------------------------------------------------------------------------*/ |
||
7177 | |||
7178 | |||
7179 | // Renders a visual indication of an event being resized |
||
7180 | renderEventResize: function(eventLocation, seg) { |
||
7181 | var eventSpans = this.eventToSpans(eventLocation); |
||
7182 | var i; |
||
7183 | |||
7184 | for (i = 0; i < eventSpans.length; i++) { |
||
7185 | this.renderHighlight(eventSpans[i]); |
||
7186 | } |
||
7187 | |||
7188 | return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements |
||
7189 | }, |
||
7190 | |||
7191 | |||
7192 | // Unrenders a visual indication of an event being resized |
||
7193 | unrenderEventResize: function() { |
||
7194 | this.unrenderHighlight(); |
||
7195 | this.unrenderHelper(); |
||
7196 | }, |
||
7197 | |||
7198 | |||
7199 | /* Event Helper |
||
7200 | ------------------------------------------------------------------------------------------------------------------*/ |
||
7201 | |||
7202 | |||
7203 | // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null. |
||
7204 | renderHelper: function(event, sourceSeg) { |
||
7205 | var helperNodes = []; |
||
7206 | var segs = this.eventToSegs(event); |
||
7207 | var rowStructs; |
||
7208 | |||
7209 | segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered |
||
7210 | rowStructs = this.renderSegRows(segs); |
||
7211 | |||
7212 | // inject each new event skeleton into each associated row |
||
7213 | this.rowEls.each(function(row, rowNode) { |
||
7214 | var rowEl = $(rowNode); // the .fc-row |
||
7215 | var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned |
||
7216 | var skeletonTop; |
||
7217 | |||
7218 | // If there is an original segment, match the top position. Otherwise, put it at the row's top level |
||
7219 | if (sourceSeg && sourceSeg.row === row) { |
||
7220 | skeletonTop = sourceSeg.el.position().top; |
||
7221 | } |
||
7222 | else { |
||
7223 | skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top; |
||
7224 | } |
||
7225 | |||
7226 | skeletonEl.css('top', skeletonTop) |
||
7227 | .find('table') |
||
7228 | .append(rowStructs[row].tbodyEl); |
||
7229 | |||
7230 | rowEl.append(skeletonEl); |
||
7231 | helperNodes.push(skeletonEl[0]); |
||
7232 | }); |
||
7233 | |||
7234 | return ( // must return the elements rendered |
||
7235 | this.helperEls = $(helperNodes) // array -> jQuery set |
||
7236 | ); |
||
7237 | }, |
||
7238 | |||
7239 | |||
7240 | // Unrenders any visual indication of a mock helper event |
||
7241 | unrenderHelper: function() { |
||
7242 | if (this.helperEls) { |
||
7243 | this.helperEls.remove(); |
||
7244 | this.helperEls = null; |
||
7245 | } |
||
7246 | }, |
||
7247 | |||
7248 | |||
7249 | /* Fill System (highlight, background events, business hours) |
||
7250 | ------------------------------------------------------------------------------------------------------------------*/ |
||
7251 | |||
7252 | |||
7253 | fillSegTag: 'td', // override the default tag name |
||
7254 | |||
7255 | |||
7256 | // Renders a set of rectangles over the given segments of days. |
||
7257 | // Only returns segments that successfully rendered. |
||
7258 | renderFill: function(type, segs, className) { |
||
7259 | var nodes = []; |
||
7260 | var i, seg; |
||
7261 | var skeletonEl; |
||
7262 | |||
7263 | segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs |
||
7264 | |||
7265 | for (i = 0; i < segs.length; i++) { |
||
7266 | seg = segs[i]; |
||
7267 | skeletonEl = this.renderFillRow(type, seg, className); |
||
7268 | this.rowEls.eq(seg.row).append(skeletonEl); |
||
7269 | nodes.push(skeletonEl[0]); |
||
7270 | } |
||
7271 | |||
7272 | this.elsByFill[type] = $(nodes); |
||
7273 | |||
7274 | return segs; |
||
7275 | }, |
||
7276 | |||
7277 | |||
7278 | // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered. |
||
7279 | renderFillRow: function(type, seg, className) { |
||
7280 | var colCnt = this.colCnt; |
||
7281 | var startCol = seg.leftCol; |
||
7282 | var endCol = seg.rightCol + 1; |
||
7283 | var skeletonEl; |
||
7284 | var trEl; |
||
7285 | |||
7286 | className = className || type.toLowerCase(); |
||
7287 | |||
7288 | skeletonEl = $( |
||
7289 | '<div class="fc-' + className + '-skeleton">' + |
||
7290 | '<table><tr/></table>' + |
||
7291 | '</div>' |
||
7292 | ); |
||
7293 | trEl = skeletonEl.find('tr'); |
||
7294 | |||
7295 | if (startCol > 0) { |
||
7296 | trEl.append('<td colspan="' + startCol + '"/>'); |
||
7297 | } |
||
7298 | |||
7299 | trEl.append( |
||
7300 | seg.el.attr('colspan', endCol - startCol) |
||
7301 | ); |
||
7302 | |||
7303 | if (endCol < colCnt) { |
||
7304 | trEl.append('<td colspan="' + (colCnt - endCol) + '"/>'); |
||
7305 | } |
||
7306 | |||
7307 | this.bookendCells(trEl); |
||
7308 | |||
7309 | return skeletonEl; |
||
7310 | } |
||
7311 | |||
7312 | }); |
||
7313 | |||
7314 | ;; |
||
7315 | |||
7316 | /* Event-rendering methods for the DayGrid class |
||
7317 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
7318 | |||
7319 | DayGrid.mixin({ |
||
7320 | |||
7321 | rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering |
||
7322 | |||
7323 | |||
7324 | // Unrenders all events currently rendered on the grid |
||
7325 | unrenderEvents: function() { |
||
7326 | this.removeSegPopover(); // removes the "more.." events popover |
||
7327 | Grid.prototype.unrenderEvents.apply(this, arguments); // calls the super-method |
||
7328 | }, |
||
7329 | |||
7330 | |||
7331 | // Retrieves all rendered segment objects currently rendered on the grid |
||
7332 | getEventSegs: function() { |
||
7333 | return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method |
||
7334 | .concat(this.popoverSegs || []); // append the segments from the "more..." popover |
||
7335 | }, |
||
7336 | |||
7337 | |||
7338 | // Renders the given background event segments onto the grid |
||
7339 | renderBgSegs: function(segs) { |
||
7340 | |||
7341 | // don't render timed background events |
||
7342 | var allDaySegs = $.grep(segs, function(seg) { |
||
7343 | return seg.event.allDay; |
||
7344 | }); |
||
7345 | |||
7346 | return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method |
||
7347 | }, |
||
7348 | |||
7349 | |||
7350 | // Renders the given foreground event segments onto the grid |
||
7351 | renderFgSegs: function(segs) { |
||
7352 | var rowStructs; |
||
7353 | |||
7354 | // render an `.el` on each seg |
||
7355 | // returns a subset of the segs. segs that were actually rendered |
||
7356 | segs = this.renderFgSegEls(segs); |
||
7357 | |||
7358 | rowStructs = this.rowStructs = this.renderSegRows(segs); |
||
7359 | |||
7360 | // append to each row's content skeleton |
||
7361 | this.rowEls.each(function(i, rowNode) { |
||
7362 | $(rowNode).find('.fc-content-skeleton > table').append( |
||
7363 | rowStructs[i].tbodyEl |
||
7364 | ); |
||
7365 | }); |
||
7366 | |||
7367 | return segs; // return only the segs that were actually rendered |
||
7368 | }, |
||
7369 | |||
7370 | |||
7371 | // Unrenders all currently rendered foreground event segments |
||
7372 | unrenderFgSegs: function() { |
||
7373 | var rowStructs = this.rowStructs || []; |
||
7374 | var rowStruct; |
||
7375 | |||
7376 | while ((rowStruct = rowStructs.pop())) { |
||
7377 | rowStruct.tbodyEl.remove(); |
||
7378 | } |
||
7379 | |||
7380 | this.rowStructs = null; |
||
7381 | }, |
||
7382 | |||
7383 | |||
7384 | // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton. |
||
7385 | // Returns an array of rowStruct objects (see the bottom of `renderSegRow`). |
||
7386 | // PRECONDITION: each segment shoud already have a rendered and assigned `.el` |
||
7387 | renderSegRows: function(segs) { |
||
7388 | var rowStructs = []; |
||
7389 | var segRows; |
||
7390 | var row; |
||
7391 | |||
7392 | segRows = this.groupSegRows(segs); // group into nested arrays |
||
7393 | |||
7394 | // iterate each row of segment groupings |
||
7395 | for (row = 0; row < segRows.length; row++) { |
||
7396 | rowStructs.push( |
||
7397 | this.renderSegRow(row, segRows[row]) |
||
7398 | ); |
||
7399 | } |
||
7400 | |||
7401 | return rowStructs; |
||
7402 | }, |
||
7403 | |||
7404 | |||
7405 | // Builds the HTML to be used for the default element for an individual segment |
||
7406 | fgSegHtml: function(seg, disableResizing) { |
||
7407 | var view = this.view; |
||
7408 | var event = seg.event; |
||
7409 | var isDraggable = view.isEventDraggable(event); |
||
7410 | var isResizableFromStart = !disableResizing && event.allDay && |
||
7411 | seg.isStart && view.isEventResizableFromStart(event); |
||
7412 | var isResizableFromEnd = !disableResizing && event.allDay && |
||
7413 | seg.isEnd && view.isEventResizableFromEnd(event); |
||
7414 | var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); |
||
7415 | var skinCss = cssToStr(this.getSegSkinCss(seg)); |
||
7416 | var timeHtml = ''; |
||
7417 | var timeText; |
||
7418 | var titleHtml; |
||
7419 | |||
7420 | classes.unshift('fc-day-grid-event', 'fc-h-event'); |
||
7421 | |||
7422 | // Only display a timed events time if it is the starting segment |
||
7423 | if (seg.isStart) { |
||
7424 | timeText = this.getEventTimeText(event); |
||
7425 | if (timeText) { |
||
7426 | timeHtml = '<span class="fc-time">' + htmlEscape(timeText) + '</span>'; |
||
7427 | } |
||
7428 | } |
||
7429 | |||
7430 | titleHtml = |
||
7431 | '<span class="fc-title">' + |
||
7432 | (htmlEscape(event.title || '') || ' ') + // we always want one line of height |
||
7433 | '</span>'; |
||
7434 | |||
7435 | return '<a class="' + classes.join(' ') + '"' + |
||
7436 | (event.url ? |
||
7437 | ' href="' + htmlEscape(event.url) + '"' : |
||
7438 | '' |
||
7439 | ) + |
||
7440 | (skinCss ? |
||
7441 | ' style="' + skinCss + '"' : |
||
7442 | '' |
||
7443 | ) + |
||
7444 | '>' + |
||
7445 | '<div class="fc-content">' + |
||
7446 | (this.isRTL ? |
||
7447 | titleHtml + ' ' + timeHtml : // put a natural space in between |
||
7448 | timeHtml + ' ' + titleHtml // |
||
7449 | ) + |
||
7450 | '</div>' + |
||
7451 | (isResizableFromStart ? |
||
7452 | '<div class="fc-resizer fc-start-resizer" />' : |
||
7453 | '' |
||
7454 | ) + |
||
7455 | (isResizableFromEnd ? |
||
7456 | '<div class="fc-resizer fc-end-resizer" />' : |
||
7457 | '' |
||
7458 | ) + |
||
7459 | '</a>'; |
||
7460 | }, |
||
7461 | |||
7462 | |||
7463 | // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains |
||
7464 | // the segments. Returns object with a bunch of internal data about how the render was calculated. |
||
7465 | // NOTE: modifies rowSegs |
||
7466 | renderSegRow: function(row, rowSegs) { |
||
7467 | var colCnt = this.colCnt; |
||
7468 | var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels |
||
7469 | var levelCnt = Math.max(1, segLevels.length); // ensure at least one level |
||
7470 | var tbody = $('<tbody/>'); |
||
7471 | var segMatrix = []; // lookup for which segments are rendered into which level+col cells |
||
7472 | var cellMatrix = []; // lookup for all <td> elements of the level+col matrix |
||
7473 | var loneCellMatrix = []; // lookup for <td> elements that only take up a single column |
||
7474 | var i, levelSegs; |
||
7475 | var col; |
||
7476 | var tr; |
||
7477 | var j, seg; |
||
7478 | var td; |
||
7479 | |||
7480 | // populates empty cells from the current column (`col`) to `endCol` |
||
7481 | function emptyCellsUntil(endCol) { |
||
7482 | while (col < endCol) { |
||
7483 | // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell |
||
7484 | td = (loneCellMatrix[i - 1] || [])[col]; |
||
7485 | if (td) { |
||
7486 | td.attr( |
||
7487 | 'rowspan', |
||
7488 | parseInt(td.attr('rowspan') || 1, 10) + 1 |
||
7489 | ); |
||
7490 | } |
||
7491 | else { |
||
7492 | td = $('<td/>'); |
||
7493 | tr.append(td); |
||
7494 | } |
||
7495 | cellMatrix[i][col] = td; |
||
7496 | loneCellMatrix[i][col] = td; |
||
7497 | col++; |
||
7498 | } |
||
7499 | } |
||
7500 | |||
7501 | for (i = 0; i < levelCnt; i++) { // iterate through all levels |
||
7502 | levelSegs = segLevels[i]; |
||
7503 | col = 0; |
||
7504 | tr = $('<tr/>'); |
||
7505 | |||
7506 | segMatrix.push([]); |
||
7507 | cellMatrix.push([]); |
||
7508 | loneCellMatrix.push([]); |
||
7509 | |||
7510 | // levelCnt might be 1 even though there are no actual levels. protect against this. |
||
7511 | // this single empty row is useful for styling. |
||
7512 | if (levelSegs) { |
||
7513 | for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level |
||
7514 | seg = levelSegs[j]; |
||
7515 | |||
7516 | emptyCellsUntil(seg.leftCol); |
||
7517 | |||
7518 | // create a container that occupies or more columns. append the event element. |
||
7519 | td = $('<td class="fc-event-container"/>').append(seg.el); |
||
7520 | if (seg.leftCol != seg.rightCol) { |
||
7521 | td.attr('colspan', seg.rightCol - seg.leftCol + 1); |
||
7522 | } |
||
7523 | else { // a single-column segment |
||
7524 | loneCellMatrix[i][col] = td; |
||
7525 | } |
||
7526 | |||
7527 | while (col <= seg.rightCol) { |
||
7528 | cellMatrix[i][col] = td; |
||
7529 | segMatrix[i][col] = seg; |
||
7530 | col++; |
||
7531 | } |
||
7532 | |||
7533 | tr.append(td); |
||
7534 | } |
||
7535 | } |
||
7536 | |||
7537 | emptyCellsUntil(colCnt); // finish off the row |
||
7538 | this.bookendCells(tr); |
||
7539 | tbody.append(tr); |
||
7540 | } |
||
7541 | |||
7542 | return { // a "rowStruct" |
||
7543 | row: row, // the row number |
||
7544 | tbodyEl: tbody, |
||
7545 | cellMatrix: cellMatrix, |
||
7546 | segMatrix: segMatrix, |
||
7547 | segLevels: segLevels, |
||
7548 | segs: rowSegs |
||
7549 | }; |
||
7550 | }, |
||
7551 | |||
7552 | |||
7553 | // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels. |
||
7554 | // NOTE: modifies segs |
||
7555 | buildSegLevels: function(segs) { |
||
7556 | var levels = []; |
||
7557 | var i, seg; |
||
7558 | var j; |
||
7559 | |||
7560 | // Give preference to elements with certain criteria, so they have |
||
7561 | // a chance to be closer to the top. |
||
7562 | this.sortEventSegs(segs); |
||
7563 | |||
7564 | for (i = 0; i < segs.length; i++) { |
||
7565 | seg = segs[i]; |
||
7566 | |||
7567 | // loop through levels, starting with the topmost, until the segment doesn't collide with other segments |
||
7568 | for (j = 0; j < levels.length; j++) { |
||
7569 | if (!isDaySegCollision(seg, levels[j])) { |
||
7570 | break; |
||
7571 | } |
||
7572 | } |
||
7573 | // `j` now holds the desired subrow index |
||
7574 | seg.level = j; |
||
7575 | |||
7576 | // create new level array if needed and append segment |
||
7577 | (levels[j] || (levels[j] = [])).push(seg); |
||
7578 | } |
||
7579 | |||
7580 | // order segments left-to-right. very important if calendar is RTL |
||
7581 | for (j = 0; j < levels.length; j++) { |
||
7582 | levels[j].sort(compareDaySegCols); |
||
7583 | } |
||
7584 | |||
7585 | return levels; |
||
7586 | }, |
||
7587 | |||
7588 | |||
7589 | // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row |
||
7590 | groupSegRows: function(segs) { |
||
7591 | var segRows = []; |
||
7592 | var i; |
||
7593 | |||
7594 | for (i = 0; i < this.rowCnt; i++) { |
||
7595 | segRows.push([]); |
||
7596 | } |
||
7597 | |||
7598 | for (i = 0; i < segs.length; i++) { |
||
7599 | segRows[segs[i].row].push(segs[i]); |
||
7600 | } |
||
7601 | |||
7602 | return segRows; |
||
7603 | } |
||
7604 | |||
7605 | }); |
||
7606 | |||
7607 | |||
7608 | // Computes whether two segments' columns collide. They are assumed to be in the same row. |
||
7609 | function isDaySegCollision(seg, otherSegs) { |
||
7610 | var i, otherSeg; |
||
7611 | |||
7612 | for (i = 0; i < otherSegs.length; i++) { |
||
7613 | otherSeg = otherSegs[i]; |
||
7614 | |||
7615 | if ( |
||
7616 | otherSeg.leftCol <= seg.rightCol && |
||
7617 | otherSeg.rightCol >= seg.leftCol |
||
7618 | ) { |
||
7619 | return true; |
||
7620 | } |
||
7621 | } |
||
7622 | |||
7623 | return false; |
||
7624 | } |
||
7625 | |||
7626 | |||
7627 | // A cmp function for determining the leftmost event |
||
7628 | function compareDaySegCols(a, b) { |
||
7629 | return a.leftCol - b.leftCol; |
||
7630 | } |
||
7631 | |||
7632 | ;; |
||
7633 | |||
7634 | /* Methods relate to limiting the number events for a given day on a DayGrid |
||
7635 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
7636 | // NOTE: all the segs being passed around in here are foreground segs |
||
7637 | |||
7638 | DayGrid.mixin({ |
||
7639 | |||
7640 | segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible |
||
7641 | popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible |
||
7642 | |||
7643 | |||
7644 | removeSegPopover: function() { |
||
7645 | if (this.segPopover) { |
||
7646 | this.segPopover.hide(); // in handler, will call segPopover's removeElement |
||
7647 | } |
||
7648 | }, |
||
7649 | |||
7650 | |||
7651 | // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid. |
||
7652 | // `levelLimit` can be false (don't limit), a number, or true (should be computed). |
||
7653 | limitRows: function(levelLimit) { |
||
7654 | var rowStructs = this.rowStructs || []; |
||
7655 | var row; // row # |
||
7656 | var rowLevelLimit; |
||
7657 | |||
7658 | for (row = 0; row < rowStructs.length; row++) { |
||
7659 | this.unlimitRow(row); |
||
7660 | |||
7661 | if (!levelLimit) { |
||
7662 | rowLevelLimit = false; |
||
7663 | } |
||
7664 | else if (typeof levelLimit === 'number') { |
||
7665 | rowLevelLimit = levelLimit; |
||
7666 | } |
||
7667 | else { |
||
7668 | rowLevelLimit = this.computeRowLevelLimit(row); |
||
7669 | } |
||
7670 | |||
7671 | if (rowLevelLimit !== false) { |
||
7672 | this.limitRow(row, rowLevelLimit); |
||
7673 | } |
||
7674 | } |
||
7675 | }, |
||
7676 | |||
7677 | |||
7678 | // Computes the number of levels a row will accomodate without going outside its bounds. |
||
7679 | // Assumes the row is "rigid" (maintains a constant height regardless of what is inside). |
||
7680 | // `row` is the row number. |
||
7681 | computeRowLevelLimit: function(row) { |
||
7682 | var rowEl = this.rowEls.eq(row); // the containing "fake" row div |
||
7683 | var rowHeight = rowEl.height(); // TODO: cache somehow? |
||
7684 | var trEls = this.rowStructs[row].tbodyEl.children(); |
||
7685 | var i, trEl; |
||
7686 | var trHeight; |
||
7687 | |||
7688 | function iterInnerHeights(i, childNode) { |
||
7689 | trHeight = Math.max(trHeight, $(childNode).outerHeight()); |
||
7690 | } |
||
7691 | |||
7692 | // Reveal one level <tr> at a time and stop when we find one out of bounds |
||
7693 | for (i = 0; i < trEls.length; i++) { |
||
7694 | trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal) |
||
7695 | |||
7696 | // with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell, |
||
7697 | // so instead, find the tallest inner content element. |
||
7698 | trHeight = 0; |
||
7699 | trEl.find('> td > :first-child').each(iterInnerHeights); |
||
7700 | |||
7701 | if (trEl.position().top + trHeight > rowHeight) { |
||
7702 | return i; |
||
7703 | } |
||
7704 | } |
||
7705 | |||
7706 | return false; // should not limit at all |
||
7707 | }, |
||
7708 | |||
7709 | |||
7710 | // Limits the given grid row to the maximum number of levels and injects "more" links if necessary. |
||
7711 | // `row` is the row number. |
||
7712 | // `levelLimit` is a number for the maximum (inclusive) number of levels allowed. |
||
7713 | limitRow: function(row, levelLimit) { |
||
7714 | var _this = this; |
||
7715 | var rowStruct = this.rowStructs[row]; |
||
7716 | var moreNodes = []; // array of "more" <a> links and <td> DOM nodes |
||
7717 | var col = 0; // col #, left-to-right (not chronologically) |
||
7718 | var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right |
||
7719 | var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row |
||
7720 | var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes |
||
7721 | var i, seg; |
||
7722 | var segsBelow; // array of segment objects below `seg` in the current `col` |
||
7723 | var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies |
||
7724 | var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column) |
||
7725 | var td, rowspan; |
||
7726 | var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell |
||
7727 | var j; |
||
7728 | var moreTd, moreWrap, moreLink; |
||
7729 | |||
7730 | // Iterates through empty level cells and places "more" links inside if need be |
||
7731 | function emptyCellsUntil(endCol) { // goes from current `col` to `endCol` |
||
7732 | while (col < endCol) { |
||
7733 | segsBelow = _this.getCellSegs(row, col, levelLimit); |
||
7734 | if (segsBelow.length) { |
||
7735 | td = cellMatrix[levelLimit - 1][col]; |
||
7736 | moreLink = _this.renderMoreLink(row, col, segsBelow); |
||
7737 | moreWrap = $('<div/>').append(moreLink); |
||
7738 | td.append(moreWrap); |
||
7739 | moreNodes.push(moreWrap[0]); |
||
7740 | } |
||
7741 | col++; |
||
7742 | } |
||
7743 | } |
||
7744 | |||
7745 | if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit? |
||
7746 | levelSegs = rowStruct.segLevels[levelLimit - 1]; |
||
7747 | cellMatrix = rowStruct.cellMatrix; |
||
7748 | |||
7749 | limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit |
||
7750 | .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array |
||
7751 | |||
7752 | // iterate though segments in the last allowable level |
||
7753 | for (i = 0; i < levelSegs.length; i++) { |
||
7754 | seg = levelSegs[i]; |
||
7755 | emptyCellsUntil(seg.leftCol); // process empty cells before the segment |
||
7756 | |||
7757 | // determine *all* segments below `seg` that occupy the same columns |
||
7758 | colSegsBelow = []; |
||
7759 | totalSegsBelow = 0; |
||
7760 | while (col <= seg.rightCol) { |
||
7761 | segsBelow = this.getCellSegs(row, col, levelLimit); |
||
7762 | colSegsBelow.push(segsBelow); |
||
7763 | totalSegsBelow += segsBelow.length; |
||
7764 | col++; |
||
7765 | } |
||
7766 | |||
7767 | if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links? |
||
7768 | td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell |
||
7769 | rowspan = td.attr('rowspan') || 1; |
||
7770 | segMoreNodes = []; |
||
7771 | |||
7772 | // make a replacement <td> for each column the segment occupies. will be one for each colspan |
||
7773 | for (j = 0; j < colSegsBelow.length; j++) { |
||
7774 | moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan); |
||
7775 | segsBelow = colSegsBelow[j]; |
||
7776 | moreLink = this.renderMoreLink( |
||
7777 | row, |
||
7778 | seg.leftCol + j, |
||
7779 | [ seg ].concat(segsBelow) // count seg as hidden too |
||
7780 | ); |
||
7781 | moreWrap = $('<div/>').append(moreLink); |
||
7782 | moreTd.append(moreWrap); |
||
7783 | segMoreNodes.push(moreTd[0]); |
||
7784 | moreNodes.push(moreTd[0]); |
||
7785 | } |
||
7786 | |||
7787 | td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements |
||
7788 | limitedNodes.push(td[0]); |
||
7789 | } |
||
7790 | } |
||
7791 | |||
7792 | emptyCellsUntil(this.colCnt); // finish off the level |
||
7793 | rowStruct.moreEls = $(moreNodes); // for easy undoing later |
||
7794 | rowStruct.limitedEls = $(limitedNodes); // for easy undoing later |
||
7795 | } |
||
7796 | }, |
||
7797 | |||
7798 | |||
7799 | // Reveals all levels and removes all "more"-related elements for a grid's row. |
||
7800 | // `row` is a row number. |
||
7801 | unlimitRow: function(row) { |
||
7802 | var rowStruct = this.rowStructs[row]; |
||
7803 | |||
7804 | if (rowStruct.moreEls) { |
||
7805 | rowStruct.moreEls.remove(); |
||
7806 | rowStruct.moreEls = null; |
||
7807 | } |
||
7808 | |||
7809 | if (rowStruct.limitedEls) { |
||
7810 | rowStruct.limitedEls.removeClass('fc-limited'); |
||
7811 | rowStruct.limitedEls = null; |
||
7812 | } |
||
7813 | }, |
||
7814 | |||
7815 | |||
7816 | // Renders an <a> element that represents hidden event element for a cell. |
||
7817 | // Responsible for attaching click handler as well. |
||
7818 | renderMoreLink: function(row, col, hiddenSegs) { |
||
7819 | var _this = this; |
||
7820 | var view = this.view; |
||
7821 | |||
7822 | return $('<a class="fc-more"/>') |
||
7823 | .text( |
||
7824 | this.getMoreLinkText(hiddenSegs.length) |
||
7825 | ) |
||
7826 | .on('click', function(ev) { |
||
7827 | var clickOption = view.opt('eventLimitClick'); |
||
7828 | var date = _this.getCellDate(row, col); |
||
7829 | var moreEl = $(this); |
||
7830 | var dayEl = _this.getCellEl(row, col); |
||
7831 | var allSegs = _this.getCellSegs(row, col); |
||
7832 | |||
7833 | // rescope the segments to be within the cell's date |
||
7834 | var reslicedAllSegs = _this.resliceDaySegs(allSegs, date); |
||
7835 | var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date); |
||
7836 | |||
7837 | if (typeof clickOption === 'function') { |
||
7838 | // the returned value can be an atomic option |
||
7839 | clickOption = view.publiclyTrigger('eventLimitClick', null, { |
||
7840 | date: date, |
||
7841 | dayEl: dayEl, |
||
7842 | moreEl: moreEl, |
||
7843 | segs: reslicedAllSegs, |
||
7844 | hiddenSegs: reslicedHiddenSegs |
||
7845 | }, ev); |
||
7846 | } |
||
7847 | |||
7848 | if (clickOption === 'popover') { |
||
7849 | _this.showSegPopover(row, col, moreEl, reslicedAllSegs); |
||
7850 | } |
||
7851 | else if (typeof clickOption === 'string') { // a view name |
||
7852 | view.calendar.zoomTo(date, clickOption); |
||
7853 | } |
||
7854 | }); |
||
7855 | }, |
||
7856 | |||
7857 | |||
7858 | // Reveals the popover that displays all events within a cell |
||
7859 | showSegPopover: function(row, col, moreLink, segs) { |
||
7860 | var _this = this; |
||
7861 | var view = this.view; |
||
7862 | var moreWrap = moreLink.parent(); // the <div> wrapper around the <a> |
||
7863 | var topEl; // the element we want to match the top coordinate of |
||
7864 | var options; |
||
7865 | |||
7866 | if (this.rowCnt == 1) { |
||
7867 | topEl = view.el; // will cause the popover to cover any sort of header |
||
7868 | } |
||
7869 | else { |
||
7870 | topEl = this.rowEls.eq(row); // will align with top of row |
||
7871 | } |
||
7872 | |||
7873 | options = { |
||
7874 | className: 'fc-more-popover', |
||
7875 | content: this.renderSegPopoverContent(row, col, segs), |
||
7876 | parentEl: this.view.el, // attach to root of view. guarantees outside of scrollbars. |
||
7877 | top: topEl.offset().top, |
||
7878 | autoHide: true, // when the user clicks elsewhere, hide the popover |
||
7879 | viewportConstrain: view.opt('popoverViewportConstrain'), |
||
7880 | hide: function() { |
||
7881 | // kill everything when the popover is hidden |
||
7882 | // notify events to be removed |
||
7883 | if (_this.popoverSegs) { |
||
7884 | var seg; |
||
7885 | for (var i = 0; i < _this.popoverSegs.length; ++i) { |
||
7886 | seg = _this.popoverSegs[i]; |
||
7887 | view.publiclyTrigger('eventDestroy', seg.event, seg.event, seg.el); |
||
7888 | } |
||
7889 | } |
||
7890 | _this.segPopover.removeElement(); |
||
7891 | _this.segPopover = null; |
||
7892 | _this.popoverSegs = null; |
||
7893 | } |
||
7894 | }; |
||
7895 | |||
7896 | // Determine horizontal coordinate. |
||
7897 | // We use the moreWrap instead of the <td> to avoid border confusion. |
||
7898 | if (this.isRTL) { |
||
7899 | options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border |
||
7900 | } |
||
7901 | else { |
||
7902 | options.left = moreWrap.offset().left - 1; // -1 to be over cell border |
||
7903 | } |
||
7904 | |||
7905 | this.segPopover = new Popover(options); |
||
7906 | this.segPopover.show(); |
||
7907 | |||
7908 | // the popover doesn't live within the grid's container element, and thus won't get the event |
||
7909 | // delegated-handlers for free. attach event-related handlers to the popover. |
||
7910 | this.bindSegHandlersToEl(this.segPopover.el); |
||
7911 | }, |
||
7912 | |||
7913 | |||
7914 | // Builds the inner DOM contents of the segment popover |
||
7915 | renderSegPopoverContent: function(row, col, segs) { |
||
7916 | var view = this.view; |
||
7917 | var isTheme = view.opt('theme'); |
||
7918 | var title = this.getCellDate(row, col).format(view.opt('dayPopoverFormat')); |
||
7919 | var content = $( |
||
7920 | '<div class="fc-header ' + view.widgetHeaderClass + '">' + |
||
7921 | '<span class="fc-close ' + |
||
7922 | (isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') + |
||
7923 | '"></span>' + |
||
7924 | '<span class="fc-title">' + |
||
7925 | htmlEscape(title) + |
||
7926 | '</span>' + |
||
7927 | '<div class="fc-clear"/>' + |
||
7928 | '</div>' + |
||
7929 | '<div class="fc-body ' + view.widgetContentClass + '">' + |
||
7930 | '<div class="fc-event-container"></div>' + |
||
7931 | '</div>' |
||
7932 | ); |
||
7933 | var segContainer = content.find('.fc-event-container'); |
||
7934 | var i; |
||
7935 | |||
7936 | // render each seg's `el` and only return the visible segs |
||
7937 | segs = this.renderFgSegEls(segs, true); // disableResizing=true |
||
7938 | this.popoverSegs = segs; |
||
7939 | |||
7940 | for (i = 0; i < segs.length; i++) { |
||
7941 | |||
7942 | // because segments in the popover are not part of a grid coordinate system, provide a hint to any |
||
7943 | // grids that want to do drag-n-drop about which cell it came from |
||
7944 | this.hitsNeeded(); |
||
7945 | segs[i].hit = this.getCellHit(row, col); |
||
7946 | this.hitsNotNeeded(); |
||
7947 | |||
7948 | segContainer.append(segs[i].el); |
||
7949 | } |
||
7950 | |||
7951 | return content; |
||
7952 | }, |
||
7953 | |||
7954 | |||
7955 | // Given the events within an array of segment objects, reslice them to be in a single day |
||
7956 | resliceDaySegs: function(segs, dayDate) { |
||
7957 | |||
7958 | // build an array of the original events |
||
7959 | var events = $.map(segs, function(seg) { |
||
7960 | return seg.event; |
||
7961 | }); |
||
7962 | |||
7963 | var dayStart = dayDate.clone(); |
||
7964 | var dayEnd = dayStart.clone().add(1, 'days'); |
||
7965 | var dayRange = { start: dayStart, end: dayEnd }; |
||
7966 | |||
7967 | // slice the events with a custom slicing function |
||
7968 | segs = this.eventsToSegs( |
||
7969 | events, |
||
7970 | function(range) { |
||
7971 | var seg = intersectRanges(range, dayRange); // undefind if no intersection |
||
7972 | return seg ? [ seg ] : []; // must return an array of segments |
||
7973 | } |
||
7974 | ); |
||
7975 | |||
7976 | // force an order because eventsToSegs doesn't guarantee one |
||
7977 | this.sortEventSegs(segs); |
||
7978 | |||
7979 | return segs; |
||
7980 | }, |
||
7981 | |||
7982 | |||
7983 | // Generates the text that should be inside a "more" link, given the number of events it represents |
||
7984 | getMoreLinkText: function(num) { |
||
7985 | var opt = this.view.opt('eventLimitText'); |
||
7986 | |||
7987 | if (typeof opt === 'function') { |
||
7988 | return opt(num); |
||
7989 | } |
||
7990 | else { |
||
7991 | return '+' + num + ' ' + opt; |
||
7992 | } |
||
7993 | }, |
||
7994 | |||
7995 | |||
7996 | // Returns segments within a given cell. |
||
7997 | // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs. |
||
7998 | getCellSegs: function(row, col, startLevel) { |
||
7999 | var segMatrix = this.rowStructs[row].segMatrix; |
||
8000 | var level = startLevel || 0; |
||
8001 | var segs = []; |
||
8002 | var seg; |
||
8003 | |||
8004 | while (level < segMatrix.length) { |
||
8005 | seg = segMatrix[level][col]; |
||
8006 | if (seg) { |
||
8007 | segs.push(seg); |
||
8008 | } |
||
8009 | level++; |
||
8010 | } |
||
8011 | |||
8012 | return segs; |
||
8013 | } |
||
8014 | |||
8015 | }); |
||
8016 | |||
8017 | ;; |
||
8018 | |||
8019 | /* A component that renders one or more columns of vertical time slots |
||
8020 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
8021 | // We mixin DayTable, even though there is only a single row of days |
||
8022 | |||
8023 | var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, { |
||
8024 | |||
8025 | slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines |
||
8026 | snapDuration: null, // granularity of time for dragging and selecting |
||
8027 | snapsPerSlot: null, |
||
8028 | labelFormat: null, // formatting string for times running along vertical axis |
||
8029 | labelInterval: null, // duration of how often a label should be displayed for a slot |
||
8030 | |||
8031 | colEls: null, // cells elements in the day-row background |
||
8032 | slatContainerEl: null, // div that wraps all the slat rows |
||
8033 | slatEls: null, // elements running horizontally across all columns |
||
8034 | nowIndicatorEls: null, |
||
8035 | |||
8036 | colCoordCache: null, |
||
8037 | slatCoordCache: null, |
||
8038 | |||
8039 | |||
8040 | constructor: function() { |
||
8041 | Grid.apply(this, arguments); // call the super-constructor |
||
8042 | |||
8043 | this.processOptions(); |
||
8044 | }, |
||
8045 | |||
8046 | |||
8047 | // Renders the time grid into `this.el`, which should already be assigned. |
||
8048 | // Relies on the view's colCnt. In the future, this component should probably be self-sufficient. |
||
8049 | renderDates: function() { |
||
8050 | this.el.html(this.renderHtml()); |
||
8051 | this.colEls = this.el.find('.fc-day, .fc-disabled-day'); |
||
8052 | this.slatContainerEl = this.el.find('.fc-slats'); |
||
8053 | this.slatEls = this.slatContainerEl.find('tr'); |
||
8054 | |||
8055 | this.colCoordCache = new CoordCache({ |
||
8056 | els: this.colEls, |
||
8057 | isHorizontal: true |
||
8058 | }); |
||
8059 | this.slatCoordCache = new CoordCache({ |
||
8060 | els: this.slatEls, |
||
8061 | isVertical: true |
||
8062 | }); |
||
8063 | |||
8064 | this.renderContentSkeleton(); |
||
8065 | }, |
||
8066 | |||
8067 | |||
8068 | // Renders the basic HTML skeleton for the grid |
||
8069 | renderHtml: function() { |
||
8070 | return '' + |
||
8071 | '<div class="fc-bg">' + |
||
8072 | '<table>' + |
||
8073 | this.renderBgTrHtml(0) + // row=0 |
||
8074 | '</table>' + |
||
8075 | '</div>' + |
||
8076 | '<div class="fc-slats">' + |
||
8077 | '<table>' + |
||
8078 | this.renderSlatRowHtml() + |
||
8079 | '</table>' + |
||
8080 | '</div>'; |
||
8081 | }, |
||
8082 | |||
8083 | |||
8084 | // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL. |
||
8085 | renderSlatRowHtml: function() { |
||
8086 | var view = this.view; |
||
8087 | var isRTL = this.isRTL; |
||
8088 | var html = ''; |
||
8089 | var slotTime = moment.duration(+this.view.minTime); // wish there was .clone() for durations |
||
8090 | var slotDate; // will be on the view's first day, but we only care about its time |
||
8091 | var isLabeled; |
||
8092 | var axisHtml; |
||
8093 | |||
8094 | // Calculate the time for each slot |
||
8095 | while (slotTime < this.view.maxTime) { |
||
8096 | slotDate = this.start.clone().time(slotTime); |
||
8097 | isLabeled = isInt(divideDurationByDuration(slotTime, this.labelInterval)); |
||
8098 | |||
8099 | axisHtml = |
||
8100 | '<td class="fc-axis fc-time ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' + |
||
8101 | (isLabeled ? |
||
8102 | '<span>' + // for matchCellWidths |
||
8103 | htmlEscape(slotDate.format(this.labelFormat)) + |
||
8104 | '</span>' : |
||
8105 | '' |
||
8106 | ) + |
||
8107 | '</td>'; |
||
8108 | |||
8109 | html += |
||
8110 | '<tr data-time="' + slotDate.format('HH:mm:ss') + '"' + |
||
8111 | (isLabeled ? '' : ' class="fc-minor"') + |
||
8112 | '>' + |
||
8113 | (!isRTL ? axisHtml : '') + |
||
8114 | '<td class="' + view.widgetContentClass + '"/>' + |
||
8115 | (isRTL ? axisHtml : '') + |
||
8116 | "</tr>"; |
||
8117 | |||
8118 | slotTime.add(this.slotDuration); |
||
8119 | } |
||
8120 | |||
8121 | return html; |
||
8122 | }, |
||
8123 | |||
8124 | |||
8125 | /* Options |
||
8126 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8127 | |||
8128 | |||
8129 | // Parses various options into properties of this object |
||
8130 | processOptions: function() { |
||
8131 | var view = this.view; |
||
8132 | var slotDuration = view.opt('slotDuration'); |
||
8133 | var snapDuration = view.opt('snapDuration'); |
||
8134 | var input; |
||
8135 | |||
8136 | slotDuration = moment.duration(slotDuration); |
||
8137 | snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration; |
||
8138 | |||
8139 | this.slotDuration = slotDuration; |
||
8140 | this.snapDuration = snapDuration; |
||
8141 | this.snapsPerSlot = slotDuration / snapDuration; // TODO: ensure an integer multiple? |
||
8142 | |||
8143 | this.minResizeDuration = snapDuration; // hack |
||
8144 | |||
8145 | // might be an array value (for TimelineView). |
||
8146 | // if so, getting the most granular entry (the last one probably). |
||
8147 | input = view.opt('slotLabelFormat'); |
||
8148 | if ($.isArray(input)) { |
||
8149 | input = input[input.length - 1]; |
||
8150 | } |
||
8151 | |||
8152 | this.labelFormat = |
||
8153 | input || |
||
8154 | view.opt('smallTimeFormat'); // the computed default |
||
8155 | |||
8156 | input = view.opt('slotLabelInterval'); |
||
8157 | this.labelInterval = input ? |
||
8158 | moment.duration(input) : |
||
8159 | this.computeLabelInterval(slotDuration); |
||
8160 | }, |
||
8161 | |||
8162 | |||
8163 | // Computes an automatic value for slotLabelInterval |
||
8164 | computeLabelInterval: function(slotDuration) { |
||
8165 | var i; |
||
8166 | var labelInterval; |
||
8167 | var slotsPerLabel; |
||
8168 | |||
8169 | // find the smallest stock label interval that results in more than one slots-per-label |
||
8170 | for (i = AGENDA_STOCK_SUB_DURATIONS.length - 1; i >= 0; i--) { |
||
8171 | labelInterval = moment.duration(AGENDA_STOCK_SUB_DURATIONS[i]); |
||
8172 | slotsPerLabel = divideDurationByDuration(labelInterval, slotDuration); |
||
8173 | if (isInt(slotsPerLabel) && slotsPerLabel > 1) { |
||
8174 | return labelInterval; |
||
8175 | } |
||
8176 | } |
||
8177 | |||
8178 | return moment.duration(slotDuration); // fall back. clone |
||
8179 | }, |
||
8180 | |||
8181 | |||
8182 | // Computes a default event time formatting string if `timeFormat` is not explicitly defined |
||
8183 | computeEventTimeFormat: function() { |
||
8184 | return this.view.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM) |
||
8185 | }, |
||
8186 | |||
8187 | |||
8188 | // Computes a default `displayEventEnd` value if one is not expliclty defined |
||
8189 | computeDisplayEventEnd: function() { |
||
8190 | return true; |
||
8191 | }, |
||
8192 | |||
8193 | |||
8194 | /* Hit System |
||
8195 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8196 | |||
8197 | |||
8198 | prepareHits: function() { |
||
8199 | this.colCoordCache.build(); |
||
8200 | this.slatCoordCache.build(); |
||
8201 | }, |
||
8202 | |||
8203 | |||
8204 | releaseHits: function() { |
||
8205 | this.colCoordCache.clear(); |
||
8206 | // NOTE: don't clear slatCoordCache because we rely on it for computeTimeTop |
||
8207 | }, |
||
8208 | |||
8209 | |||
8210 | queryHit: function(leftOffset, topOffset) { |
||
8211 | var snapsPerSlot = this.snapsPerSlot; |
||
8212 | var colCoordCache = this.colCoordCache; |
||
8213 | var slatCoordCache = this.slatCoordCache; |
||
8214 | |||
8215 | if (colCoordCache.isLeftInBounds(leftOffset) && slatCoordCache.isTopInBounds(topOffset)) { |
||
8216 | var colIndex = colCoordCache.getHorizontalIndex(leftOffset); |
||
8217 | var slatIndex = slatCoordCache.getVerticalIndex(topOffset); |
||
8218 | |||
8219 | if (colIndex != null && slatIndex != null) { |
||
8220 | var slatTop = slatCoordCache.getTopOffset(slatIndex); |
||
8221 | var slatHeight = slatCoordCache.getHeight(slatIndex); |
||
8222 | var partial = (topOffset - slatTop) / slatHeight; // floating point number between 0 and 1 |
||
8223 | var localSnapIndex = Math.floor(partial * snapsPerSlot); // the snap # relative to start of slat |
||
8224 | var snapIndex = slatIndex * snapsPerSlot + localSnapIndex; |
||
8225 | var snapTop = slatTop + (localSnapIndex / snapsPerSlot) * slatHeight; |
||
8226 | var snapBottom = slatTop + ((localSnapIndex + 1) / snapsPerSlot) * slatHeight; |
||
8227 | |||
8228 | return { |
||
8229 | col: colIndex, |
||
8230 | snap: snapIndex, |
||
8231 | component: this, // needed unfortunately :( |
||
8232 | left: colCoordCache.getLeftOffset(colIndex), |
||
8233 | right: colCoordCache.getRightOffset(colIndex), |
||
8234 | top: snapTop, |
||
8235 | bottom: snapBottom |
||
8236 | }; |
||
8237 | } |
||
8238 | } |
||
8239 | }, |
||
8240 | |||
8241 | |||
8242 | getHitSpan: function(hit) { |
||
8243 | var start = this.getCellDate(0, hit.col); // row=0 |
||
8244 | var time = this.computeSnapTime(hit.snap); // pass in the snap-index |
||
8245 | var end; |
||
8246 | |||
8247 | start.time(time); |
||
8248 | end = start.clone().add(this.snapDuration); |
||
8249 | |||
8250 | return { start: start, end: end }; |
||
8251 | }, |
||
8252 | |||
8253 | |||
8254 | getHitEl: function(hit) { |
||
8255 | return this.colEls.eq(hit.col); |
||
8256 | }, |
||
8257 | |||
8258 | |||
8259 | /* Dates |
||
8260 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8261 | |||
8262 | |||
8263 | rangeUpdated: function() { |
||
8264 | this.updateDayTable(); |
||
8265 | }, |
||
8266 | |||
8267 | |||
8268 | // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day |
||
8269 | computeSnapTime: function(snapIndex) { |
||
8270 | return moment.duration(this.view.minTime + this.snapDuration * snapIndex); |
||
8271 | }, |
||
8272 | |||
8273 | |||
8274 | // Slices up the given span (unzoned start/end with other misc data) into an array of segments |
||
8275 | spanToSegs: function(span) { |
||
8276 | var segs = this.sliceRangeByTimes(span); |
||
8277 | var i; |
||
8278 | |||
8279 | for (i = 0; i < segs.length; i++) { |
||
8280 | if (this.isRTL) { |
||
8281 | segs[i].col = this.daysPerRow - 1 - segs[i].dayIndex; |
||
8282 | } |
||
8283 | else { |
||
8284 | segs[i].col = segs[i].dayIndex; |
||
8285 | } |
||
8286 | } |
||
8287 | |||
8288 | return segs; |
||
8289 | }, |
||
8290 | |||
8291 | |||
8292 | sliceRangeByTimes: function(range) { |
||
8293 | var segs = []; |
||
8294 | var seg; |
||
8295 | var dayIndex; |
||
8296 | var dayDate; |
||
8297 | var dayRange; |
||
8298 | |||
8299 | for (dayIndex = 0; dayIndex < this.daysPerRow; dayIndex++) { |
||
8300 | dayDate = this.dayDates[dayIndex].clone().time(0); // TODO: better API for this? |
||
8301 | dayRange = { |
||
8302 | start: dayDate.clone().add(this.view.minTime), // don't use .time() because it sux with negatives |
||
8303 | end: dayDate.clone().add(this.view.maxTime) |
||
8304 | }; |
||
8305 | seg = intersectRanges(range, dayRange); // both will be ambig timezone |
||
8306 | if (seg) { |
||
8307 | seg.dayIndex = dayIndex; |
||
8308 | segs.push(seg); |
||
8309 | } |
||
8310 | } |
||
8311 | |||
8312 | return segs; |
||
8313 | }, |
||
8314 | |||
8315 | |||
8316 | /* Coordinates |
||
8317 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8318 | |||
8319 | |||
8320 | updateSize: function(isResize) { // NOT a standard Grid method |
||
8321 | this.slatCoordCache.build(); |
||
8322 | |||
8323 | if (isResize) { |
||
8324 | this.updateSegVerticals( |
||
8325 | [].concat(this.fgSegs || [], this.bgSegs || [], this.businessSegs || []) |
||
8326 | ); |
||
8327 | } |
||
8328 | }, |
||
8329 | |||
8330 | |||
8331 | getTotalSlatHeight: function() { |
||
8332 | return this.slatContainerEl.outerHeight(); |
||
8333 | }, |
||
8334 | |||
8335 | |||
8336 | // Computes the top coordinate, relative to the bounds of the grid, of the given date. |
||
8337 | // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight. |
||
8338 | computeDateTop: function(date, startOfDayDate) { |
||
8339 | return this.computeTimeTop( |
||
8340 | moment.duration( |
||
8341 | date - startOfDayDate.clone().stripTime() |
||
8342 | ) |
||
8343 | ); |
||
8344 | }, |
||
8345 | |||
8346 | |||
8347 | // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration). |
||
8348 | computeTimeTop: function(time) { |
||
8349 | var len = this.slatEls.length; |
||
8350 | var slatCoverage = (time - this.view.minTime) / this.slotDuration; // floating-point value of # of slots covered |
||
8351 | var slatIndex; |
||
8352 | var slatRemainder; |
||
8353 | |||
8354 | // compute a floating-point number for how many slats should be progressed through. |
||
8355 | // from 0 to number of slats (inclusive) |
||
8356 | // constrained because minTime/maxTime might be customized. |
||
8357 | slatCoverage = Math.max(0, slatCoverage); |
||
8358 | slatCoverage = Math.min(len, slatCoverage); |
||
8359 | |||
8360 | // an integer index of the furthest whole slat |
||
8361 | // from 0 to number slats (*exclusive*, so len-1) |
||
8362 | slatIndex = Math.floor(slatCoverage); |
||
8363 | slatIndex = Math.min(slatIndex, len - 1); |
||
8364 | |||
8365 | // how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition. |
||
8366 | // could be 1.0 if slatCoverage is covering *all* the slots |
||
8367 | slatRemainder = slatCoverage - slatIndex; |
||
8368 | |||
8369 | return this.slatCoordCache.getTopPosition(slatIndex) + |
||
8370 | this.slatCoordCache.getHeight(slatIndex) * slatRemainder; |
||
8371 | }, |
||
8372 | |||
8373 | |||
8374 | |||
8375 | /* Event Drag Visualization |
||
8376 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8377 | |||
8378 | |||
8379 | // Renders a visual indication of an event being dragged over the specified date(s). |
||
8380 | // A returned value of `true` signals that a mock "helper" event has been rendered. |
||
8381 | renderDrag: function(eventLocation, seg) { |
||
8382 | var eventSpans; |
||
8383 | var i; |
||
8384 | |||
8385 | if (seg) { // if there is event information for this drag, render a helper event |
||
8386 | |||
8387 | // returns mock event elements |
||
8388 | // signal that a helper has been rendered |
||
8389 | return this.renderEventLocationHelper(eventLocation, seg); |
||
8390 | } |
||
8391 | else { // otherwise, just render a highlight |
||
8392 | eventSpans = this.eventToSpans(eventLocation); |
||
8393 | |||
8394 | for (i = 0; i < eventSpans.length; i++) { |
||
8395 | this.renderHighlight(eventSpans[i]); |
||
8396 | } |
||
8397 | } |
||
8398 | }, |
||
8399 | |||
8400 | |||
8401 | // Unrenders any visual indication of an event being dragged |
||
8402 | unrenderDrag: function() { |
||
8403 | this.unrenderHelper(); |
||
8404 | this.unrenderHighlight(); |
||
8405 | }, |
||
8406 | |||
8407 | |||
8408 | /* Event Resize Visualization |
||
8409 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8410 | |||
8411 | |||
8412 | // Renders a visual indication of an event being resized |
||
8413 | renderEventResize: function(eventLocation, seg) { |
||
8414 | return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements |
||
8415 | }, |
||
8416 | |||
8417 | |||
8418 | // Unrenders any visual indication of an event being resized |
||
8419 | unrenderEventResize: function() { |
||
8420 | this.unrenderHelper(); |
||
8421 | }, |
||
8422 | |||
8423 | |||
8424 | /* Event Helper |
||
8425 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8426 | |||
8427 | |||
8428 | // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag) |
||
8429 | renderHelper: function(event, sourceSeg) { |
||
8430 | return this.renderHelperSegs(this.eventToSegs(event), sourceSeg); // returns mock event elements |
||
8431 | }, |
||
8432 | |||
8433 | |||
8434 | // Unrenders any mock helper event |
||
8435 | unrenderHelper: function() { |
||
8436 | this.unrenderHelperSegs(); |
||
8437 | }, |
||
8438 | |||
8439 | |||
8440 | /* Business Hours |
||
8441 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8442 | |||
8443 | |||
8444 | renderBusinessHours: function() { |
||
8445 | this.renderBusinessSegs( |
||
8446 | this.buildBusinessHourSegs() |
||
8447 | ); |
||
8448 | }, |
||
8449 | |||
8450 | |||
8451 | unrenderBusinessHours: function() { |
||
8452 | this.unrenderBusinessSegs(); |
||
8453 | }, |
||
8454 | |||
8455 | |||
8456 | /* Now Indicator |
||
8457 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8458 | |||
8459 | |||
8460 | getNowIndicatorUnit: function() { |
||
8461 | return 'minute'; // will refresh on the minute |
||
8462 | }, |
||
8463 | |||
8464 | |||
8465 | renderNowIndicator: function(date) { |
||
8466 | // seg system might be overkill, but it handles scenario where line needs to be rendered |
||
8467 | // more than once because of columns with the same date (resources columns for example) |
||
8468 | var segs = this.spanToSegs({ start: date, end: date }); |
||
8469 | var top = this.computeDateTop(date, date); |
||
8470 | var nodes = []; |
||
8471 | var i; |
||
8472 | |||
8473 | // render lines within the columns |
||
8474 | for (i = 0; i < segs.length; i++) { |
||
8475 | nodes.push($('<div class="fc-now-indicator fc-now-indicator-line"></div>') |
||
8476 | .css('top', top) |
||
8477 | .appendTo(this.colContainerEls.eq(segs[i].col))[0]); |
||
8478 | } |
||
8479 | |||
8480 | // render an arrow over the axis |
||
8481 | if (segs.length > 0) { // is the current time in view? |
||
8482 | nodes.push($('<div class="fc-now-indicator fc-now-indicator-arrow"></div>') |
||
8483 | .css('top', top) |
||
8484 | .appendTo(this.el.find('.fc-content-skeleton'))[0]); |
||
8485 | } |
||
8486 | |||
8487 | this.nowIndicatorEls = $(nodes); |
||
8488 | }, |
||
8489 | |||
8490 | |||
8491 | unrenderNowIndicator: function() { |
||
8492 | if (this.nowIndicatorEls) { |
||
8493 | this.nowIndicatorEls.remove(); |
||
8494 | this.nowIndicatorEls = null; |
||
8495 | } |
||
8496 | }, |
||
8497 | |||
8498 | |||
8499 | /* Selection |
||
8500 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8501 | |||
8502 | |||
8503 | // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight. |
||
8504 | renderSelection: function(span) { |
||
8505 | if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered |
||
8506 | |||
8507 | // normally acceps an eventLocation, span has a start/end, which is good enough |
||
8508 | this.renderEventLocationHelper(span); |
||
8509 | } |
||
8510 | else { |
||
8511 | this.renderHighlight(span); |
||
8512 | } |
||
8513 | }, |
||
8514 | |||
8515 | |||
8516 | // Unrenders any visual indication of a selection |
||
8517 | unrenderSelection: function() { |
||
8518 | this.unrenderHelper(); |
||
8519 | this.unrenderHighlight(); |
||
8520 | }, |
||
8521 | |||
8522 | |||
8523 | /* Highlight |
||
8524 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8525 | |||
8526 | |||
8527 | renderHighlight: function(span) { |
||
8528 | this.renderHighlightSegs(this.spanToSegs(span)); |
||
8529 | }, |
||
8530 | |||
8531 | |||
8532 | unrenderHighlight: function() { |
||
8533 | this.unrenderHighlightSegs(); |
||
8534 | } |
||
8535 | |||
8536 | }); |
||
8537 | |||
8538 | ;; |
||
8539 | |||
8540 | /* Methods for rendering SEGMENTS, pieces of content that live on the view |
||
8541 | ( this file is no longer just for events ) |
||
8542 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
8543 | |||
8544 | TimeGrid.mixin({ |
||
8545 | |||
8546 | colContainerEls: null, // containers for each column |
||
8547 | |||
8548 | // inner-containers for each column where different types of segs live |
||
8549 | fgContainerEls: null, |
||
8550 | bgContainerEls: null, |
||
8551 | helperContainerEls: null, |
||
8552 | highlightContainerEls: null, |
||
8553 | businessContainerEls: null, |
||
8554 | |||
8555 | // arrays of different types of displayed segments |
||
8556 | fgSegs: null, |
||
8557 | bgSegs: null, |
||
8558 | helperSegs: null, |
||
8559 | highlightSegs: null, |
||
8560 | businessSegs: null, |
||
8561 | |||
8562 | |||
8563 | // Renders the DOM that the view's content will live in |
||
8564 | renderContentSkeleton: function() { |
||
8565 | var cellHtml = ''; |
||
8566 | var i; |
||
8567 | var skeletonEl; |
||
8568 | |||
8569 | for (i = 0; i < this.colCnt; i++) { |
||
8570 | cellHtml += |
||
8571 | '<td>' + |
||
8572 | '<div class="fc-content-col">' + |
||
8573 | '<div class="fc-event-container fc-helper-container"></div>' + |
||
8574 | '<div class="fc-event-container"></div>' + |
||
8575 | '<div class="fc-highlight-container"></div>' + |
||
8576 | '<div class="fc-bgevent-container"></div>' + |
||
8577 | '<div class="fc-business-container"></div>' + |
||
8578 | '</div>' + |
||
8579 | '</td>'; |
||
8580 | } |
||
8581 | |||
8582 | skeletonEl = $( |
||
8583 | '<div class="fc-content-skeleton">' + |
||
8584 | '<table>' + |
||
8585 | '<tr>' + cellHtml + '</tr>' + |
||
8586 | '</table>' + |
||
8587 | '</div>' |
||
8588 | ); |
||
8589 | |||
8590 | this.colContainerEls = skeletonEl.find('.fc-content-col'); |
||
8591 | this.helperContainerEls = skeletonEl.find('.fc-helper-container'); |
||
8592 | this.fgContainerEls = skeletonEl.find('.fc-event-container:not(.fc-helper-container)'); |
||
8593 | this.bgContainerEls = skeletonEl.find('.fc-bgevent-container'); |
||
8594 | this.highlightContainerEls = skeletonEl.find('.fc-highlight-container'); |
||
8595 | this.businessContainerEls = skeletonEl.find('.fc-business-container'); |
||
8596 | |||
8597 | this.bookendCells(skeletonEl.find('tr')); // TODO: do this on string level |
||
8598 | this.el.append(skeletonEl); |
||
8599 | }, |
||
8600 | |||
8601 | |||
8602 | /* Foreground Events |
||
8603 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8604 | |||
8605 | |||
8606 | renderFgSegs: function(segs) { |
||
8607 | segs = this.renderFgSegsIntoContainers(segs, this.fgContainerEls); |
||
8608 | this.fgSegs = segs; |
||
8609 | return segs; // needed for Grid::renderEvents |
||
8610 | }, |
||
8611 | |||
8612 | |||
8613 | unrenderFgSegs: function() { |
||
8614 | this.unrenderNamedSegs('fgSegs'); |
||
8615 | }, |
||
8616 | |||
8617 | |||
8618 | /* Foreground Helper Events |
||
8619 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8620 | |||
8621 | |||
8622 | renderHelperSegs: function(segs, sourceSeg) { |
||
8623 | var helperEls = []; |
||
8624 | var i, seg; |
||
8625 | var sourceEl; |
||
8626 | |||
8627 | segs = this.renderFgSegsIntoContainers(segs, this.helperContainerEls); |
||
8628 | |||
8629 | // Try to make the segment that is in the same row as sourceSeg look the same |
||
8630 | for (i = 0; i < segs.length; i++) { |
||
8631 | seg = segs[i]; |
||
8632 | if (sourceSeg && sourceSeg.col === seg.col) { |
||
8633 | sourceEl = sourceSeg.el; |
||
8634 | seg.el.css({ |
||
8635 | left: sourceEl.css('left'), |
||
8636 | right: sourceEl.css('right'), |
||
8637 | 'margin-left': sourceEl.css('margin-left'), |
||
8638 | 'margin-right': sourceEl.css('margin-right') |
||
8639 | }); |
||
8640 | } |
||
8641 | helperEls.push(seg.el[0]); |
||
8642 | } |
||
8643 | |||
8644 | this.helperSegs = segs; |
||
8645 | |||
8646 | return $(helperEls); // must return rendered helpers |
||
8647 | }, |
||
8648 | |||
8649 | |||
8650 | unrenderHelperSegs: function() { |
||
8651 | this.unrenderNamedSegs('helperSegs'); |
||
8652 | }, |
||
8653 | |||
8654 | |||
8655 | /* Background Events |
||
8656 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8657 | |||
8658 | |||
8659 | renderBgSegs: function(segs) { |
||
8660 | segs = this.renderFillSegEls('bgEvent', segs); // TODO: old fill system |
||
8661 | this.updateSegVerticals(segs); |
||
8662 | this.attachSegsByCol(this.groupSegsByCol(segs), this.bgContainerEls); |
||
8663 | this.bgSegs = segs; |
||
8664 | return segs; // needed for Grid::renderEvents |
||
8665 | }, |
||
8666 | |||
8667 | |||
8668 | unrenderBgSegs: function() { |
||
8669 | this.unrenderNamedSegs('bgSegs'); |
||
8670 | }, |
||
8671 | |||
8672 | |||
8673 | /* Highlight |
||
8674 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8675 | |||
8676 | |||
8677 | renderHighlightSegs: function(segs) { |
||
8678 | segs = this.renderFillSegEls('highlight', segs); // TODO: old fill system |
||
8679 | this.updateSegVerticals(segs); |
||
8680 | this.attachSegsByCol(this.groupSegsByCol(segs), this.highlightContainerEls); |
||
8681 | this.highlightSegs = segs; |
||
8682 | }, |
||
8683 | |||
8684 | |||
8685 | unrenderHighlightSegs: function() { |
||
8686 | this.unrenderNamedSegs('highlightSegs'); |
||
8687 | }, |
||
8688 | |||
8689 | |||
8690 | /* Business Hours |
||
8691 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8692 | |||
8693 | |||
8694 | renderBusinessSegs: function(segs) { |
||
8695 | segs = this.renderFillSegEls('businessHours', segs); // TODO: old fill system |
||
8696 | this.updateSegVerticals(segs); |
||
8697 | this.attachSegsByCol(this.groupSegsByCol(segs), this.businessContainerEls); |
||
8698 | this.businessSegs = segs; |
||
8699 | }, |
||
8700 | |||
8701 | |||
8702 | unrenderBusinessSegs: function() { |
||
8703 | this.unrenderNamedSegs('businessSegs'); |
||
8704 | }, |
||
8705 | |||
8706 | |||
8707 | /* Seg Rendering Utils |
||
8708 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8709 | |||
8710 | |||
8711 | // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col |
||
8712 | groupSegsByCol: function(segs) { |
||
8713 | var segsByCol = []; |
||
8714 | var i; |
||
8715 | |||
8716 | for (i = 0; i < this.colCnt; i++) { |
||
8717 | segsByCol.push([]); |
||
8718 | } |
||
8719 | |||
8720 | for (i = 0; i < segs.length; i++) { |
||
8721 | segsByCol[segs[i].col].push(segs[i]); |
||
8722 | } |
||
8723 | |||
8724 | return segsByCol; |
||
8725 | }, |
||
8726 | |||
8727 | |||
8728 | // Given segments grouped by column, insert the segments' elements into a parallel array of container |
||
8729 | // elements, each living within a column. |
||
8730 | attachSegsByCol: function(segsByCol, containerEls) { |
||
8731 | var col; |
||
8732 | var segs; |
||
8733 | var i; |
||
8734 | |||
8735 | for (col = 0; col < this.colCnt; col++) { // iterate each column grouping |
||
8736 | segs = segsByCol[col]; |
||
8737 | |||
8738 | for (i = 0; i < segs.length; i++) { |
||
8739 | containerEls.eq(col).append(segs[i].el); |
||
8740 | } |
||
8741 | } |
||
8742 | }, |
||
8743 | |||
8744 | |||
8745 | // Given the name of a property of `this` object, assumed to be an array of segments, |
||
8746 | // loops through each segment and removes from DOM. Will null-out the property afterwards. |
||
8747 | unrenderNamedSegs: function(propName) { |
||
8748 | var segs = this[propName]; |
||
8749 | var i; |
||
8750 | |||
8751 | if (segs) { |
||
8752 | for (i = 0; i < segs.length; i++) { |
||
8753 | segs[i].el.remove(); |
||
8754 | } |
||
8755 | this[propName] = null; |
||
8756 | } |
||
8757 | }, |
||
8758 | |||
8759 | |||
8760 | |||
8761 | /* Foreground Event Rendering Utils |
||
8762 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8763 | |||
8764 | |||
8765 | // Given an array of foreground segments, render a DOM element for each, computes position, |
||
8766 | // and attaches to the column inner-container elements. |
||
8767 | renderFgSegsIntoContainers: function(segs, containerEls) { |
||
8768 | var segsByCol; |
||
8769 | var col; |
||
8770 | |||
8771 | segs = this.renderFgSegEls(segs); // will call fgSegHtml |
||
8772 | segsByCol = this.groupSegsByCol(segs); |
||
8773 | |||
8774 | for (col = 0; col < this.colCnt; col++) { |
||
8775 | this.updateFgSegCoords(segsByCol[col]); |
||
8776 | } |
||
8777 | |||
8778 | this.attachSegsByCol(segsByCol, containerEls); |
||
8779 | |||
8780 | return segs; |
||
8781 | }, |
||
8782 | |||
8783 | |||
8784 | // Renders the HTML for a single event segment's default rendering |
||
8785 | fgSegHtml: function(seg, disableResizing) { |
||
8786 | var view = this.view; |
||
8787 | var event = seg.event; |
||
8788 | var isDraggable = view.isEventDraggable(event); |
||
8789 | var isResizableFromStart = !disableResizing && seg.isStart && view.isEventResizableFromStart(event); |
||
8790 | var isResizableFromEnd = !disableResizing && seg.isEnd && view.isEventResizableFromEnd(event); |
||
8791 | var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); |
||
8792 | var skinCss = cssToStr(this.getSegSkinCss(seg)); |
||
8793 | var timeText; |
||
8794 | var fullTimeText; // more verbose time text. for the print stylesheet |
||
8795 | var startTimeText; // just the start time text |
||
8796 | |||
8797 | classes.unshift('fc-time-grid-event', 'fc-v-event'); |
||
8798 | |||
8799 | if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day... |
||
8800 | // Don't display time text on segments that run entirely through a day. |
||
8801 | // That would appear as midnight-midnight and would look dumb. |
||
8802 | // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am) |
||
8803 | if (seg.isStart || seg.isEnd) { |
||
8804 | timeText = this.getEventTimeText(seg); |
||
8805 | fullTimeText = this.getEventTimeText(seg, 'LT'); |
||
8806 | startTimeText = this.getEventTimeText(seg, null, false); // displayEnd=false |
||
8807 | } |
||
8808 | } else { |
||
8809 | // Display the normal time text for the *event's* times |
||
8810 | timeText = this.getEventTimeText(event); |
||
8811 | fullTimeText = this.getEventTimeText(event, 'LT'); |
||
8812 | startTimeText = this.getEventTimeText(event, null, false); // displayEnd=false |
||
8813 | } |
||
8814 | |||
8815 | return '<a class="' + classes.join(' ') + '"' + |
||
8816 | (event.url ? |
||
8817 | ' href="' + htmlEscape(event.url) + '"' : |
||
8818 | '' |
||
8819 | ) + |
||
8820 | (skinCss ? |
||
8821 | ' style="' + skinCss + '"' : |
||
8822 | '' |
||
8823 | ) + |
||
8824 | '>' + |
||
8825 | '<div class="fc-content">' + |
||
8826 | (timeText ? |
||
8827 | '<div class="fc-time"' + |
||
8828 | ' data-start="' + htmlEscape(startTimeText) + '"' + |
||
8829 | ' data-full="' + htmlEscape(fullTimeText) + '"' + |
||
8830 | '>' + |
||
8831 | '<span>' + htmlEscape(timeText) + '</span>' + |
||
8832 | '</div>' : |
||
8833 | '' |
||
8834 | ) + |
||
8835 | (event.title ? |
||
8836 | '<div class="fc-title">' + |
||
8837 | htmlEscape(event.title) + |
||
8838 | '</div>' : |
||
8839 | '' |
||
8840 | ) + |
||
8841 | '</div>' + |
||
8842 | '<div class="fc-bg"/>' + |
||
8843 | /* TODO: write CSS for this |
||
8844 | (isResizableFromStart ? |
||
8845 | '<div class="fc-resizer fc-start-resizer" />' : |
||
8846 | '' |
||
8847 | ) + |
||
8848 | */ |
||
8849 | (isResizableFromEnd ? |
||
8850 | '<div class="fc-resizer fc-end-resizer" />' : |
||
8851 | '' |
||
8852 | ) + |
||
8853 | '</a>'; |
||
8854 | }, |
||
8855 | |||
8856 | |||
8857 | /* Seg Position Utils |
||
8858 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8859 | |||
8860 | |||
8861 | // Refreshes the CSS top/bottom coordinates for each segment element. |
||
8862 | // Works when called after initial render, after a window resize/zoom for example. |
||
8863 | updateSegVerticals: function(segs) { |
||
8864 | this.computeSegVerticals(segs); |
||
8865 | this.assignSegVerticals(segs); |
||
8866 | }, |
||
8867 | |||
8868 | |||
8869 | // For each segment in an array, computes and assigns its top and bottom properties |
||
8870 | computeSegVerticals: function(segs) { |
||
8871 | var i, seg; |
||
8872 | var dayDate; |
||
8873 | |||
8874 | for (i = 0; i < segs.length; i++) { |
||
8875 | seg = segs[i]; |
||
8876 | dayDate = this.dayDates[seg.dayIndex]; |
||
8877 | |||
8878 | seg.top = this.computeDateTop(seg.start, dayDate); |
||
8879 | seg.bottom = this.computeDateTop(seg.end, dayDate); |
||
8880 | } |
||
8881 | }, |
||
8882 | |||
8883 | |||
8884 | // Given segments that already have their top/bottom properties computed, applies those values to |
||
8885 | // the segments' elements. |
||
8886 | assignSegVerticals: function(segs) { |
||
8887 | var i, seg; |
||
8888 | |||
8889 | for (i = 0; i < segs.length; i++) { |
||
8890 | seg = segs[i]; |
||
8891 | seg.el.css(this.generateSegVerticalCss(seg)); |
||
8892 | } |
||
8893 | }, |
||
8894 | |||
8895 | |||
8896 | // Generates an object with CSS properties for the top/bottom coordinates of a segment element |
||
8897 | generateSegVerticalCss: function(seg) { |
||
8898 | return { |
||
8899 | top: seg.top, |
||
8900 | bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container |
||
8901 | }; |
||
8902 | }, |
||
8903 | |||
8904 | |||
8905 | /* Foreground Event Positioning Utils |
||
8906 | ------------------------------------------------------------------------------------------------------------------*/ |
||
8907 | |||
8908 | |||
8909 | // Given segments that are assumed to all live in the *same column*, |
||
8910 | // compute their verical/horizontal coordinates and assign to their elements. |
||
8911 | updateFgSegCoords: function(segs) { |
||
8912 | this.computeSegVerticals(segs); // horizontals relies on this |
||
8913 | this.computeFgSegHorizontals(segs); // compute horizontal coordinates, z-index's, and reorder the array |
||
8914 | this.assignSegVerticals(segs); |
||
8915 | this.assignFgSegHorizontals(segs); |
||
8916 | }, |
||
8917 | |||
8918 | |||
8919 | // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each. |
||
8920 | // NOTE: Also reorders the given array by date! |
||
8921 | computeFgSegHorizontals: function(segs) { |
||
8922 | var levels; |
||
8923 | var level0; |
||
8924 | var i; |
||
8925 | |||
8926 | this.sortEventSegs(segs); // order by certain criteria |
||
8927 | levels = buildSlotSegLevels(segs); |
||
8928 | computeForwardSlotSegs(levels); |
||
8929 | |||
8930 | if ((level0 = levels[0])) { |
||
8931 | |||
8932 | for (i = 0; i < level0.length; i++) { |
||
8933 | computeSlotSegPressures(level0[i]); |
||
8934 | } |
||
8935 | |||
8936 | for (i = 0; i < level0.length; i++) { |
||
8937 | this.computeFgSegForwardBack(level0[i], 0, 0); |
||
8938 | } |
||
8939 | } |
||
8940 | }, |
||
8941 | |||
8942 | |||
8943 | // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range |
||
8944 | // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and |
||
8945 | // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left. |
||
8946 | // |
||
8947 | // The segment might be part of a "series", which means consecutive segments with the same pressure |
||
8948 | // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of |
||
8949 | // segments behind this one in the current series, and `seriesBackwardCoord` is the starting |
||
8950 | // coordinate of the first segment in the series. |
||
8951 | computeFgSegForwardBack: function(seg, seriesBackwardPressure, seriesBackwardCoord) { |
||
8952 | var forwardSegs = seg.forwardSegs; |
||
8953 | var i; |
||
8954 | |||
8955 | if (seg.forwardCoord === undefined) { // not already computed |
||
8956 | |||
8957 | if (!forwardSegs.length) { |
||
8958 | |||
8959 | // if there are no forward segments, this segment should butt up against the edge |
||
8960 | seg.forwardCoord = 1; |
||
8961 | } |
||
8962 | else { |
||
8963 | |||
8964 | // sort highest pressure first |
||
8965 | this.sortForwardSegs(forwardSegs); |
||
8966 | |||
8967 | // this segment's forwardCoord will be calculated from the backwardCoord of the |
||
8968 | // highest-pressure forward segment. |
||
8969 | this.computeFgSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord); |
||
8970 | seg.forwardCoord = forwardSegs[0].backwardCoord; |
||
8971 | } |
||
8972 | |||
8973 | // calculate the backwardCoord from the forwardCoord. consider the series |
||
8974 | seg.backwardCoord = seg.forwardCoord - |
||
8975 | (seg.forwardCoord - seriesBackwardCoord) / // available width for series |
||
8976 | (seriesBackwardPressure + 1); // # of segments in the series |
||
8977 | |||
8978 | // use this segment's coordinates to computed the coordinates of the less-pressurized |
||
8979 | // forward segments |
||
8980 | for (i=0; i<forwardSegs.length; i++) { |
||
8981 | this.computeFgSegForwardBack(forwardSegs[i], 0, seg.forwardCoord); |
||
8982 | } |
||
8983 | } |
||
8984 | }, |
||
8985 | |||
8986 | |||
8987 | sortForwardSegs: function(forwardSegs) { |
||
8988 | forwardSegs.sort(proxy(this, 'compareForwardSegs')); |
||
8989 | }, |
||
8990 | |||
8991 | |||
8992 | // A cmp function for determining which forward segment to rely on more when computing coordinates. |
||
8993 | compareForwardSegs: function(seg1, seg2) { |
||
8994 | // put higher-pressure first |
||
8995 | return seg2.forwardPressure - seg1.forwardPressure || |
||
8996 | // put segments that are closer to initial edge first (and favor ones with no coords yet) |
||
8997 | (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) || |
||
8998 | // do normal sorting... |
||
8999 | this.compareEventSegs(seg1, seg2); |
||
9000 | }, |
||
9001 | |||
9002 | |||
9003 | // Given foreground event segments that have already had their position coordinates computed, |
||
9004 | // assigns position-related CSS values to their elements. |
||
9005 | assignFgSegHorizontals: function(segs) { |
||
9006 | var i, seg; |
||
9007 | |||
9008 | for (i = 0; i < segs.length; i++) { |
||
9009 | seg = segs[i]; |
||
9010 | seg.el.css(this.generateFgSegHorizontalCss(seg)); |
||
9011 | |||
9012 | // if the height is short, add a className for alternate styling |
||
9013 | if (seg.bottom - seg.top < 30) { |
||
9014 | seg.el.addClass('fc-short'); |
||
9015 | } |
||
9016 | } |
||
9017 | }, |
||
9018 | |||
9019 | |||
9020 | // Generates an object with CSS properties/values that should be applied to an event segment element. |
||
9021 | // Contains important positioning-related properties that should be applied to any event element, customized or not. |
||
9022 | generateFgSegHorizontalCss: function(seg) { |
||
9023 | var shouldOverlap = this.view.opt('slotEventOverlap'); |
||
9024 | var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point |
||
9025 | var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point |
||
9026 | var props = this.generateSegVerticalCss(seg); // get top/bottom first |
||
9027 | var left; // amount of space from left edge, a fraction of the total width |
||
9028 | var right; // amount of space from right edge, a fraction of the total width |
||
9029 | |||
9030 | if (shouldOverlap) { |
||
9031 | // double the width, but don't go beyond the maximum forward coordinate (1.0) |
||
9032 | forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2); |
||
9033 | } |
||
9034 | |||
9035 | if (this.isRTL) { |
||
9036 | left = 1 - forwardCoord; |
||
9037 | right = backwardCoord; |
||
9038 | } |
||
9039 | else { |
||
9040 | left = backwardCoord; |
||
9041 | right = 1 - forwardCoord; |
||
9042 | } |
||
9043 | |||
9044 | props.zIndex = seg.level + 1; // convert from 0-base to 1-based |
||
9045 | props.left = left * 100 + '%'; |
||
9046 | props.right = right * 100 + '%'; |
||
9047 | |||
9048 | if (shouldOverlap && seg.forwardPressure) { |
||
9049 | // add padding to the edge so that forward stacked events don't cover the resizer's icon |
||
9050 | props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width |
||
9051 | } |
||
9052 | |||
9053 | return props; |
||
9054 | } |
||
9055 | |||
9056 | }); |
||
9057 | |||
9058 | |||
9059 | // Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is |
||
9060 | // left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date. |
||
9061 | function buildSlotSegLevels(segs) { |
||
9062 | var levels = []; |
||
9063 | var i, seg; |
||
9064 | var j; |
||
9065 | |||
9066 | for (i=0; i<segs.length; i++) { |
||
9067 | seg = segs[i]; |
||
9068 | |||
9069 | // go through all the levels and stop on the first level where there are no collisions |
||
9070 | for (j=0; j<levels.length; j++) { |
||
9071 | if (!computeSlotSegCollisions(seg, levels[j]).length) { |
||
9072 | break; |
||
9073 | } |
||
9074 | } |
||
9075 | |||
9076 | seg.level = j; |
||
9077 | |||
9078 | (levels[j] || (levels[j] = [])).push(seg); |
||
9079 | } |
||
9080 | |||
9081 | return levels; |
||
9082 | } |
||
9083 | |||
9084 | |||
9085 | // For every segment, figure out the other segments that are in subsequent |
||
9086 | // levels that also occupy the same vertical space. Accumulate in seg.forwardSegs |
||
9087 | function computeForwardSlotSegs(levels) { |
||
9088 | var i, level; |
||
9089 | var j, seg; |
||
9090 | var k; |
||
9091 | |||
9092 | for (i=0; i<levels.length; i++) { |
||
9093 | level = levels[i]; |
||
9094 | |||
9095 | for (j=0; j<level.length; j++) { |
||
9096 | seg = level[j]; |
||
9097 | |||
9098 | seg.forwardSegs = []; |
||
9099 | for (k=i+1; k<levels.length; k++) { |
||
9100 | computeSlotSegCollisions(seg, levels[k], seg.forwardSegs); |
||
9101 | } |
||
9102 | } |
||
9103 | } |
||
9104 | } |
||
9105 | |||
9106 | |||
9107 | // Figure out which path forward (via seg.forwardSegs) results in the longest path until |
||
9108 | // the furthest edge is reached. The number of segments in this path will be seg.forwardPressure |
||
9109 | function computeSlotSegPressures(seg) { |
||
9110 | var forwardSegs = seg.forwardSegs; |
||
9111 | var forwardPressure = 0; |
||
9112 | var i, forwardSeg; |
||
9113 | |||
9114 | if (seg.forwardPressure === undefined) { // not already computed |
||
9115 | |||
9116 | for (i=0; i<forwardSegs.length; i++) { |
||
9117 | forwardSeg = forwardSegs[i]; |
||
9118 | |||
9119 | // figure out the child's maximum forward path |
||
9120 | computeSlotSegPressures(forwardSeg); |
||
9121 | |||
9122 | // either use the existing maximum, or use the child's forward pressure |
||
9123 | // plus one (for the forwardSeg itself) |
||
9124 | forwardPressure = Math.max( |
||
9125 | forwardPressure, |
||
9126 | 1 + forwardSeg.forwardPressure |
||
9127 | ); |
||
9128 | } |
||
9129 | |||
9130 | seg.forwardPressure = forwardPressure; |
||
9131 | } |
||
9132 | } |
||
9133 | |||
9134 | |||
9135 | // Find all the segments in `otherSegs` that vertically collide with `seg`. |
||
9136 | // Append into an optionally-supplied `results` array and return. |
||
9137 | function computeSlotSegCollisions(seg, otherSegs, results) { |
||
9138 | results = results || []; |
||
9139 | |||
9140 | for (var i=0; i<otherSegs.length; i++) { |
||
9141 | if (isSlotSegCollision(seg, otherSegs[i])) { |
||
9142 | results.push(otherSegs[i]); |
||
9143 | } |
||
9144 | } |
||
9145 | |||
9146 | return results; |
||
9147 | } |
||
9148 | |||
9149 | |||
9150 | // Do these segments occupy the same vertical space? |
||
9151 | function isSlotSegCollision(seg1, seg2) { |
||
9152 | return seg1.bottom > seg2.top && seg1.top < seg2.bottom; |
||
9153 | } |
||
9154 | |||
9155 | ;; |
||
9156 | |||
9157 | /* An abstract class from which other views inherit from |
||
9158 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
9159 | |||
9160 | var View = FC.View = Model.extend({ |
||
9161 | |||
9162 | type: null, // subclass' view name (string) |
||
9163 | name: null, // deprecated. use `type` instead |
||
9164 | title: null, // the text that will be displayed in the header's title |
||
9165 | |||
9166 | calendar: null, // owner Calendar object |
||
9167 | viewSpec: null, |
||
9168 | options: null, // hash containing all options. already merged with view-specific-options |
||
9169 | el: null, // the view's containing element. set by Calendar |
||
9170 | |||
9171 | renderQueue: null, |
||
9172 | batchRenderDepth: 0, |
||
9173 | isDatesRendered: false, |
||
9174 | isEventsRendered: false, |
||
9175 | isBaseRendered: false, // related to viewRender/viewDestroy triggers |
||
9176 | |||
9177 | queuedScroll: null, |
||
9178 | |||
9179 | isRTL: false, |
||
9180 | isSelected: false, // boolean whether a range of time is user-selected or not |
||
9181 | selectedEvent: null, |
||
9182 | |||
9183 | eventOrderSpecs: null, // criteria for ordering events when they have same date/time |
||
9184 | |||
9185 | // classNames styled by jqui themes |
||
9186 | widgetHeaderClass: null, |
||
9187 | widgetContentClass: null, |
||
9188 | highlightStateClass: null, |
||
9189 | |||
9190 | // for date utils, computed from options |
||
9191 | nextDayThreshold: null, |
||
9192 | isHiddenDayHash: null, |
||
9193 | |||
9194 | // now indicator |
||
9195 | isNowIndicatorRendered: null, |
||
9196 | initialNowDate: null, // result first getNow call |
||
9197 | initialNowQueriedMs: null, // ms time the getNow was called |
||
9198 | nowIndicatorTimeoutID: null, // for refresh timing of now indicator |
||
9199 | nowIndicatorIntervalID: null, // " |
||
9200 | |||
9201 | |||
9202 | constructor: function(calendar, viewSpec) { |
||
9203 | Model.prototype.constructor.call(this); |
||
9204 | |||
9205 | this.calendar = calendar; |
||
9206 | this.viewSpec = viewSpec; |
||
9207 | |||
9208 | // shortcuts |
||
9209 | this.type = viewSpec.type; |
||
9210 | this.options = viewSpec.options; |
||
9211 | |||
9212 | // .name is deprecated |
||
9213 | this.name = this.type; |
||
9214 | |||
9215 | this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold')); |
||
9216 | this.initThemingProps(); |
||
9217 | this.initHiddenDays(); |
||
9218 | this.isRTL = this.opt('isRTL'); |
||
9219 | |||
9220 | this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder')); |
||
9221 | |||
9222 | this.renderQueue = this.buildRenderQueue(); |
||
9223 | this.initAutoBatchRender(); |
||
9224 | |||
9225 | this.initialize(); |
||
9226 | }, |
||
9227 | |||
9228 | |||
9229 | buildRenderQueue: function() { |
||
9230 | var _this = this; |
||
9231 | var renderQueue = new RenderQueue({ |
||
9232 | event: this.opt('eventRenderWait') |
||
9233 | }); |
||
9234 | |||
9235 | renderQueue.on('start', function() { |
||
9236 | _this.freezeHeight(); |
||
9237 | _this.addScroll(_this.queryScroll()); |
||
9238 | }); |
||
9239 | |||
9240 | renderQueue.on('stop', function() { |
||
9241 | _this.thawHeight(); |
||
9242 | _this.popScroll(); |
||
9243 | }); |
||
9244 | |||
9245 | return renderQueue; |
||
9246 | }, |
||
9247 | |||
9248 | |||
9249 | initAutoBatchRender: function() { |
||
9250 | var _this = this; |
||
9251 | |||
9252 | this.on('before:change', function() { |
||
9253 | _this.startBatchRender(); |
||
9254 | }); |
||
9255 | |||
9256 | this.on('change', function() { |
||
9257 | _this.stopBatchRender(); |
||
9258 | }); |
||
9259 | }, |
||
9260 | |||
9261 | |||
9262 | startBatchRender: function() { |
||
9263 | if (!(this.batchRenderDepth++)) { |
||
9264 | this.renderQueue.pause(); |
||
9265 | } |
||
9266 | }, |
||
9267 | |||
9268 | |||
9269 | stopBatchRender: function() { |
||
9270 | if (!(--this.batchRenderDepth)) { |
||
9271 | this.renderQueue.resume(); |
||
9272 | } |
||
9273 | }, |
||
9274 | |||
9275 | |||
9276 | // A good place for subclasses to initialize member variables |
||
9277 | initialize: function() { |
||
9278 | // subclasses can implement |
||
9279 | }, |
||
9280 | |||
9281 | |||
9282 | // Retrieves an option with the given name |
||
9283 | opt: function(name) { |
||
9284 | return this.options[name]; |
||
9285 | }, |
||
9286 | |||
9287 | |||
9288 | // Triggers handlers that are view-related. Modifies args before passing to calendar. |
||
9289 | publiclyTrigger: function(name, thisObj) { // arguments beyond thisObj are passed along |
||
9290 | var calendar = this.calendar; |
||
9291 | |||
9292 | return calendar.publiclyTrigger.apply( |
||
9293 | calendar, |
||
9294 | [name, thisObj || this].concat( |
||
9295 | Array.prototype.slice.call(arguments, 2), // arguments beyond thisObj |
||
9296 | [ this ] // always make the last argument a reference to the view. TODO: deprecate |
||
9297 | ) |
||
9298 | ); |
||
9299 | }, |
||
9300 | |||
9301 | |||
9302 | /* Title and Date Formatting |
||
9303 | ------------------------------------------------------------------------------------------------------------------*/ |
||
9304 | |||
9305 | |||
9306 | // Sets the view's title property to the most updated computed value |
||
9307 | updateTitle: function() { |
||
9308 | this.title = this.computeTitle(); |
||
9309 | this.calendar.setToolbarsTitle(this.title); |
||
9310 | }, |
||
9311 | |||
9312 | |||
9313 | // Computes what the title at the top of the calendar should be for this view |
||
9314 | computeTitle: function() { |
||
9315 | var range; |
||
9316 | |||
9317 | // for views that span a large unit of time, show the proper interval, ignoring stray days before and after |
||
9318 | if (/^(year|month)$/.test(this.currentRangeUnit)) { |
||
9319 | range = this.currentRange; |
||
9320 | } |
||
9321 | else { // for day units or smaller, use the actual day range |
||
9322 | range = this.activeRange; |
||
9323 | } |
||
9324 | |||
9325 | return this.formatRange( |
||
9326 | { |
||
9327 | // in case currentRange has a time, make sure timezone is correct |
||
9328 | start: this.calendar.applyTimezone(range.start), |
||
9329 | end: this.calendar.applyTimezone(range.end) |
||
9330 | }, |
||
9331 | this.opt('titleFormat') || this.computeTitleFormat(), |
||
9332 | this.opt('titleRangeSeparator') |
||
9333 | ); |
||
9334 | }, |
||
9335 | |||
9336 | |||
9337 | // Generates the format string that should be used to generate the title for the current date range. |
||
9338 | // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`. |
||
9339 | computeTitleFormat: function() { |
||
9340 | if (this.currentRangeUnit == 'year') { |
||
9341 | return 'YYYY'; |
||
9342 | } |
||
9343 | else if (this.currentRangeUnit == 'month') { |
||
9344 | return this.opt('monthYearFormat'); // like "September 2014" |
||
9345 | } |
||
9346 | else if (this.currentRangeAs('days') > 1) { |
||
9347 | return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014" |
||
9348 | } |
||
9349 | else { |
||
9350 | return 'LL'; // one day. longer, like "September 9 2014" |
||
9351 | } |
||
9352 | }, |
||
9353 | |||
9354 | |||
9355 | // Utility for formatting a range. Accepts a range object, formatting string, and optional separator. |
||
9356 | // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account. |
||
9357 | // The timezones of the dates within `range` will be respected. |
||
9358 | formatRange: function(range, formatStr, separator) { |
||
9359 | var end = range.end; |
||
9360 | |||
9361 | if (!end.hasTime()) { // all-day? |
||
9362 | end = end.clone().subtract(1); // convert to inclusive. last ms of previous day |
||
9363 | } |
||
9364 | |||
9365 | return formatRange(range.start, end, formatStr, separator, this.opt('isRTL')); |
||
9366 | }, |
||
9367 | |||
9368 | |||
9369 | getAllDayHtml: function() { |
||
9370 | return this.opt('allDayHtml') || htmlEscape(this.opt('allDayText')); |
||
9371 | }, |
||
9372 | |||
9373 | |||
9374 | /* Navigation |
||
9375 | ------------------------------------------------------------------------------------------------------------------*/ |
||
9376 | |||
9377 | |||
9378 | // Generates HTML for an anchor to another view into the calendar. |
||
9379 | // Will either generate an <a> tag or a non-clickable <span> tag, depending on enabled settings. |
||
9380 | // `gotoOptions` can either be a moment input, or an object with the form: |
||
9381 | // { date, type, forceOff } |
||
9382 | // `type` is a view-type like "day" or "week". default value is "day". |
||
9383 | // `attrs` and `innerHtml` are use to generate the rest of the HTML tag. |
||
9384 | buildGotoAnchorHtml: function(gotoOptions, attrs, innerHtml) { |
||
9385 | var date, type, forceOff; |
||
9386 | var finalOptions; |
||
9387 | |||
9388 | if ($.isPlainObject(gotoOptions)) { |
||
9389 | date = gotoOptions.date; |
||
9390 | type = gotoOptions.type; |
||
9391 | forceOff = gotoOptions.forceOff; |
||
9392 | } |
||
9393 | else { |
||
9394 | date = gotoOptions; // a single moment input |
||
9395 | } |
||
9396 | date = FC.moment(date); // if a string, parse it |
||
9397 | |||
9398 | finalOptions = { // for serialization into the link |
||
9399 | date: date.format('YYYY-MM-DD'), |
||
9400 | type: type || 'day' |
||
9401 | }; |
||
9402 | |||
9403 | if (typeof attrs === 'string') { |
||
9404 | innerHtml = attrs; |
||
9405 | attrs = null; |
||
9406 | } |
||
9407 | |||
9408 | attrs = attrs ? ' ' + attrsToStr(attrs) : ''; // will have a leading space |
||
9409 | innerHtml = innerHtml || ''; |
||
9410 | |||
9411 | if (!forceOff && this.opt('navLinks')) { |
||
9412 | return '<a' + attrs + |
||
9413 | ' data-goto="' + htmlEscape(JSON.stringify(finalOptions)) + '">' + |
||
9414 | innerHtml + |
||
9415 | '</a>'; |
||
9416 | } |
||
9417 | else { |
||
9418 | return '<span' + attrs + '>' + |
||
9419 | innerHtml + |
||
9420 | '</span>'; |
||
9421 | } |
||
9422 | }, |
||
9423 | |||
9424 | |||
9425 | // Rendering Non-date-related Content |
||
9426 | // ----------------------------------------------------------------------------------------------------------------- |
||
9427 | |||
9428 | |||
9429 | // Sets the container element that the view should render inside of, does global DOM-related initializations, |
||
9430 | // and renders all the non-date-related content inside. |
||
9431 | setElement: function(el) { |
||
9432 | this.el = el; |
||
9433 | this.bindGlobalHandlers(); |
||
9434 | this.bindBaseRenderHandlers(); |
||
9435 | this.renderSkeleton(); |
||
9436 | }, |
||
9437 | |||
9438 | |||
9439 | // Removes the view's container element from the DOM, clearing any content beforehand. |
||
9440 | // Undoes any other DOM-related attachments. |
||
9441 | removeElement: function() { |
||
9442 | this.unsetDate(); |
||
9443 | this.unrenderSkeleton(); |
||
9444 | |||
9445 | this.unbindGlobalHandlers(); |
||
9446 | this.unbindBaseRenderHandlers(); |
||
9447 | |||
9448 | this.el.remove(); |
||
9449 | // NOTE: don't null-out this.el in case the View was destroyed within an API callback. |
||
9450 | // We don't null-out the View's other jQuery element references upon destroy, |
||
9451 | // so we shouldn't kill this.el either. |
||
9452 | }, |
||
9453 | |||
9454 | |||
9455 | // Renders the basic structure of the view before any content is rendered |
||
9456 | renderSkeleton: function() { |
||
9457 | // subclasses should implement |
||
9458 | }, |
||
9459 | |||
9460 | |||
9461 | // Unrenders the basic structure of the view |
||
9462 | unrenderSkeleton: function() { |
||
9463 | // subclasses should implement |
||
9464 | }, |
||
9465 | |||
9466 | |||
9467 | // Date Setting/Unsetting |
||
9468 | // ----------------------------------------------------------------------------------------------------------------- |
||
9469 | |||
9470 | |||
9471 | setDate: function(date) { |
||
9472 | var currentDateProfile = this.get('dateProfile'); |
||
9473 | var newDateProfile = this.buildDateProfile(date, null, true); // forceToValid=true |
||
9474 | |||
9475 | if ( |
||
9476 | !currentDateProfile || |
||
9477 | !isRangesEqual(currentDateProfile.activeRange, newDateProfile.activeRange) |
||
9478 | ) { |
||
9479 | this.set('dateProfile', newDateProfile); |
||
9480 | } |
||
9481 | |||
9482 | return newDateProfile.date; |
||
9483 | }, |
||
9484 | |||
9485 | |||
9486 | unsetDate: function() { |
||
9487 | this.unset('dateProfile'); |
||
9488 | }, |
||
9489 | |||
9490 | |||
9491 | // Date Rendering |
||
9492 | // ----------------------------------------------------------------------------------------------------------------- |
||
9493 | |||
9494 | |||
9495 | requestDateRender: function(dateProfile) { |
||
9496 | var _this = this; |
||
9497 | |||
9498 | this.renderQueue.queue(function() { |
||
9499 | _this.executeDateRender(dateProfile); |
||
9500 | }, 'date', 'init'); |
||
9501 | }, |
||
9502 | |||
9503 | |||
9504 | requestDateUnrender: function() { |
||
9505 | var _this = this; |
||
9506 | |||
9507 | this.renderQueue.queue(function() { |
||
9508 | _this.executeDateUnrender(); |
||
9509 | }, 'date', 'destroy'); |
||
9510 | }, |
||
9511 | |||
9512 | |||
9513 | // Event Data |
||
9514 | // ----------------------------------------------------------------------------------------------------------------- |
||
9515 | |||
9516 | |||
9517 | fetchInitialEvents: function(dateProfile) { |
||
9518 | return this.calendar.requestEvents( |
||
9519 | dateProfile.activeRange.start, |
||
9520 | dateProfile.activeRange.end |
||
9521 | ); |
||
9522 | }, |
||
9523 | |||
9524 | |||
9525 | bindEventChanges: function() { |
||
9526 | this.listenTo(this.calendar, 'eventsReset', this.resetEvents); |
||
9527 | }, |
||
9528 | |||
9529 | |||
9530 | unbindEventChanges: function() { |
||
9531 | this.stopListeningTo(this.calendar, 'eventsReset'); |
||
9532 | }, |
||
9533 | |||
9534 | |||
9535 | setEvents: function(events) { |
||
9536 | this.set('currentEvents', events); |
||
9537 | this.set('hasEvents', true); |
||
9538 | }, |
||
9539 | |||
9540 | |||
9541 | unsetEvents: function() { |
||
9542 | this.unset('currentEvents'); |
||
9543 | this.unset('hasEvents'); |
||
9544 | }, |
||
9545 | |||
9546 | |||
9547 | resetEvents: function(events) { |
||
9548 | this.startBatchRender(); |
||
9549 | this.unsetEvents(); |
||
9550 | this.setEvents(events); |
||
9551 | this.stopBatchRender(); |
||
9552 | }, |
||
9553 | |||
9554 | |||
9555 | // Event Rendering |
||
9556 | // ----------------------------------------------------------------------------------------------------------------- |
||
9557 | |||
9558 | |||
9559 | requestEventsRender: function(events) { |
||
9560 | var _this = this; |
||
9561 | |||
9562 | this.renderQueue.queue(function() { |
||
9563 | _this.executeEventsRender(events); |
||
9564 | }, 'event', 'init'); |
||
9565 | }, |
||
9566 | |||
9567 | |||
9568 | requestEventsUnrender: function() { |
||
9569 | var _this = this; |
||
9570 | |||
9571 | this.renderQueue.queue(function() { |
||
9572 | _this.executeEventsUnrender(); |
||
9573 | }, 'event', 'destroy'); |
||
9574 | }, |
||
9575 | |||
9576 | |||
9577 | // Date High-level Rendering |
||
9578 | // ----------------------------------------------------------------------------------------------------------------- |
||
9579 | |||
9580 | |||
9581 | // if dateProfile not specified, uses current |
||
9582 | executeDateRender: function(dateProfile, skipScroll) { |
||
9583 | |||
9584 | this.setDateProfileForRendering(dateProfile); |
||
9585 | this.updateTitle(); |
||
9586 | this.calendar.updateToolbarButtons(); |
||
9587 | |||
9588 | if (this.render) { |
||
9589 | this.render(); // TODO: deprecate |
||
9590 | } |
||
9591 | |||
9592 | this.renderDates(); |
||
9593 | this.updateSize(); |
||
9594 | this.renderBusinessHours(); // might need coordinates, so should go after updateSize() |
||
9595 | this.startNowIndicator(); |
||
9596 | |||
9597 | if (!skipScroll) { |
||
9598 | this.addScroll(this.computeInitialDateScroll()); |
||
9599 | } |
||
9600 | |||
9601 | this.isDatesRendered = true; |
||
9602 | this.trigger('datesRendered'); |
||
9603 | }, |
||
9604 | |||
9605 | |||
9606 | executeDateUnrender: function() { |
||
9607 | |||
9608 | this.unselect(); |
||
9609 | this.stopNowIndicator(); |
||
9610 | |||
9611 | this.trigger('before:datesUnrendered'); |
||
9612 | |||
9613 | this.unrenderBusinessHours(); |
||
9614 | this.unrenderDates(); |
||
9615 | |||
9616 | if (this.destroy) { |
||
9617 | this.destroy(); // TODO: deprecate |
||
9618 | } |
||
9619 | |||
9620 | this.isDatesRendered = false; |
||
9621 | }, |
||
9622 | |||
9623 | |||
9624 | // Date Low-level Rendering |
||
9625 | // ----------------------------------------------------------------------------------------------------------------- |
||
9626 | |||
9627 | |||
9628 | // date-cell content only |
||
9629 | renderDates: function() { |
||
9630 | // subclasses should implement |
||
9631 | }, |
||
9632 | |||
9633 | |||
9634 | // date-cell content only |
||
9635 | unrenderDates: function() { |
||
9636 | // subclasses should override |
||
9637 | }, |
||
9638 | |||
9639 | |||
9640 | // Determing when the "meat" of the view is rendered (aka the base) |
||
9641 | // ----------------------------------------------------------------------------------------------------------------- |
||
9642 | |||
9643 | |||
9644 | bindBaseRenderHandlers: function() { |
||
9645 | var _this = this; |
||
9646 | |||
9647 | this.on('datesRendered.baseHandler', function() { |
||
9648 | _this.onBaseRender(); |
||
9649 | }); |
||
9650 | |||
9651 | this.on('before:datesUnrendered.baseHandler', function() { |
||
9652 | _this.onBeforeBaseUnrender(); |
||
9653 | }); |
||
9654 | }, |
||
9655 | |||
9656 | |||
9657 | unbindBaseRenderHandlers: function() { |
||
9658 | this.off('.baseHandler'); |
||
9659 | }, |
||
9660 | |||
9661 | |||
9662 | onBaseRender: function() { |
||
9663 | this.applyScreenState(); |
||
9664 | this.publiclyTrigger('viewRender', this, this, this.el); |
||
9665 | }, |
||
9666 | |||
9667 | |||
9668 | onBeforeBaseUnrender: function() { |
||
9669 | this.applyScreenState(); |
||
9670 | this.publiclyTrigger('viewDestroy', this, this, this.el); |
||
9671 | }, |
||
9672 | |||
9673 | |||
9674 | // Misc view rendering utils |
||
9675 | // ----------------------------------------------------------------------------------------------------------------- |
||
9676 | |||
9677 | |||
9678 | // Binds DOM handlers to elements that reside outside the view container, such as the document |
||
9679 | bindGlobalHandlers: function() { |
||
9680 | this.listenTo(GlobalEmitter.get(), { |
||
9681 | touchstart: this.processUnselect, |
||
9682 | mousedown: this.handleDocumentMousedown |
||
9683 | }); |
||
9684 | }, |
||
9685 | |||
9686 | |||
9687 | // Unbinds DOM handlers from elements that reside outside the view container |
||
9688 | unbindGlobalHandlers: function() { |
||
9689 | this.stopListeningTo(GlobalEmitter.get()); |
||
9690 | }, |
||
9691 | |||
9692 | |||
9693 | // Initializes internal variables related to theming |
||
9694 | initThemingProps: function() { |
||
9695 | var tm = this.opt('theme') ? 'ui' : 'fc'; |
||
9696 | |||
9697 | this.widgetHeaderClass = tm + '-widget-header'; |
||
9698 | this.widgetContentClass = tm + '-widget-content'; |
||
9699 | this.highlightStateClass = tm + '-state-highlight'; |
||
9700 | }, |
||
9701 | |||
9702 | |||
9703 | /* Business Hours |
||
9704 | ------------------------------------------------------------------------------------------------------------------*/ |
||
9705 | |||
9706 | |||
9707 | // Renders business-hours onto the view. Assumes updateSize has already been called. |
||
9708 | renderBusinessHours: function() { |
||
9709 | // subclasses should implement |
||
9710 | }, |
||
9711 | |||
9712 | |||
9713 | // Unrenders previously-rendered business-hours |
||
9714 | unrenderBusinessHours: function() { |
||
9715 | // subclasses should implement |
||
9716 | }, |
||
9717 | |||
9718 | |||
9719 | /* Now Indicator |
||
9720 | ------------------------------------------------------------------------------------------------------------------*/ |
||
9721 | |||
9722 | |||
9723 | // Immediately render the current time indicator and begins re-rendering it at an interval, |
||
9724 | // which is defined by this.getNowIndicatorUnit(). |
||
9725 | // TODO: somehow do this for the current whole day's background too |
||
9726 | startNowIndicator: function() { |
||
9727 | var _this = this; |
||
9728 | var unit; |
||
9729 | var update; |
||
9730 | var delay; // ms wait value |
||
9731 | |||
9732 | if (this.opt('nowIndicator')) { |
||
9733 | unit = this.getNowIndicatorUnit(); |
||
9734 | if (unit) { |
||
9735 | update = proxy(this, 'updateNowIndicator'); // bind to `this` |
||
9736 | |||
9737 | this.initialNowDate = this.calendar.getNow(); |
||
9738 | this.initialNowQueriedMs = +new Date(); |
||
9739 | this.renderNowIndicator(this.initialNowDate); |
||
9740 | this.isNowIndicatorRendered = true; |
||
9741 | |||
9742 | // wait until the beginning of the next interval |
||
9743 | delay = this.initialNowDate.clone().startOf(unit).add(1, unit) - this.initialNowDate; |
||
9744 | this.nowIndicatorTimeoutID = setTimeout(function() { |
||
9745 | _this.nowIndicatorTimeoutID = null; |
||
9746 | update(); |
||
9747 | delay = +moment.duration(1, unit); |
||
9748 | delay = Math.max(100, delay); // prevent too frequent |
||
9749 | _this.nowIndicatorIntervalID = setInterval(update, delay); // update every interval |
||
9750 | }, delay); |
||
9751 | } |
||
9752 | } |
||
9753 | }, |
||
9754 | |||
9755 | |||
9756 | // rerenders the now indicator, computing the new current time from the amount of time that has passed |
||
9757 | // since the initial getNow call. |
||
9758 | updateNowIndicator: function() { |
||
9759 | if (this.isNowIndicatorRendered) { |
||
9760 | this.unrenderNowIndicator(); |
||
9761 | this.renderNowIndicator( |
||
9762 | this.initialNowDate.clone().add(new Date() - this.initialNowQueriedMs) // add ms |
||
9763 | ); |
||
9764 | } |
||
9765 | }, |
||
9766 | |||
9767 | |||
9768 | // Immediately unrenders the view's current time indicator and stops any re-rendering timers. |
||
9769 | // Won't cause side effects if indicator isn't rendered. |
||
9770 | stopNowIndicator: function() { |
||
9771 | if (this.isNowIndicatorRendered) { |
||
9772 | |||
9773 | if (this.nowIndicatorTimeoutID) { |
||
9774 | clearTimeout(this.nowIndicatorTimeoutID); |
||
9775 | this.nowIndicatorTimeoutID = null; |
||
9776 | } |
||
9777 | if (this.nowIndicatorIntervalID) { |
||
9778 | clearTimeout(this.nowIndicatorIntervalID); |
||
9779 | this.nowIndicatorIntervalID = null; |
||
9780 | } |
||
9781 | |||
9782 | this.unrenderNowIndicator(); |
||
9783 | this.isNowIndicatorRendered = false; |
||
9784 | } |
||
9785 | }, |
||
9786 | |||
9787 | |||
9788 | // Returns a string unit, like 'second' or 'minute' that defined how often the current time indicator |
||
9789 | // should be refreshed. If something falsy is returned, no time indicator is rendered at all. |
||
9790 | getNowIndicatorUnit: function() { |
||
9791 | // subclasses should implement |
||
9792 | }, |
||
9793 | |||
9794 | |||
9795 | // Renders a current time indicator at the given datetime |
||
9796 | renderNowIndicator: function(date) { |
||
9797 | // subclasses should implement |
||
9798 | }, |
||
9799 | |||
9800 | |||
9801 | // Undoes the rendering actions from renderNowIndicator |
||
9802 | unrenderNowIndicator: function() { |
||
9803 | // subclasses should implement |
||
9804 | }, |
||
9805 | |||
9806 | |||
9807 | /* Dimensions |
||
9808 | ------------------------------------------------------------------------------------------------------------------*/ |
||
9809 | |||
9810 | |||
9811 | // Refreshes anything dependant upon sizing of the container element of the grid |
||
9812 | updateSize: function(isResize) { |
||
9813 | var scroll; |
||
9814 | |||
9815 | if (isResize) { |
||
9816 | scroll = this.queryScroll(); |
||
9817 | } |
||
9818 | |||
9819 | this.updateHeight(isResize); |
||
9820 | this.updateWidth(isResize); |
||
9821 | this.updateNowIndicator(); |
||
9822 | |||
9823 | if (isResize) { |
||
9824 | this.applyScroll(scroll); |
||
9825 | } |
||
9826 | }, |
||
9827 | |||
9828 | |||
9829 | // Refreshes the horizontal dimensions of the calendar |
||
9830 | updateWidth: function(isResize) { |
||
9831 | // subclasses should implement |
||
9832 | }, |
||
9833 | |||
9834 | |||
9835 | // Refreshes the vertical dimensions of the calendar |
||
9836 | updateHeight: function(isResize) { |
||
9837 | var calendar = this.calendar; // we poll the calendar for height information |
||
9838 | |||
9839 | this.setHeight( |
||
9840 | calendar.getSuggestedViewHeight(), |
||
9841 | calendar.isHeightAuto() |
||
9842 | ); |
||
9843 | }, |
||
9844 | |||
9845 | |||
9846 | // Updates the vertical dimensions of the calendar to the specified height. |
||
9847 | // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height. |
||
9848 | setHeight: function(height, isAuto) { |
||
9849 | // subclasses should implement |
||
9850 | }, |
||
9851 | |||
9852 | |||
9853 | /* Scroller |
||
9854 | ------------------------------------------------------------------------------------------------------------------*/ |
||
9855 | |||
9856 | |||
9857 | addForcedScroll: function(scroll) { |
||
9858 | this.addScroll( |
||
9859 | $.extend(scroll, { isForced: true }) |
||
9860 | ); |
||
9861 | }, |
||
9862 | |||
9863 | |||
9864 | addScroll: function(scroll) { |
||
9865 | var queuedScroll = this.queuedScroll || (this.queuedScroll = {}); |
||
9866 | |||
9867 | if (!queuedScroll.isForced) { |
||
9868 | $.extend(queuedScroll, scroll); |
||
9869 | } |
||
9870 | }, |
||
9871 | |||
9872 | |||
9873 | popScroll: function() { |
||
9874 | this.applyQueuedScroll(); |
||
9875 | this.queuedScroll = null; |
||
9876 | }, |
||
9877 | |||
9878 | |||
9879 | applyQueuedScroll: function() { |
||
9880 | if (this.queuedScroll) { |
||
9881 | this.applyScroll(this.queuedScroll); |
||
9882 | } |
||
9883 | }, |
||
9884 | |||
9885 | |||
9886 | queryScroll: function() { |
||
9887 | var scroll = {}; |
||
9888 | |||
9889 | if (this.isDatesRendered) { |
||
9890 | $.extend(scroll, this.queryDateScroll()); |
||
9891 | } |
||
9892 | |||
9893 | return scroll; |
||
9894 | }, |
||
9895 | |||
9896 | |||
9897 | applyScroll: function(scroll) { |
||
9898 | if (this.isDatesRendered) { |
||
9899 | this.applyDateScroll(scroll); |
||
9900 | } |
||
9901 | }, |
||
9902 | |||
9903 | |||
9904 | computeInitialDateScroll: function() { |
||
9905 | return {}; // subclasses must implement |
||
9906 | }, |
||
9907 | |||
9908 | |||
9909 | queryDateScroll: function() { |
||
9910 | return {}; // subclasses must implement |
||
9911 | }, |
||
9912 | |||
9913 | |||
9914 | applyDateScroll: function(scroll) { |
||
9915 | ; // subclasses must implement |
||
9916 | }, |
||
9917 | |||
9918 | |||
9919 | /* Height Freezing |
||
9920 | ------------------------------------------------------------------------------------------------------------------*/ |
||
9921 | |||
9922 | |||
9923 | freezeHeight: function() { |
||
9924 | this.calendar.freezeContentHeight(); |
||
9925 | }, |
||
9926 | |||
9927 | |||
9928 | thawHeight: function() { |
||
9929 | this.calendar.thawContentHeight(); |
||
9930 | }, |
||
9931 | |||
9932 | |||
9933 | // Event High-level Rendering |
||
9934 | // ----------------------------------------------------------------------------------------------------------------- |
||
9935 | |||
9936 | |||
9937 | executeEventsRender: function(events) { |
||
9938 | this.renderEvents(events); |
||
9939 | this.isEventsRendered = true; |
||
9940 | |||
9941 | this.onEventsRender(); |
||
9942 | }, |
||
9943 | |||
9944 | |||
9945 | executeEventsUnrender: function() { |
||
9946 | this.onBeforeEventsUnrender(); |
||
9947 | |||
9948 | if (this.destroyEvents) { |
||
9949 | this.destroyEvents(); // TODO: deprecate |
||
9950 | } |
||
9951 | |||
9952 | this.unrenderEvents(); |
||
9953 | this.isEventsRendered = false; |
||
9954 | }, |
||
9955 | |||
9956 | |||
9957 | // Event Rendering Triggers |
||
9958 | // ----------------------------------------------------------------------------------------------------------------- |
||
9959 | |||
9960 | |||
9961 | // Signals that all events have been rendered |
||
9962 | onEventsRender: function() { |
||
9963 | this.applyScreenState(); |
||
9964 | |||
9965 | this.renderedEventSegEach(function(seg) { |
||
9966 | this.publiclyTrigger('eventAfterRender', seg.event, seg.event, seg.el); |
||
9967 | }); |
||
9968 | this.publiclyTrigger('eventAfterAllRender'); |
||
9969 | }, |
||
9970 | |||
9971 | |||
9972 | // Signals that all event elements are about to be removed |
||
9973 | onBeforeEventsUnrender: function() { |
||
9974 | this.applyScreenState(); |
||
9975 | |||
9976 | this.renderedEventSegEach(function(seg) { |
||
9977 | this.publiclyTrigger('eventDestroy', seg.event, seg.event, seg.el); |
||
9978 | }); |
||
9979 | }, |
||
9980 | |||
9981 | |||
9982 | applyScreenState: function() { |
||
9983 | this.thawHeight(); |
||
9984 | this.freezeHeight(); |
||
9985 | this.applyQueuedScroll(); |
||
9986 | }, |
||
9987 | |||
9988 | |||
9989 | // Event Low-level Rendering |
||
9990 | // ----------------------------------------------------------------------------------------------------------------- |
||
9991 | |||
9992 | |||
9993 | // Renders the events onto the view. |
||
9994 | renderEvents: function(events) { |
||
9995 | // subclasses should implement |
||
9996 | }, |
||
9997 | |||
9998 | |||
9999 | // Removes event elements from the view. |
||
10000 | unrenderEvents: function() { |
||
10001 | // subclasses should implement |
||
10002 | }, |
||
10003 | |||
10004 | |||
10005 | // Event Rendering Utils |
||
10006 | // ----------------------------------------------------------------------------------------------------------------- |
||
10007 | |||
10008 | |||
10009 | // Given an event and the default element used for rendering, returns the element that should actually be used. |
||
10010 | // Basically runs events and elements through the eventRender hook. |
||
10011 | resolveEventEl: function(event, el) { |
||
10012 | var custom = this.publiclyTrigger('eventRender', event, event, el); |
||
10013 | |||
10014 | if (custom === false) { // means don't render at all |
||
10015 | el = null; |
||
10016 | } |
||
10017 | else if (custom && custom !== true) { |
||
10018 | el = $(custom); |
||
10019 | } |
||
10020 | |||
10021 | return el; |
||
10022 | }, |
||
10023 | |||
10024 | |||
10025 | // Hides all rendered event segments linked to the given event |
||
10026 | showEvent: function(event) { |
||
10027 | this.renderedEventSegEach(function(seg) { |
||
10028 | seg.el.css('visibility', ''); |
||
10029 | }, event); |
||
10030 | }, |
||
10031 | |||
10032 | |||
10033 | // Shows all rendered event segments linked to the given event |
||
10034 | hideEvent: function(event) { |
||
10035 | this.renderedEventSegEach(function(seg) { |
||
10036 | seg.el.css('visibility', 'hidden'); |
||
10037 | }, event); |
||
10038 | }, |
||
10039 | |||
10040 | |||
10041 | // Iterates through event segments that have been rendered (have an el). Goes through all by default. |
||
10042 | // If the optional `event` argument is specified, only iterates through segments linked to that event. |
||
10043 | // The `this` value of the callback function will be the view. |
||
10044 | renderedEventSegEach: function(func, event) { |
||
10045 | var segs = this.getEventSegs(); |
||
10046 | var i; |
||
10047 | |||
10048 | for (i = 0; i < segs.length; i++) { |
||
10049 | if (!event || segs[i].event._id === event._id) { |
||
10050 | if (segs[i].el) { |
||
10051 | func.call(this, segs[i]); |
||
10052 | } |
||
10053 | } |
||
10054 | } |
||
10055 | }, |
||
10056 | |||
10057 | |||
10058 | // Retrieves all the rendered segment objects for the view |
||
10059 | getEventSegs: function() { |
||
10060 | // subclasses must implement |
||
10061 | return []; |
||
10062 | }, |
||
10063 | |||
10064 | |||
10065 | /* Event Drag-n-Drop |
||
10066 | ------------------------------------------------------------------------------------------------------------------*/ |
||
10067 | |||
10068 | |||
10069 | // Computes if the given event is allowed to be dragged by the user |
||
10070 | isEventDraggable: function(event) { |
||
10071 | return this.isEventStartEditable(event); |
||
10072 | }, |
||
10073 | |||
10074 | |||
10075 | isEventStartEditable: function(event) { |
||
10076 | return firstDefined( |
||
10077 | event.startEditable, |
||
10078 | (event.source || {}).startEditable, |
||
10079 | this.opt('eventStartEditable'), |
||
10080 | this.isEventGenerallyEditable(event) |
||
10081 | ); |
||
10082 | }, |
||
10083 | |||
10084 | |||
10085 | isEventGenerallyEditable: function(event) { |
||
10086 | return firstDefined( |
||
10087 | event.editable, |
||
10088 | (event.source || {}).editable, |
||
10089 | this.opt('editable') |
||
10090 | ); |
||
10091 | }, |
||
10092 | |||
10093 | |||
10094 | // Must be called when an event in the view is dropped onto new location. |
||
10095 | // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event. |
||
10096 | reportSegDrop: function(seg, dropLocation, largeUnit, el, ev) { |
||
10097 | var calendar = this.calendar; |
||
10098 | var mutateResult = calendar.mutateSeg(seg, dropLocation, largeUnit); |
||
10099 | var undoFunc = function() { |
||
10100 | mutateResult.undo(); |
||
10101 | calendar.reportEventChange(); |
||
10102 | }; |
||
10103 | |||
10104 | this.triggerEventDrop(seg.event, mutateResult.dateDelta, undoFunc, el, ev); |
||
10105 | calendar.reportEventChange(); // will rerender events |
||
10106 | }, |
||
10107 | |||
10108 | |||
10109 | // Triggers event-drop handlers that have subscribed via the API |
||
10110 | triggerEventDrop: function(event, dateDelta, undoFunc, el, ev) { |
||
10111 | this.publiclyTrigger('eventDrop', el[0], event, dateDelta, undoFunc, ev, {}); // {} = jqui dummy |
||
10112 | }, |
||
10113 | |||
10114 | |||
10115 | /* External Element Drag-n-Drop |
||
10116 | ------------------------------------------------------------------------------------------------------------------*/ |
||
10117 | |||
10118 | |||
10119 | // Must be called when an external element, via jQuery UI, has been dropped onto the calendar. |
||
10120 | // `meta` is the parsed data that has been embedded into the dragging event. |
||
10121 | // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event. |
||
10122 | reportExternalDrop: function(meta, dropLocation, el, ev, ui) { |
||
10123 | var eventProps = meta.eventProps; |
||
10124 | var eventInput; |
||
10125 | var event; |
||
10126 | |||
10127 | // Try to build an event object and render it. TODO: decouple the two |
||
10128 | if (eventProps) { |
||
10129 | eventInput = $.extend({}, eventProps, dropLocation); |
||
10130 | event = this.calendar.renderEvent(eventInput, meta.stick)[0]; // renderEvent returns an array |
||
10131 | } |
||
10132 | |||
10133 | this.triggerExternalDrop(event, dropLocation, el, ev, ui); |
||
10134 | }, |
||
10135 | |||
10136 | |||
10137 | // Triggers external-drop handlers that have subscribed via the API |
||
10138 | triggerExternalDrop: function(event, dropLocation, el, ev, ui) { |
||
10139 | |||
10140 | // trigger 'drop' regardless of whether element represents an event |
||
10141 | this.publiclyTrigger('drop', el[0], dropLocation.start, ev, ui); |
||
10142 | |||
10143 | if (event) { |
||
10144 | this.publiclyTrigger('eventReceive', null, event); // signal an external event landed |
||
10145 | } |
||
10146 | }, |
||
10147 | |||
10148 | |||
10149 | /* Drag-n-Drop Rendering (for both events and external elements) |
||
10150 | ------------------------------------------------------------------------------------------------------------------*/ |
||
10151 | |||
10152 | |||
10153 | // Renders a visual indication of a event or external-element drag over the given drop zone. |
||
10154 | // If an external-element, seg will be `null`. |
||
10155 | // Must return elements used for any mock events. |
||
10156 | renderDrag: function(dropLocation, seg) { |
||
10157 | // subclasses must implement |
||
10158 | }, |
||
10159 | |||
10160 | |||
10161 | // Unrenders a visual indication of an event or external-element being dragged. |
||
10162 | unrenderDrag: function() { |
||
10163 | // subclasses must implement |
||
10164 | }, |
||
10165 | |||
10166 | |||
10167 | /* Event Resizing |
||
10168 | ------------------------------------------------------------------------------------------------------------------*/ |
||
10169 | |||
10170 | |||
10171 | // Computes if the given event is allowed to be resized from its starting edge |
||
10172 | isEventResizableFromStart: function(event) { |
||
10173 | return this.opt('eventResizableFromStart') && this.isEventResizable(event); |
||
10174 | }, |
||
10175 | |||
10176 | |||
10177 | // Computes if the given event is allowed to be resized from its ending edge |
||
10178 | isEventResizableFromEnd: function(event) { |
||
10179 | return this.isEventResizable(event); |
||
10180 | }, |
||
10181 | |||
10182 | |||
10183 | // Computes if the given event is allowed to be resized by the user at all |
||
10184 | isEventResizable: function(event) { |
||
10185 | var source = event.source || {}; |
||
10186 | |||
10187 | return firstDefined( |
||
10188 | event.durationEditable, |
||
10189 | source.durationEditable, |
||
10190 | this.opt('eventDurationEditable'), |
||
10191 | event.editable, |
||
10192 | source.editable, |
||
10193 | this.opt('editable') |
||
10194 | ); |
||
10195 | }, |
||
10196 | |||
10197 | |||
10198 | // Must be called when an event in the view has been resized to a new length |
||
10199 | reportSegResize: function(seg, resizeLocation, largeUnit, el, ev) { |
||
10200 | var calendar = this.calendar; |
||
10201 | var mutateResult = calendar.mutateSeg(seg, resizeLocation, largeUnit); |
||
10202 | var undoFunc = function() { |
||
10203 | mutateResult.undo(); |
||
10204 | calendar.reportEventChange(); |
||
10205 | }; |
||
10206 | |||
10207 | this.triggerEventResize(seg.event, mutateResult.durationDelta, undoFunc, el, ev); |
||
10208 | calendar.reportEventChange(); // will rerender events |
||
10209 | }, |
||
10210 | |||
10211 | |||
10212 | // Triggers event-resize handlers that have subscribed via the API |
||
10213 | triggerEventResize: function(event, durationDelta, undoFunc, el, ev) { |
||
10214 | this.publiclyTrigger('eventResize', el[0], event, durationDelta, undoFunc, ev, {}); // {} = jqui dummy |
||
10215 | }, |
||
10216 | |||
10217 | |||
10218 | /* Selection (time range) |
||
10219 | ------------------------------------------------------------------------------------------------------------------*/ |
||
10220 | |||
10221 | |||
10222 | // Selects a date span on the view. `start` and `end` are both Moments. |
||
10223 | // `ev` is the native mouse event that begin the interaction. |
||
10224 | select: function(span, ev) { |
||
10225 | this.unselect(ev); |
||
10226 | this.renderSelection(span); |
||
10227 | this.reportSelection(span, ev); |
||
10228 | }, |
||
10229 | |||
10230 | |||
10231 | // Renders a visual indication of the selection |
||
10232 | renderSelection: function(span) { |
||
10233 | // subclasses should implement |
||
10234 | }, |
||
10235 | |||
10236 | |||
10237 | // Called when a new selection is made. Updates internal state and triggers handlers. |
||
10238 | reportSelection: function(span, ev) { |
||
10239 | this.isSelected = true; |
||
10240 | this.triggerSelect(span, ev); |
||
10241 | }, |
||
10242 | |||
10243 | |||
10244 | // Triggers handlers to 'select' |
||
10245 | triggerSelect: function(span, ev) { |
||
10246 | this.publiclyTrigger( |
||
10247 | 'select', |
||
10248 | null, |
||
10249 | this.calendar.applyTimezone(span.start), // convert to calendar's tz for external API |
||
10250 | this.calendar.applyTimezone(span.end), // " |
||
10251 | ev |
||
10252 | ); |
||
10253 | }, |
||
10254 | |||
10255 | |||
10256 | // Undoes a selection. updates in the internal state and triggers handlers. |
||
10257 | // `ev` is the native mouse event that began the interaction. |
||
10258 | unselect: function(ev) { |
||
10259 | if (this.isSelected) { |
||
10260 | this.isSelected = false; |
||
10261 | if (this.destroySelection) { |
||
10262 | this.destroySelection(); // TODO: deprecate |
||
10263 | } |
||
10264 | this.unrenderSelection(); |
||
10265 | this.publiclyTrigger('unselect', null, ev); |
||
10266 | } |
||
10267 | }, |
||
10268 | |||
10269 | |||
10270 | // Unrenders a visual indication of selection |
||
10271 | unrenderSelection: function() { |
||
10272 | // subclasses should implement |
||
10273 | }, |
||
10274 | |||
10275 | |||
10276 | /* Event Selection |
||
10277 | ------------------------------------------------------------------------------------------------------------------*/ |
||
10278 | |||
10279 | |||
10280 | selectEvent: function(event) { |
||
10281 | if (!this.selectedEvent || this.selectedEvent !== event) { |
||
10282 | this.unselectEvent(); |
||
10283 | this.renderedEventSegEach(function(seg) { |
||
10284 | seg.el.addClass('fc-selected'); |
||
10285 | }, event); |
||
10286 | this.selectedEvent = event; |
||
10287 | } |
||
10288 | }, |
||
10289 | |||
10290 | |||
10291 | unselectEvent: function() { |
||
10292 | if (this.selectedEvent) { |
||
10293 | this.renderedEventSegEach(function(seg) { |
||
10294 | seg.el.removeClass('fc-selected'); |
||
10295 | }, this.selectedEvent); |
||
10296 | this.selectedEvent = null; |
||
10297 | } |
||
10298 | }, |
||
10299 | |||
10300 | |||
10301 | isEventSelected: function(event) { |
||
10302 | // event references might change on refetchEvents(), while selectedEvent doesn't, |
||
10303 | // so compare IDs |
||
10304 | return this.selectedEvent && this.selectedEvent._id === event._id; |
||
10305 | }, |
||
10306 | |||
10307 | |||
10308 | /* Mouse / Touch Unselecting (time range & event unselection) |
||
10309 | ------------------------------------------------------------------------------------------------------------------*/ |
||
10310 | // TODO: move consistently to down/start or up/end? |
||
10311 | // TODO: don't kill previous selection if touch scrolling |
||
10312 | |||
10313 | |||
10314 | handleDocumentMousedown: function(ev) { |
||
10315 | if (isPrimaryMouseButton(ev)) { |
||
10316 | this.processUnselect(ev); |
||
10317 | } |
||
10318 | }, |
||
10319 | |||
10320 | |||
10321 | processUnselect: function(ev) { |
||
10322 | this.processRangeUnselect(ev); |
||
10323 | this.processEventUnselect(ev); |
||
10324 | }, |
||
10325 | |||
10326 | |||
10327 | processRangeUnselect: function(ev) { |
||
10328 | var ignore; |
||
10329 | |||
10330 | // is there a time-range selection? |
||
10331 | if (this.isSelected && this.opt('unselectAuto')) { |
||
10332 | // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element |
||
10333 | ignore = this.opt('unselectCancel'); |
||
10334 | if (!ignore || !$(ev.target).closest(ignore).length) { |
||
10335 | this.unselect(ev); |
||
10336 | } |
||
10337 | } |
||
10338 | }, |
||
10339 | |||
10340 | |||
10341 | processEventUnselect: function(ev) { |
||
10342 | if (this.selectedEvent) { |
||
10343 | if (!$(ev.target).closest('.fc-selected').length) { |
||
10344 | this.unselectEvent(); |
||
10345 | } |
||
10346 | } |
||
10347 | }, |
||
10348 | |||
10349 | |||
10350 | /* Day Click |
||
10351 | ------------------------------------------------------------------------------------------------------------------*/ |
||
10352 | |||
10353 | |||
10354 | // Triggers handlers to 'dayClick' |
||
10355 | // Span has start/end of the clicked area. Only the start is useful. |
||
10356 | triggerDayClick: function(span, dayEl, ev) { |
||
10357 | this.publiclyTrigger( |
||
10358 | 'dayClick', |
||
10359 | dayEl, |
||
10360 | this.calendar.applyTimezone(span.start), // convert to calendar's timezone for external API |
||
10361 | ev |
||
10362 | ); |
||
10363 | }, |
||
10364 | |||
10365 | |||
10366 | /* Date Utils |
||
10367 | ------------------------------------------------------------------------------------------------------------------*/ |
||
10368 | |||
10369 | |||
10370 | // Returns the date range of the full days the given range visually appears to occupy. |
||
10371 | // Returns a new range object. |
||
10372 | computeDayRange: function(range) { |
||
10373 | var startDay = range.start.clone().stripTime(); // the beginning of the day the range starts |
||
10374 | var end = range.end; |
||
10375 | var endDay = null; |
||
10376 | var endTimeMS; |
||
10377 | |||
10378 | if (end) { |
||
10379 | endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends |
||
10380 | endTimeMS = +end.time(); // # of milliseconds into `endDay` |
||
10381 | |||
10382 | // If the end time is actually inclusively part of the next day and is equal to or |
||
10383 | // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`. |
||
10384 | // Otherwise, leaving it as inclusive will cause it to exclude `endDay`. |
||
10385 | if (endTimeMS && endTimeMS >= this.nextDayThreshold) { |
||
10386 | endDay.add(1, 'days'); |
||
10387 | } |
||
10388 | } |
||
10389 | |||
10390 | // If no end was specified, or if it is within `startDay` but not past nextDayThreshold, |
||
10391 | // assign the default duration of one day. |
||
10392 | if (!end || endDay <= startDay) { |
||
10393 | endDay = startDay.clone().add(1, 'days'); |
||
10394 | } |
||
10395 | |||
10396 | return { start: startDay, end: endDay }; |
||
10397 | }, |
||
10398 | |||
10399 | |||
10400 | // Does the given event visually appear to occupy more than one day? |
||
10401 | isMultiDayEvent: function(event) { |
||
10402 | var range = this.computeDayRange(event); // event is range-ish |
||
10403 | |||
10404 | return range.end.diff(range.start, 'days') > 1; |
||
10405 | } |
||
10406 | |||
10407 | }); |
||
10408 | |||
10409 | |||
10410 | View.watch('displayingDates', [ 'dateProfile' ], function(deps) { |
||
10411 | this.requestDateRender(deps.dateProfile); |
||
10412 | }, function() { |
||
10413 | this.requestDateUnrender(); |
||
10414 | }); |
||
10415 | |||
10416 | |||
10417 | View.watch('initialEvents', [ 'dateProfile' ], function(deps) { |
||
10418 | return this.fetchInitialEvents(deps.dateProfile); |
||
10419 | }); |
||
10420 | |||
10421 | |||
10422 | View.watch('bindingEvents', [ 'initialEvents' ], function(deps) { |
||
10423 | this.setEvents(deps.initialEvents); |
||
10424 | this.bindEventChanges(); |
||
10425 | }, function() { |
||
10426 | this.unbindEventChanges(); |
||
10427 | this.unsetEvents(); |
||
10428 | }); |
||
10429 | |||
10430 | |||
10431 | View.watch('displayingEvents', [ 'displayingDates', 'hasEvents' ], function() { |
||
10432 | this.requestEventsRender(this.get('currentEvents')); // if there were event mutations after initialEvents |
||
10433 | }, function() { |
||
10434 | this.requestEventsUnrender(); |
||
10435 | }); |
||
10436 | |||
10437 | ;; |
||
10438 | |||
10439 | View.mixin({ |
||
10440 | |||
10441 | // range the view is formally responsible for. |
||
10442 | // for example, a month view might have 1st-31st, excluding padded dates |
||
10443 | currentRange: null, |
||
10444 | currentRangeUnit: null, // name of largest unit being displayed, like "month" or "week" |
||
10445 | |||
10446 | // date range with a rendered skeleton |
||
10447 | // includes not-active days that need some sort of DOM |
||
10448 | renderRange: null, |
||
10449 | |||
10450 | // dates that display events and accept drag-n-drop |
||
10451 | activeRange: null, |
||
10452 | |||
10453 | // constraint for where prev/next operations can go and where events can be dragged/resized to. |
||
10454 | // an object with optional start and end properties. |
||
10455 | validRange: null, |
||
10456 | |||
10457 | // how far the current date will move for a prev/next operation |
||
10458 | dateIncrement: null, |
||
10459 | |||
10460 | minTime: null, // Duration object that denotes the first visible time of any given day |
||
10461 | maxTime: null, // Duration object that denotes the exclusive visible end time of any given day |
||
10462 | usesMinMaxTime: false, // whether minTime/maxTime will affect the activeRange. Views must opt-in. |
||
10463 | |||
10464 | // DEPRECATED |
||
10465 | start: null, // use activeRange.start |
||
10466 | end: null, // use activeRange.end |
||
10467 | intervalStart: null, // use currentRange.start |
||
10468 | intervalEnd: null, // use currentRange.end |
||
10469 | |||
10470 | |||
10471 | /* Date Range Computation |
||
10472 | ------------------------------------------------------------------------------------------------------------------*/ |
||
10473 | |||
10474 | |||
10475 | setDateProfileForRendering: function(dateProfile) { |
||
10476 | this.currentRange = dateProfile.currentRange; |
||
10477 | this.currentRangeUnit = dateProfile.currentRangeUnit; |
||
10478 | this.renderRange = dateProfile.renderRange; |
||
10479 | this.activeRange = dateProfile.activeRange; |
||
10480 | this.validRange = dateProfile.validRange; |
||
10481 | this.dateIncrement = dateProfile.dateIncrement; |
||
10482 | this.minTime = dateProfile.minTime; |
||
10483 | this.maxTime = dateProfile.maxTime; |
||
10484 | |||
10485 | // DEPRECATED, but we need to keep it updated |
||
10486 | this.start = dateProfile.activeRange.start; |
||
10487 | this.end = dateProfile.activeRange.end; |
||
10488 | this.intervalStart = dateProfile.currentRange.start; |
||
10489 | this.intervalEnd = dateProfile.currentRange.end; |
||
10490 | }, |
||
10491 | |||
10492 | |||
10493 | // Builds a structure with info about what the dates/ranges will be for the "prev" view. |
||
10494 | buildPrevDateProfile: function(date) { |
||
10495 | var prevDate = date.clone().startOf(this.currentRangeUnit).subtract(this.dateIncrement); |
||
10496 | |||
10497 | return this.buildDateProfile(prevDate, -1); |
||
10498 | }, |
||
10499 | |||
10500 | |||
10501 | // Builds a structure with info about what the dates/ranges will be for the "next" view. |
||
10502 | buildNextDateProfile: function(date) { |
||
10503 | var nextDate = date.clone().startOf(this.currentRangeUnit).add(this.dateIncrement); |
||
10504 | |||
10505 | return this.buildDateProfile(nextDate, 1); |
||
10506 | }, |
||
10507 | |||
10508 | |||
10509 | // Builds a structure holding dates/ranges for rendering around the given date. |
||
10510 | // Optional direction param indicates whether the date is being incremented/decremented |
||
10511 | // from its previous value. decremented = -1, incremented = 1 (default). |
||
10512 | buildDateProfile: function(date, direction, forceToValid) { |
||
10513 | var validRange = this.buildValidRange(); |
||
10514 | var minTime = null; |
||
10515 | var maxTime = null; |
||
10516 | var currentInfo; |
||
10517 | var renderRange; |
||
10518 | var activeRange; |
||
10519 | var isValid; |
||
10520 | |||
10521 | if (forceToValid) { |
||
10522 | date = constrainDate(date, validRange); |
||
10523 | } |
||
10524 | |||
10525 | currentInfo = this.buildCurrentRangeInfo(date, direction); |
||
10526 | renderRange = this.buildRenderRange(currentInfo.range, currentInfo.unit); |
||
10527 | activeRange = cloneRange(renderRange); |
||
10528 | |||
10529 | if (!this.opt('showNonCurrentDates')) { |
||
10530 | activeRange = constrainRange(activeRange, currentInfo.range); |
||
10531 | } |
||
10532 | |||
10533 | minTime = moment.duration(this.opt('minTime')); |
||
10534 | maxTime = moment.duration(this.opt('maxTime')); |
||
10535 | this.adjustActiveRange(activeRange, minTime, maxTime); |
||
10536 | |||
10537 | activeRange = constrainRange(activeRange, validRange); |
||
10538 | date = constrainDate(date, activeRange); |
||
10539 | |||
10540 | // it's invalid if the originally requested date is not contained, |
||
10541 | // or if the range is completely outside of the valid range. |
||
10542 | isValid = doRangesIntersect(currentInfo.range, validRange); |
||
10543 | |||
10544 | return { |
||
10545 | validRange: validRange, |
||
10546 | currentRange: currentInfo.range, |
||
10547 | currentRangeUnit: currentInfo.unit, |
||
10548 | activeRange: activeRange, |
||
10549 | renderRange: renderRange, |
||
10550 | minTime: minTime, |
||
10551 | maxTime: maxTime, |
||
10552 | isValid: isValid, |
||
10553 | date: date, |
||
10554 | dateIncrement: this.buildDateIncrement(currentInfo.duration) |
||
10555 | // pass a fallback (might be null) ^ |
||
10556 | }; |
||
10557 | }, |
||
10558 | |||
10559 | |||
10560 | // Builds an object with optional start/end properties. |
||
10561 | // Indicates the minimum/maximum dates to display. |
||
10562 | buildValidRange: function() { |
||
10563 | return this.getRangeOption('validRange', this.calendar.getNow()) || {}; |
||
10564 | }, |
||
10565 | |||
10566 | |||
10567 | // Builds a structure with info about the "current" range, the range that is |
||
10568 | // highlighted as being the current month for example. |
||
10569 | // See buildDateProfile for a description of `direction`. |
||
10570 | // Guaranteed to have `range` and `unit` properties. `duration` is optional. |
||
10571 | buildCurrentRangeInfo: function(date, direction) { |
||
10572 | var duration = null; |
||
10573 | var unit = null; |
||
10574 | var range = null; |
||
10575 | var dayCount; |
||
10576 | |||
10577 | if (this.viewSpec.duration) { |
||
10578 | duration = this.viewSpec.duration; |
||
10579 | unit = this.viewSpec.durationUnit; |
||
10580 | range = this.buildRangeFromDuration(date, direction, duration, unit); |
||
10581 | } |
||
10582 | else if ((dayCount = this.opt('dayCount'))) { |
||
10583 | unit = 'day'; |
||
10584 | range = this.buildRangeFromDayCount(date, direction, dayCount); |
||
10585 | } |
||
10586 | else if ((range = this.buildCustomVisibleRange(date))) { |
||
10587 | unit = computeGreatestUnit(range.start, range.end); |
||
10588 | } |
||
10589 | else { |
||
10590 | duration = this.getFallbackDuration(); |
||
10591 | unit = computeGreatestUnit(duration); |
||
10592 | range = this.buildRangeFromDuration(date, direction, duration, unit); |
||
10593 | } |
||
10594 | |||
10595 | this.normalizeCurrentRange(range, unit); // modifies in-place |
||
10596 | |||
10597 | return { duration: duration, unit: unit, range: range }; |
||
10598 | }, |
||
10599 | |||
10600 | |||
10601 | getFallbackDuration: function() { |
||
10602 | return moment.duration({ days: 1 }); |
||
10603 | }, |
||
10604 | |||
10605 | |||
10606 | // If the range has day units or larger, remove times. Otherwise, ensure times. |
||
10607 | normalizeCurrentRange: function(range, unit) { |
||
10608 | |||
10609 | if (/^(year|month|week|day)$/.test(unit)) { // whole-days? |
||
10610 | range.start.stripTime(); |
||
10611 | range.end.stripTime(); |
||
10612 | } |
||
10613 | else { // needs to have a time? |
||
10614 | if (!range.start.hasTime()) { |
||
10615 | range.start.time(0); // give 00:00 time |
||
10616 | } |
||
10617 | if (!range.end.hasTime()) { |
||
10618 | range.end.time(0); // give 00:00 time |
||
10619 | } |
||
10620 | } |
||
10621 | }, |
||
10622 | |||
10623 | |||
10624 | // Mutates the given activeRange to have time values (un-ambiguate) |
||
10625 | // if the minTime or maxTime causes the range to expand. |
||
10626 | // TODO: eventually activeRange should *always* have times. |
||
10627 | adjustActiveRange: function(range, minTime, maxTime) { |
||
10628 | var hasSpecialTimes = false; |
||
10629 | |||
10630 | if (this.usesMinMaxTime) { |
||
10631 | |||
10632 | if (minTime < 0) { |
||
10633 | range.start.time(0).add(minTime); |
||
10634 | hasSpecialTimes = true; |
||
10635 | } |
||
10636 | |||
10637 | if (maxTime > 24 * 60 * 60 * 1000) { // beyond 24 hours? |
||
10638 | range.end.time(maxTime - (24 * 60 * 60 * 1000)); |
||
10639 | hasSpecialTimes = true; |
||
10640 | } |
||
10641 | |||
10642 | if (hasSpecialTimes) { |
||
10643 | if (!range.start.hasTime()) { |
||
10644 | range.start.time(0); |
||
10645 | } |
||
10646 | if (!range.end.hasTime()) { |
||
10647 | range.end.time(0); |
||
10648 | } |
||
10649 | } |
||
10650 | } |
||
10651 | }, |
||
10652 | |||
10653 | |||
10654 | // Builds the "current" range when it is specified as an explicit duration. |
||
10655 | // `unit` is the already-computed computeGreatestUnit value of duration. |
||
10656 | buildRangeFromDuration: function(date, direction, duration, unit) { |
||
10657 | var alignment = this.opt('dateAlignment'); |
||
10658 | var start = date.clone(); |
||
10659 | var end; |
||
10660 | var dateIncrementInput; |
||
10661 | var dateIncrementDuration; |
||
10662 | |||
10663 | // if the view displays a single day or smaller |
||
10664 | if (duration.as('days') <= 1) { |
||
10665 | if (this.isHiddenDay(start)) { |
||
10666 | start = this.skipHiddenDays(start, direction); |
||
10667 | start.startOf('day'); |
||
10668 | } |
||
10669 | } |
||
10670 | |||
10671 | // compute what the alignment should be |
||
10672 | if (!alignment) { |
||
10673 | dateIncrementInput = this.opt('dateIncrement'); |
||
10674 | |||
10675 | if (dateIncrementInput) { |
||
10676 | dateIncrementDuration = moment.duration(dateIncrementInput); |
||
10677 | |||
10678 | // use the smaller of the two units |
||
10679 | if (dateIncrementDuration < duration) { |
||
10680 | alignment = computeDurationGreatestUnit(dateIncrementDuration, dateIncrementInput); |
||
10681 | } |
||
10682 | else { |
||
10683 | alignment = unit; |
||
10684 | } |
||
10685 | } |
||
10686 | else { |
||
10687 | alignment = unit; |
||
10688 | } |
||
10689 | } |
||
10690 | |||
10691 | start.startOf(alignment); |
||
10692 | end = start.clone().add(duration); |
||
10693 | |||
10694 | return { start: start, end: end }; |
||
10695 | }, |
||
10696 | |||
10697 | |||
10698 | // Builds the "current" range when a dayCount is specified. |
||
10699 | buildRangeFromDayCount: function(date, direction, dayCount) { |
||
10700 | var customAlignment = this.opt('dateAlignment'); |
||
10701 | var runningCount = 0; |
||
10702 | var start = date.clone(); |
||
10703 | var end; |
||
10704 | |||
10705 | if (customAlignment) { |
||
10706 | start.startOf(customAlignment); |
||
10707 | } |
||
10708 | |||
10709 | start.startOf('day'); |
||
10710 | start = this.skipHiddenDays(start, direction); |
||
10711 | |||
10712 | end = start.clone(); |
||
10713 | do { |
||
10714 | end.add(1, 'day'); |
||
10715 | if (!this.isHiddenDay(end)) { |
||
10716 | runningCount++; |
||
10717 | } |
||
10718 | } while (runningCount < dayCount); |
||
10719 | |||
10720 | return { start: start, end: end }; |
||
10721 | }, |
||
10722 | |||
10723 | |||
10724 | // Builds a normalized range object for the "visible" range, |
||
10725 | // which is a way to define the currentRange and activeRange at the same time. |
||
10726 | buildCustomVisibleRange: function(date) { |
||
10727 | var visibleRange = this.getRangeOption( |
||
10728 | 'visibleRange', |
||
10729 | this.calendar.moment(date) // correct zone. also generates new obj that avoids mutations |
||
10730 | ); |
||
10731 | |||
10732 | if (visibleRange && (!visibleRange.start || !visibleRange.end)) { |
||
10733 | return null; |
||
10734 | } |
||
10735 | |||
10736 | return visibleRange; |
||
10737 | }, |
||
10738 | |||
10739 | |||
10740 | // Computes the range that will represent the element/cells for *rendering*, |
||
10741 | // but which may have voided days/times. |
||
10742 | buildRenderRange: function(currentRange, currentRangeUnit) { |
||
10743 | // cut off days in the currentRange that are hidden |
||
10744 | return this.trimHiddenDays(currentRange); |
||
10745 | }, |
||
10746 | |||
10747 | |||
10748 | // Compute the duration value that should be added/substracted to the current date |
||
10749 | // when a prev/next operation happens. |
||
10750 | buildDateIncrement: function(fallback) { |
||
10751 | var dateIncrementInput = this.opt('dateIncrement'); |
||
10752 | var customAlignment; |
||
10753 | |||
10754 | if (dateIncrementInput) { |
||
10755 | return moment.duration(dateIncrementInput); |
||
10756 | } |
||
10757 | else if ((customAlignment = this.opt('dateAlignment'))) { |
||
10758 | return moment.duration(1, customAlignment); |
||
10759 | } |
||
10760 | else if (fallback) { |
||
10761 | return fallback; |
||
10762 | } |
||
10763 | else { |
||
10764 | return moment.duration({ days: 1 }); |
||
10765 | } |
||
10766 | }, |
||
10767 | |||
10768 | |||
10769 | // Remove days from the beginning and end of the range that are computed as hidden. |
||
10770 | trimHiddenDays: function(inputRange) { |
||
10771 | return { |
||
10772 | start: this.skipHiddenDays(inputRange.start), |
||
10773 | end: this.skipHiddenDays(inputRange.end, -1, true) // exclusively move backwards |
||
10774 | }; |
||
10775 | }, |
||
10776 | |||
10777 | |||
10778 | // Compute the number of the give units in the "current" range. |
||
10779 | // Will return a floating-point number. Won't round. |
||
10780 | currentRangeAs: function(unit) { |
||
10781 | var currentRange = this.currentRange; |
||
10782 | return currentRange.end.diff(currentRange.start, unit, true); |
||
10783 | }, |
||
10784 | |||
10785 | |||
10786 | // Arguments after name will be forwarded to a hypothetical function value |
||
10787 | // WARNING: passed-in arguments will be given to generator functions as-is and can cause side-effects. |
||
10788 | // Always clone your objects if you fear mutation. |
||
10789 | getRangeOption: function(name) { |
||
10790 | var val = this.opt(name); |
||
10791 | |||
10792 | if (typeof val === 'function') { |
||
10793 | val = val.apply( |
||
10794 | null, |
||
10795 | Array.prototype.slice.call(arguments, 1) |
||
10796 | ); |
||
10797 | } |
||
10798 | |||
10799 | if (val) { |
||
10800 | return this.calendar.parseRange(val); |
||
10801 | } |
||
10802 | }, |
||
10803 | |||
10804 | |||
10805 | /* Hidden Days |
||
10806 | ------------------------------------------------------------------------------------------------------------------*/ |
||
10807 | |||
10808 | |||
10809 | // Initializes internal variables related to calculating hidden days-of-week |
||
10810 | initHiddenDays: function() { |
||
10811 | var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden |
||
10812 | var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool) |
||
10813 | var dayCnt = 0; |
||
10814 | var i; |
||
10815 | |||
10816 | if (this.opt('weekends') === false) { |
||
10817 | hiddenDays.push(0, 6); // 0=sunday, 6=saturday |
||
10818 | } |
||
10819 | |||
10820 | for (i = 0; i < 7; i++) { |
||
10821 | if ( |
||
10822 | !(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1) |
||
10823 | ) { |
||
10824 | dayCnt++; |
||
10825 | } |
||
10826 | } |
||
10827 | |||
10828 | if (!dayCnt) { |
||
10829 | throw 'invalid hiddenDays'; // all days were hidden? bad. |
||
10830 | } |
||
10831 | |||
10832 | this.isHiddenDayHash = isHiddenDayHash; |
||
10833 | }, |
||
10834 | |||
10835 | |||
10836 | // Is the current day hidden? |
||
10837 | // `day` is a day-of-week index (0-6), or a Moment |
||
10838 | isHiddenDay: function(day) { |
||
10839 | if (moment.isMoment(day)) { |
||
10840 | day = day.day(); |
||
10841 | } |
||
10842 | return this.isHiddenDayHash[day]; |
||
10843 | }, |
||
10844 | |||
10845 | |||
10846 | // Incrementing the current day until it is no longer a hidden day, returning a copy. |
||
10847 | // DOES NOT CONSIDER validRange! |
||
10848 | // If the initial value of `date` is not a hidden day, don't do anything. |
||
10849 | // Pass `isExclusive` as `true` if you are dealing with an end date. |
||
10850 | // `inc` defaults to `1` (increment one day forward each time) |
||
10851 | skipHiddenDays: function(date, inc, isExclusive) { |
||
10852 | var out = date.clone(); |
||
10853 | inc = inc || 1; |
||
10854 | while ( |
||
10855 | this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7] |
||
10856 | ) { |
||
10857 | out.add(inc, 'days'); |
||
10858 | } |
||
10859 | return out; |
||
10860 | } |
||
10861 | |||
10862 | }); |
||
10863 | |||
10864 | ;; |
||
10865 | |||
10866 | /* |
||
10867 | Embodies a div that has potential scrollbars |
||
10868 | */ |
||
10869 | var Scroller = FC.Scroller = Class.extend({ |
||
10870 | |||
10871 | el: null, // the guaranteed outer element |
||
10872 | scrollEl: null, // the element with the scrollbars |
||
10873 | overflowX: null, |
||
10874 | overflowY: null, |
||
10875 | |||
10876 | |||
10877 | constructor: function(options) { |
||
10878 | options = options || {}; |
||
10879 | this.overflowX = options.overflowX || options.overflow || 'auto'; |
||
10880 | this.overflowY = options.overflowY || options.overflow || 'auto'; |
||
10881 | }, |
||
10882 | |||
10883 | |||
10884 | render: function() { |
||
10885 | this.el = this.renderEl(); |
||
10886 | this.applyOverflow(); |
||
10887 | }, |
||
10888 | |||
10889 | |||
10890 | renderEl: function() { |
||
10891 | return (this.scrollEl = $('<div class="fc-scroller"></div>')); |
||
10892 | }, |
||
10893 | |||
10894 | |||
10895 | // sets to natural height, unlocks overflow |
||
10896 | clear: function() { |
||
10897 | this.setHeight('auto'); |
||
10898 | this.applyOverflow(); |
||
10899 | }, |
||
10900 | |||
10901 | |||
10902 | destroy: function() { |
||
10903 | this.el.remove(); |
||
10904 | }, |
||
10905 | |||
10906 | |||
10907 | // Overflow |
||
10908 | // ----------------------------------------------------------------------------------------------------------------- |
||
10909 | |||
10910 | |||
10911 | applyOverflow: function() { |
||
10912 | this.scrollEl.css({ |
||
10913 | 'overflow-x': this.overflowX, |
||
10914 | 'overflow-y': this.overflowY |
||
10915 | }); |
||
10916 | }, |
||
10917 | |||
10918 | |||
10919 | // Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'. |
||
10920 | // Useful for preserving scrollbar widths regardless of future resizes. |
||
10921 | // Can pass in scrollbarWidths for optimization. |
||
10922 | lockOverflow: function(scrollbarWidths) { |
||
10923 | var overflowX = this.overflowX; |
||
10924 | var overflowY = this.overflowY; |
||
10925 | |||
10926 | scrollbarWidths = scrollbarWidths || this.getScrollbarWidths(); |
||
10927 | |||
10928 | if (overflowX === 'auto') { |
||
10929 | overflowX = ( |
||
10930 | scrollbarWidths.top || scrollbarWidths.bottom || // horizontal scrollbars? |
||
10931 | // OR scrolling pane with massless scrollbars? |
||
10932 | this.scrollEl[0].scrollWidth - 1 > this.scrollEl[0].clientWidth |
||
10933 | // subtract 1 because of IE off-by-one issue |
||
10934 | ) ? 'scroll' : 'hidden'; |
||
10935 | } |
||
10936 | |||
10937 | if (overflowY === 'auto') { |
||
10938 | overflowY = ( |
||
10939 | scrollbarWidths.left || scrollbarWidths.right || // vertical scrollbars? |
||
10940 | // OR scrolling pane with massless scrollbars? |
||
10941 | this.scrollEl[0].scrollHeight - 1 > this.scrollEl[0].clientHeight |
||
10942 | // subtract 1 because of IE off-by-one issue |
||
10943 | ) ? 'scroll' : 'hidden'; |
||
10944 | } |
||
10945 | |||
10946 | this.scrollEl.css({ 'overflow-x': overflowX, 'overflow-y': overflowY }); |
||
10947 | }, |
||
10948 | |||
10949 | |||
10950 | // Getters / Setters |
||
10951 | // ----------------------------------------------------------------------------------------------------------------- |
||
10952 | |||
10953 | |||
10954 | setHeight: function(height) { |
||
10955 | this.scrollEl.height(height); |
||
10956 | }, |
||
10957 | |||
10958 | |||
10959 | getScrollTop: function() { |
||
10960 | return this.scrollEl.scrollTop(); |
||
10961 | }, |
||
10962 | |||
10963 | |||
10964 | setScrollTop: function(top) { |
||
10965 | this.scrollEl.scrollTop(top); |
||
10966 | }, |
||
10967 | |||
10968 | |||
10969 | getClientWidth: function() { |
||
10970 | return this.scrollEl[0].clientWidth; |
||
10971 | }, |
||
10972 | |||
10973 | |||
10974 | getClientHeight: function() { |
||
10975 | return this.scrollEl[0].clientHeight; |
||
10976 | }, |
||
10977 | |||
10978 | |||
10979 | getScrollbarWidths: function() { |
||
10980 | return getScrollbarWidths(this.scrollEl); |
||
10981 | } |
||
10982 | |||
10983 | }); |
||
10984 | |||
10985 | ;; |
||
10986 | function Iterator(items) { |
||
10987 | this.items = items || []; |
||
10988 | } |
||
10989 | |||
10990 | |||
10991 | /* Calls a method on every item passing the arguments through */ |
||
10992 | Iterator.prototype.proxyCall = function(methodName) { |
||
10993 | var args = Array.prototype.slice.call(arguments, 1); |
||
10994 | var results = []; |
||
10995 | |||
10996 | this.items.forEach(function(item) { |
||
10997 | results.push(item[methodName].apply(item, args)); |
||
10998 | }); |
||
10999 | |||
11000 | return results; |
||
11001 | }; |
||
11002 | |||
11003 | ;; |
||
11004 | |||
11005 | /* Toolbar with buttons and title |
||
11006 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
11007 | |||
11008 | function Toolbar(calendar, toolbarOptions) { |
||
11009 | var t = this; |
||
11010 | |||
11011 | // exports |
||
11012 | t.setToolbarOptions = setToolbarOptions; |
||
11013 | t.render = render; |
||
11014 | t.removeElement = removeElement; |
||
11015 | t.updateTitle = updateTitle; |
||
11016 | t.activateButton = activateButton; |
||
11017 | t.deactivateButton = deactivateButton; |
||
11018 | t.disableButton = disableButton; |
||
11019 | t.enableButton = enableButton; |
||
11020 | t.getViewsWithButtons = getViewsWithButtons; |
||
11021 | t.el = null; // mirrors local `el` |
||
11022 | |||
11023 | // locals |
||
11024 | var el; |
||
11025 | var viewsWithButtons = []; |
||
11026 | var tm; |
||
11027 | |||
11028 | // method to update toolbar-specific options, not calendar-wide options |
||
11029 | function setToolbarOptions(newToolbarOptions) { |
||
11030 | toolbarOptions = newToolbarOptions; |
||
11031 | } |
||
11032 | |||
11033 | // can be called repeatedly and will rerender |
||
11034 | function render() { |
||
11035 | var sections = toolbarOptions.layout; |
||
11036 | |||
11037 | tm = calendar.opt('theme') ? 'ui' : 'fc'; |
||
11038 | |||
11039 | if (sections) { |
||
11040 | if (!el) { |
||
11041 | el = this.el = $("<div class='fc-toolbar "+ toolbarOptions.extraClasses + "'/>"); |
||
11042 | } |
||
11043 | else { |
||
11044 | el.empty(); |
||
11045 | } |
||
11046 | el.append(renderSection('left')) |
||
11047 | .append(renderSection('right')) |
||
11048 | .append(renderSection('center')) |
||
11049 | .append('<div class="fc-clear"/>'); |
||
11050 | } |
||
11051 | else { |
||
11052 | removeElement(); |
||
11053 | } |
||
11054 | } |
||
11055 | |||
11056 | |||
11057 | function removeElement() { |
||
11058 | if (el) { |
||
11059 | el.remove(); |
||
11060 | el = t.el = null; |
||
11061 | } |
||
11062 | } |
||
11063 | |||
11064 | |||
11065 | function renderSection(position) { |
||
11066 | var sectionEl = $('<div class="fc-' + position + '"/>'); |
||
11067 | var buttonStr = toolbarOptions.layout[position]; |
||
11068 | var calendarCustomButtons = calendar.opt('customButtons') || {}; |
||
11069 | var calendarButtonText = calendar.opt('buttonText') || {}; |
||
11070 | |||
11071 | if (buttonStr) { |
||
11072 | $.each(buttonStr.split(' '), function(i) { |
||
11073 | var groupChildren = $(); |
||
11074 | var isOnlyButtons = true; |
||
11075 | var groupEl; |
||
11076 | |||
11077 | $.each(this.split(','), function(j, buttonName) { |
||
11078 | var customButtonProps; |
||
11079 | var viewSpec; |
||
11080 | var buttonClick; |
||
11081 | var overrideText; // text explicitly set by calendar's constructor options. overcomes icons |
||
11082 | var defaultText; |
||
11083 | var themeIcon; |
||
11084 | var normalIcon; |
||
11085 | var innerHtml; |
||
11086 | var classes; |
||
11087 | var button; // the element |
||
11088 | |||
11089 | if (buttonName == 'title') { |
||
11090 | groupChildren = groupChildren.add($('<h2> </h2>')); // we always want it to take up height |
||
11091 | isOnlyButtons = false; |
||
11092 | } |
||
11093 | else { |
||
11094 | if ((customButtonProps = calendarCustomButtons[buttonName])) { |
||
11095 | buttonClick = function(ev) { |
||
11096 | if (customButtonProps.click) { |
||
11097 | customButtonProps.click.call(button[0], ev); |
||
11098 | } |
||
11099 | }; |
||
11100 | overrideText = ''; // icons will override text |
||
11101 | defaultText = customButtonProps.text; |
||
11102 | } |
||
11103 | else if ((viewSpec = calendar.getViewSpec(buttonName))) { |
||
11104 | buttonClick = function() { |
||
11105 | calendar.changeView(buttonName); |
||
11106 | }; |
||
11107 | viewsWithButtons.push(buttonName); |
||
11108 | overrideText = viewSpec.buttonTextOverride; |
||
11109 | defaultText = viewSpec.buttonTextDefault; |
||
11110 | } |
||
11111 | else if (calendar[buttonName]) { // a calendar method |
||
11112 | buttonClick = function() { |
||
11113 | calendar[buttonName](); |
||
11114 | }; |
||
11115 | overrideText = (calendar.overrides.buttonText || {})[buttonName]; |
||
11116 | defaultText = calendarButtonText[buttonName]; // everything else is considered default |
||
11117 | } |
||
11118 | |||
11119 | if (buttonClick) { |
||
11120 | |||
11121 | themeIcon = |
||
11122 | customButtonProps ? |
||
11123 | customButtonProps.themeIcon : |
||
11124 | calendar.opt('themeButtonIcons')[buttonName]; |
||
11125 | |||
11126 | normalIcon = |
||
11127 | customButtonProps ? |
||
11128 | customButtonProps.icon : |
||
11129 | calendar.opt('buttonIcons')[buttonName]; |
||
11130 | |||
11131 | if (overrideText) { |
||
11132 | innerHtml = htmlEscape(overrideText); |
||
11133 | } |
||
11134 | else if (themeIcon && calendar.opt('theme')) { |
||
11135 | innerHtml = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>"; |
||
11136 | } |
||
11137 | else if (normalIcon && !calendar.opt('theme')) { |
||
11138 | innerHtml = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>"; |
||
11139 | } |
||
11140 | else { |
||
11141 | innerHtml = htmlEscape(defaultText); |
||
11142 | } |
||
11143 | |||
11144 | classes = [ |
||
11145 | 'fc-' + buttonName + '-button', |
||
11146 | tm + '-button', |
||
11147 | tm + '-state-default' |
||
11148 | ]; |
||
11149 | |||
11150 | button = $( // type="button" so that it doesn't submit a form |
||
11151 | '<button type="button" class="' + classes.join(' ') + '">' + |
||
11152 | innerHtml + |
||
11153 | '</button>' |
||
11154 | ) |
||
11155 | .click(function(ev) { |
||
11156 | // don't process clicks for disabled buttons |
||
11157 | if (!button.hasClass(tm + '-state-disabled')) { |
||
11158 | |||
11159 | buttonClick(ev); |
||
11160 | |||
11161 | // after the click action, if the button becomes the "active" tab, or disabled, |
||
11162 | // it should never have a hover class, so remove it now. |
||
11163 | if ( |
||
11164 | button.hasClass(tm + '-state-active') || |
||
11165 | button.hasClass(tm + '-state-disabled') |
||
11166 | ) { |
||
11167 | button.removeClass(tm + '-state-hover'); |
||
11168 | } |
||
11169 | } |
||
11170 | }) |
||
11171 | .mousedown(function() { |
||
11172 | // the *down* effect (mouse pressed in). |
||
11173 | // only on buttons that are not the "active" tab, or disabled |
||
11174 | button |
||
11175 | .not('.' + tm + '-state-active') |
||
11176 | .not('.' + tm + '-state-disabled') |
||
11177 | .addClass(tm + '-state-down'); |
||
11178 | }) |
||
11179 | .mouseup(function() { |
||
11180 | // undo the *down* effect |
||
11181 | button.removeClass(tm + '-state-down'); |
||
11182 | }) |
||
11183 | .hover( |
||
11184 | function() { |
||
11185 | // the *hover* effect. |
||
11186 | // only on buttons that are not the "active" tab, or disabled |
||
11187 | button |
||
11188 | .not('.' + tm + '-state-active') |
||
11189 | .not('.' + tm + '-state-disabled') |
||
11190 | .addClass(tm + '-state-hover'); |
||
11191 | }, |
||
11192 | function() { |
||
11193 | // undo the *hover* effect |
||
11194 | button |
||
11195 | .removeClass(tm + '-state-hover') |
||
11196 | .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup |
||
11197 | } |
||
11198 | ); |
||
11199 | |||
11200 | groupChildren = groupChildren.add(button); |
||
11201 | } |
||
11202 | } |
||
11203 | }); |
||
11204 | |||
11205 | if (isOnlyButtons) { |
||
11206 | groupChildren |
||
11207 | .first().addClass(tm + '-corner-left').end() |
||
11208 | .last().addClass(tm + '-corner-right').end(); |
||
11209 | } |
||
11210 | |||
11211 | if (groupChildren.length > 1) { |
||
11212 | groupEl = $('<div/>'); |
||
11213 | if (isOnlyButtons) { |
||
11214 | groupEl.addClass('fc-button-group'); |
||
11215 | } |
||
11216 | groupEl.append(groupChildren); |
||
11217 | sectionEl.append(groupEl); |
||
11218 | } |
||
11219 | else { |
||
11220 | sectionEl.append(groupChildren); // 1 or 0 children |
||
11221 | } |
||
11222 | }); |
||
11223 | } |
||
11224 | |||
11225 | return sectionEl; |
||
11226 | } |
||
11227 | |||
11228 | |||
11229 | function updateTitle(text) { |
||
11230 | if (el) { |
||
11231 | el.find('h2').text(text); |
||
11232 | } |
||
11233 | } |
||
11234 | |||
11235 | |||
11236 | function activateButton(buttonName) { |
||
11237 | if (el) { |
||
11238 | el.find('.fc-' + buttonName + '-button') |
||
11239 | .addClass(tm + '-state-active'); |
||
11240 | } |
||
11241 | } |
||
11242 | |||
11243 | |||
11244 | function deactivateButton(buttonName) { |
||
11245 | if (el) { |
||
11246 | el.find('.fc-' + buttonName + '-button') |
||
11247 | .removeClass(tm + '-state-active'); |
||
11248 | } |
||
11249 | } |
||
11250 | |||
11251 | |||
11252 | function disableButton(buttonName) { |
||
11253 | if (el) { |
||
11254 | el.find('.fc-' + buttonName + '-button') |
||
11255 | .prop('disabled', true) |
||
11256 | .addClass(tm + '-state-disabled'); |
||
11257 | } |
||
11258 | } |
||
11259 | |||
11260 | |||
11261 | function enableButton(buttonName) { |
||
11262 | if (el) { |
||
11263 | el.find('.fc-' + buttonName + '-button') |
||
11264 | .prop('disabled', false) |
||
11265 | .removeClass(tm + '-state-disabled'); |
||
11266 | } |
||
11267 | } |
||
11268 | |||
11269 | |||
11270 | function getViewsWithButtons() { |
||
11271 | return viewsWithButtons; |
||
11272 | } |
||
11273 | |||
11274 | } |
||
11275 | |||
11276 | ;; |
||
11277 | |||
11278 | var Calendar = FC.Calendar = Class.extend(EmitterMixin, { |
||
11279 | |||
11280 | view: null, // current View object |
||
11281 | viewsByType: null, // holds all instantiated view instances, current or not |
||
11282 | currentDate: null, // unzoned moment. private (public API should use getDate instead) |
||
11283 | loadingLevel: 0, // number of simultaneous loading tasks |
||
11284 | |||
11285 | |||
11286 | constructor: function(el, overrides) { |
||
11287 | |||
11288 | // declare the current calendar instance relies on GlobalEmitter. needed for garbage collection. |
||
11289 | // unneeded() is called in destroy. |
||
11290 | GlobalEmitter.needed(); |
||
11291 | |||
11292 | this.el = el; |
||
11293 | this.viewsByType = {}; |
||
11294 | this.viewSpecCache = {}; |
||
11295 | |||
11296 | this.initOptionsInternals(overrides); |
||
11297 | this.initMomentInternals(); // needs to happen after options hash initialized |
||
11298 | this.initCurrentDate(); |
||
11299 | |||
11300 | EventManager.call(this); // needs options immediately |
||
11301 | this.initialize(); |
||
11302 | }, |
||
11303 | |||
11304 | |||
11305 | // Subclasses can override this for initialization logic after the constructor has been called |
||
11306 | initialize: function() { |
||
11307 | }, |
||
11308 | |||
11309 | |||
11310 | // Public API |
||
11311 | // ----------------------------------------------------------------------------------------------------------------- |
||
11312 | |||
11313 | |||
11314 | getCalendar: function() { |
||
11315 | return this; |
||
11316 | }, |
||
11317 | |||
11318 | |||
11319 | getView: function() { |
||
11320 | return this.view; |
||
11321 | }, |
||
11322 | |||
11323 | |||
11324 | publiclyTrigger: function(name, thisObj) { |
||
11325 | var args = Array.prototype.slice.call(arguments, 2); |
||
11326 | var optHandler = this.opt(name); |
||
11327 | |||
11328 | thisObj = thisObj || this.el[0]; |
||
11329 | this.triggerWith(name, thisObj, args); // Emitter's method |
||
11330 | |||
11331 | if (optHandler) { |
||
11332 | return optHandler.apply(thisObj, args); |
||
11333 | } |
||
11334 | }, |
||
11335 | |||
11336 | |||
11337 | // View |
||
11338 | // ----------------------------------------------------------------------------------------------------------------- |
||
11339 | |||
11340 | |||
11341 | // Given a view name for a custom view or a standard view, creates a ready-to-go View object |
||
11342 | instantiateView: function(viewType) { |
||
11343 | var spec = this.getViewSpec(viewType); |
||
11344 | |||
11345 | return new spec['class'](this, spec); |
||
11346 | }, |
||
11347 | |||
11348 | |||
11349 | // Returns a boolean about whether the view is okay to instantiate at some point |
||
11350 | isValidViewType: function(viewType) { |
||
11351 | return Boolean(this.getViewSpec(viewType)); |
||
11352 | }, |
||
11353 | |||
11354 | |||
11355 | changeView: function(viewName, dateOrRange) { |
||
11356 | |||
11357 | if (dateOrRange) { |
||
11358 | |||
11359 | if (dateOrRange.start && dateOrRange.end) { // a range |
||
11360 | this.recordOptionOverrides({ // will not rerender |
||
11361 | visibleRange: dateOrRange |
||
11362 | }); |
||
11363 | } |
||
11364 | else { // a date |
||
11365 | this.currentDate = this.moment(dateOrRange).stripZone(); // just like gotoDate |
||
11366 | } |
||
11367 | } |
||
11368 | |||
11369 | this.renderView(viewName); |
||
11370 | }, |
||
11371 | |||
11372 | |||
11373 | // Forces navigation to a view for the given date. |
||
11374 | // `viewType` can be a specific view name or a generic one like "week" or "day". |
||
11375 | zoomTo: function(newDate, viewType) { |
||
11376 | var spec; |
||
11377 | |||
11378 | viewType = viewType || 'day'; // day is default zoom |
||
11379 | spec = this.getViewSpec(viewType) || this.getUnitViewSpec(viewType); |
||
11380 | |||
11381 | this.currentDate = newDate.clone(); |
||
11382 | this.renderView(spec ? spec.type : null); |
||
11383 | }, |
||
11384 | |||
11385 | |||
11386 | // Current Date |
||
11387 | // ----------------------------------------------------------------------------------------------------------------- |
||
11388 | |||
11389 | |||
11390 | initCurrentDate: function() { |
||
11391 | var defaultDateInput = this.opt('defaultDate'); |
||
11392 | |||
11393 | // compute the initial ambig-timezone date |
||
11394 | if (defaultDateInput != null) { |
||
11395 | this.currentDate = this.moment(defaultDateInput).stripZone(); |
||
11396 | } |
||
11397 | else { |
||
11398 | this.currentDate = this.getNow(); // getNow already returns unzoned |
||
11399 | } |
||
11400 | }, |
||
11401 | |||
11402 | |||
11403 | prev: function() { |
||
11404 | var prevInfo = this.view.buildPrevDateProfile(this.currentDate); |
||
11405 | |||
11406 | if (prevInfo.isValid) { |
||
11407 | this.currentDate = prevInfo.date; |
||
11408 | this.renderView(); |
||
11409 | } |
||
11410 | }, |
||
11411 | |||
11412 | |||
11413 | next: function() { |
||
11414 | var nextInfo = this.view.buildNextDateProfile(this.currentDate); |
||
11415 | |||
11416 | if (nextInfo.isValid) { |
||
11417 | this.currentDate = nextInfo.date; |
||
11418 | this.renderView(); |
||
11419 | } |
||
11420 | }, |
||
11421 | |||
11422 | |||
11423 | prevYear: function() { |
||
11424 | this.currentDate.add(-1, 'years'); |
||
11425 | this.renderView(); |
||
11426 | }, |
||
11427 | |||
11428 | |||
11429 | nextYear: function() { |
||
11430 | this.currentDate.add(1, 'years'); |
||
11431 | this.renderView(); |
||
11432 | }, |
||
11433 | |||
11434 | |||
11435 | today: function() { |
||
11436 | this.currentDate = this.getNow(); // should deny like prev/next? |
||
11437 | this.renderView(); |
||
11438 | }, |
||
11439 | |||
11440 | |||
11441 | gotoDate: function(zonedDateInput) { |
||
11442 | this.currentDate = this.moment(zonedDateInput).stripZone(); |
||
11443 | this.renderView(); |
||
11444 | }, |
||
11445 | |||
11446 | |||
11447 | incrementDate: function(delta) { |
||
11448 | this.currentDate.add(moment.duration(delta)); |
||
11449 | this.renderView(); |
||
11450 | }, |
||
11451 | |||
11452 | |||
11453 | // for external API |
||
11454 | getDate: function() { |
||
11455 | return this.applyTimezone(this.currentDate); // infuse the calendar's timezone |
||
11456 | }, |
||
11457 | |||
11458 | |||
11459 | // Loading Triggering |
||
11460 | // ----------------------------------------------------------------------------------------------------------------- |
||
11461 | |||
11462 | |||
11463 | // Should be called when any type of async data fetching begins |
||
11464 | pushLoading: function() { |
||
11465 | if (!(this.loadingLevel++)) { |
||
11466 | this.publiclyTrigger('loading', null, true, this.view); |
||
11467 | } |
||
11468 | }, |
||
11469 | |||
11470 | |||
11471 | // Should be called when any type of async data fetching completes |
||
11472 | popLoading: function() { |
||
11473 | if (!(--this.loadingLevel)) { |
||
11474 | this.publiclyTrigger('loading', null, false, this.view); |
||
11475 | } |
||
11476 | }, |
||
11477 | |||
11478 | |||
11479 | // Selection |
||
11480 | // ----------------------------------------------------------------------------------------------------------------- |
||
11481 | |||
11482 | |||
11483 | // this public method receives start/end dates in any format, with any timezone |
||
11484 | select: function(zonedStartInput, zonedEndInput) { |
||
11485 | this.view.select( |
||
11486 | this.buildSelectSpan.apply(this, arguments) |
||
11487 | ); |
||
11488 | }, |
||
11489 | |||
11490 | |||
11491 | unselect: function() { // safe to be called before renderView |
||
11492 | if (this.view) { |
||
11493 | this.view.unselect(); |
||
11494 | } |
||
11495 | }, |
||
11496 | |||
11497 | |||
11498 | // Given arguments to the select method in the API, returns a span (unzoned start/end and other info) |
||
11499 | buildSelectSpan: function(zonedStartInput, zonedEndInput) { |
||
11500 | var start = this.moment(zonedStartInput).stripZone(); |
||
11501 | var end; |
||
11502 | |||
11503 | if (zonedEndInput) { |
||
11504 | end = this.moment(zonedEndInput).stripZone(); |
||
11505 | } |
||
11506 | else if (start.hasTime()) { |
||
11507 | end = start.clone().add(this.defaultTimedEventDuration); |
||
11508 | } |
||
11509 | else { |
||
11510 | end = start.clone().add(this.defaultAllDayEventDuration); |
||
11511 | } |
||
11512 | |||
11513 | return { start: start, end: end }; |
||
11514 | }, |
||
11515 | |||
11516 | |||
11517 | // Misc |
||
11518 | // ----------------------------------------------------------------------------------------------------------------- |
||
11519 | |||
11520 | |||
11521 | // will return `null` if invalid range |
||
11522 | parseRange: function(rangeInput) { |
||
11523 | var start = null; |
||
11524 | var end = null; |
||
11525 | |||
11526 | if (rangeInput.start) { |
||
11527 | start = this.moment(rangeInput.start).stripZone(); |
||
11528 | } |
||
11529 | |||
11530 | if (rangeInput.end) { |
||
11531 | end = this.moment(rangeInput.end).stripZone(); |
||
11532 | } |
||
11533 | |||
11534 | if (!start && !end) { |
||
11535 | return null; |
||
11536 | } |
||
11537 | |||
11538 | if (start && end && end.isBefore(start)) { |
||
11539 | return null; |
||
11540 | } |
||
11541 | |||
11542 | return { start: start, end: end }; |
||
11543 | }, |
||
11544 | |||
11545 | |||
11546 | rerenderEvents: function() { // API method. destroys old events if previously rendered. |
||
11547 | if (this.elementVisible()) { |
||
11548 | this.reportEventChange(); // will re-trasmit events to the view, causing a rerender |
||
11549 | } |
||
11550 | } |
||
11551 | |||
11552 | }); |
||
11553 | |||
11554 | ;; |
||
11555 | /* |
||
11556 | Options binding/triggering system. |
||
11557 | */ |
||
11558 | Calendar.mixin({ |
||
11559 | |||
11560 | dirDefaults: null, // option defaults related to LTR or RTL |
||
11561 | localeDefaults: null, // option defaults related to current locale |
||
11562 | overrides: null, // option overrides given to the fullCalendar constructor |
||
11563 | dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides. |
||
11564 | optionsModel: null, // all defaults combined with overrides |
||
11565 | |||
11566 | |||
11567 | initOptionsInternals: function(overrides) { |
||
11568 | this.overrides = $.extend({}, overrides); // make a copy |
||
11569 | this.dynamicOverrides = {}; |
||
11570 | this.optionsModel = new Model(); |
||
11571 | |||
11572 | this.populateOptionsHash(); |
||
11573 | }, |
||
11574 | |||
11575 | |||
11576 | // public getter/setter |
||
11577 | option: function(name, value) { |
||
11578 | var newOptionHash; |
||
11579 | |||
11580 | if (typeof name === 'string') { |
||
11581 | if (value === undefined) { // getter |
||
11582 | return this.optionsModel.get(name); |
||
11583 | } |
||
11584 | else { // setter for individual option |
||
11585 | newOptionHash = {}; |
||
11586 | newOptionHash[name] = value; |
||
11587 | this.setOptions(newOptionHash); |
||
11588 | } |
||
11589 | } |
||
11590 | else if (typeof name === 'object') { // compound setter with object input |
||
11591 | this.setOptions(name); |
||
11592 | } |
||
11593 | }, |
||
11594 | |||
11595 | |||
11596 | // private getter |
||
11597 | opt: function(name) { |
||
11598 | return this.optionsModel.get(name); |
||
11599 | }, |
||
11600 | |||
11601 | |||
11602 | setOptions: function(newOptionHash) { |
||
11603 | var optionCnt = 0; |
||
11604 | var optionName; |
||
11605 | |||
11606 | this.recordOptionOverrides(newOptionHash); |
||
11607 | |||
11608 | for (optionName in newOptionHash) { |
||
11609 | optionCnt++; |
||
11610 | } |
||
11611 | |||
11612 | // special-case handling of single option change. |
||
11613 | // if only one option change, `optionName` will be its name. |
||
11614 | if (optionCnt === 1) { |
||
11615 | if (optionName === 'height' || optionName === 'contentHeight' || optionName === 'aspectRatio') { |
||
11616 | this.updateSize(true); // true = allow recalculation of height |
||
11617 | return; |
||
11618 | } |
||
11619 | else if (optionName === 'defaultDate') { |
||
11620 | return; // can't change date this way. use gotoDate instead |
||
11621 | } |
||
11622 | else if (optionName === 'businessHours') { |
||
11623 | if (this.view) { |
||
11624 | this.view.unrenderBusinessHours(); |
||
11625 | this.view.renderBusinessHours(); |
||
11626 | } |
||
11627 | return; |
||
11628 | } |
||
11629 | else if (optionName === 'timezone') { |
||
11630 | this.rezoneArrayEventSources(); |
||
11631 | this.refetchEvents(); |
||
11632 | return; |
||
11633 | } |
||
11634 | } |
||
11635 | |||
11636 | // catch-all. rerender the header and footer and rebuild/rerender the current view |
||
11637 | this.renderHeader(); |
||
11638 | this.renderFooter(); |
||
11639 | |||
11640 | // even non-current views will be affected by this option change. do before rerender |
||
11641 | // TODO: detangle |
||
11642 | this.viewsByType = {}; |
||
11643 | |||
11644 | this.reinitView(); |
||
11645 | }, |
||
11646 | |||
11647 | |||
11648 | // Computes the flattened options hash for the calendar and assigns to `this.options`. |
||
11649 | // Assumes this.overrides and this.dynamicOverrides have already been initialized. |
||
11650 | populateOptionsHash: function() { |
||
11651 | var locale, localeDefaults; |
||
11652 | var isRTL, dirDefaults; |
||
11653 | var rawOptions; |
||
11654 | |||
11655 | locale = firstDefined( // explicit locale option given? |
||
11656 | this.dynamicOverrides.locale, |
||
11657 | this.overrides.locale |
||
11658 | ); |
||
11659 | localeDefaults = localeOptionHash[locale]; |
||
11660 | if (!localeDefaults) { // explicit locale option not given or invalid? |
||
11661 | locale = Calendar.defaults.locale; |
||
11662 | localeDefaults = localeOptionHash[locale] || {}; |
||
11663 | } |
||
11664 | |||
11665 | isRTL = firstDefined( // based on options computed so far, is direction RTL? |
||
11666 | this.dynamicOverrides.isRTL, |
||
11667 | this.overrides.isRTL, |
||
11668 | localeDefaults.isRTL, |
||
11669 | Calendar.defaults.isRTL |
||
11670 | ); |
||
11671 | dirDefaults = isRTL ? Calendar.rtlDefaults : {}; |
||
11672 | |||
11673 | this.dirDefaults = dirDefaults; |
||
11674 | this.localeDefaults = localeDefaults; |
||
11675 | |||
11676 | rawOptions = mergeOptions([ // merge defaults and overrides. lowest to highest precedence |
||
11677 | Calendar.defaults, // global defaults |
||
11678 | dirDefaults, |
||
11679 | localeDefaults, |
||
11680 | this.overrides, |
||
11681 | this.dynamicOverrides |
||
11682 | ]); |
||
11683 | populateInstanceComputableOptions(rawOptions); // fill in gaps with computed options |
||
11684 | |||
11685 | this.optionsModel.reset(rawOptions); |
||
11686 | }, |
||
11687 | |||
11688 | |||
11689 | // stores the new options internally, but does not rerender anything. |
||
11690 | recordOptionOverrides: function(newOptionHash) { |
||
11691 | var optionName; |
||
11692 | |||
11693 | for (optionName in newOptionHash) { |
||
11694 | this.dynamicOverrides[optionName] = newOptionHash[optionName]; |
||
11695 | } |
||
11696 | |||
11697 | this.viewSpecCache = {}; // the dynamic override invalidates the options in this cache, so just clear it |
||
11698 | this.populateOptionsHash(); // this.options needs to be recomputed after the dynamic override |
||
11699 | } |
||
11700 | |||
11701 | }); |
||
11702 | |||
11703 | ;; |
||
11704 | |||
11705 | Calendar.mixin({ |
||
11706 | |||
11707 | defaultAllDayEventDuration: null, |
||
11708 | defaultTimedEventDuration: null, |
||
11709 | localeData: null, |
||
11710 | |||
11711 | |||
11712 | initMomentInternals: function() { |
||
11713 | var _this = this; |
||
11714 | |||
11715 | this.defaultAllDayEventDuration = moment.duration(this.opt('defaultAllDayEventDuration')); |
||
11716 | this.defaultTimedEventDuration = moment.duration(this.opt('defaultTimedEventDuration')); |
||
11717 | |||
11718 | // Called immediately, and when any of the options change. |
||
11719 | // Happens before any internal objects rebuild or rerender, because this is very core. |
||
11720 | this.optionsModel.watch('buildingMomentLocale', [ |
||
11721 | '?locale', '?monthNames', '?monthNamesShort', '?dayNames', '?dayNamesShort', |
||
11722 | '?firstDay', '?weekNumberCalculation' |
||
11723 | ], function(opts) { |
||
11724 | var weekNumberCalculation = opts.weekNumberCalculation; |
||
11725 | var firstDay = opts.firstDay; |
||
11726 | var _week; |
||
11727 | |||
11728 | // normalize |
||
11729 | if (weekNumberCalculation === 'iso') { |
||
11730 | weekNumberCalculation = 'ISO'; // normalize |
||
11731 | } |
||
11732 | |||
11733 | var localeData = createObject( // make a cheap copy |
||
11734 | getMomentLocaleData(opts.locale) // will fall back to en |
||
11735 | ); |
||
11736 | |||
11737 | if (opts.monthNames) { |
||
11738 | localeData._months = opts.monthNames; |
||
11739 | } |
||
11740 | if (opts.monthNamesShort) { |
||
11741 | localeData._monthsShort = opts.monthNamesShort; |
||
11742 | } |
||
11743 | if (opts.dayNames) { |
||
11744 | localeData._weekdays = opts.dayNames; |
||
11745 | } |
||
11746 | if (opts.dayNamesShort) { |
||
11747 | localeData._weekdaysShort = opts.dayNamesShort; |
||
11748 | } |
||
11749 | |||
11750 | if (firstDay == null && weekNumberCalculation === 'ISO') { |
||
11751 | firstDay = 1; |
||
11752 | } |
||
11753 | if (firstDay != null) { |
||
11754 | _week = createObject(localeData._week); // _week: { dow: # } |
||
11755 | _week.dow = firstDay; |
||
11756 | localeData._week = _week; |
||
11757 | } |
||
11758 | |||
11759 | if ( // whitelist certain kinds of input |
||
11760 | weekNumberCalculation === 'ISO' || |
||
11761 | weekNumberCalculation === 'local' || |
||
11762 | typeof weekNumberCalculation === 'function' |
||
11763 | ) { |
||
11764 | localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it |
||
11765 | } |
||
11766 | |||
11767 | _this.localeData = localeData; |
||
11768 | |||
11769 | // If the internal current date object already exists, move to new locale. |
||
11770 | // We do NOT need to do this technique for event dates, because this happens when converting to "segments". |
||
11771 | if (_this.currentDate) { |
||
11772 | _this.localizeMoment(_this.currentDate); // sets to localeData |
||
11773 | } |
||
11774 | }); |
||
11775 | }, |
||
11776 | |||
11777 | |||
11778 | // Builds a moment using the settings of the current calendar: timezone and locale. |
||
11779 | // Accepts anything the vanilla moment() constructor accepts. |
||
11780 | moment: function() { |
||
11781 | var mom; |
||
11782 | |||
11783 | if (this.opt('timezone') === 'local') { |
||
11784 | mom = FC.moment.apply(null, arguments); |
||
11785 | |||
11786 | // Force the moment to be local, because FC.moment doesn't guarantee it. |
||
11787 | if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone |
||
11788 | mom.local(); |
||
11789 | } |
||
11790 | } |
||
11791 | else if (this.opt('timezone') === 'UTC') { |
||
11792 | mom = FC.moment.utc.apply(null, arguments); // process as UTC |
||
11793 | } |
||
11794 | else { |
||
11795 | mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone |
||
11796 | } |
||
11797 | |||
11798 | this.localizeMoment(mom); // TODO |
||
11799 | |||
11800 | return mom; |
||
11801 | }, |
||
11802 | |||
11803 | |||
11804 | // Updates the given moment's locale settings to the current calendar locale settings. |
||
11805 | localizeMoment: function(mom) { |
||
11806 | mom._locale = this.localeData; |
||
11807 | }, |
||
11808 | |||
11809 | |||
11810 | // Returns a boolean about whether or not the calendar knows how to calculate |
||
11811 | // the timezone offset of arbitrary dates in the current timezone. |
||
11812 | getIsAmbigTimezone: function() { |
||
11813 | return this.opt('timezone') !== 'local' && this.opt('timezone') !== 'UTC'; |
||
11814 | }, |
||
11815 | |||
11816 | |||
11817 | // Returns a copy of the given date in the current timezone. Has no effect on dates without times. |
||
11818 | applyTimezone: function(date) { |
||
11819 | if (!date.hasTime()) { |
||
11820 | return date.clone(); |
||
11821 | } |
||
11822 | |||
11823 | var zonedDate = this.moment(date.toArray()); |
||
11824 | var timeAdjust = date.time() - zonedDate.time(); |
||
11825 | var adjustedZonedDate; |
||
11826 | |||
11827 | // Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396) |
||
11828 | if (timeAdjust) { // is the time result different than expected? |
||
11829 | adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds |
||
11830 | if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now? |
||
11831 | zonedDate = adjustedZonedDate; |
||
11832 | } |
||
11833 | } |
||
11834 | |||
11835 | return zonedDate; |
||
11836 | }, |
||
11837 | |||
11838 | |||
11839 | // Returns a moment for the current date, as defined by the client's computer or from the `now` option. |
||
11840 | // Will return an moment with an ambiguous timezone. |
||
11841 | getNow: function() { |
||
11842 | var now = this.opt('now'); |
||
11843 | if (typeof now === 'function') { |
||
11844 | now = now(); |
||
11845 | } |
||
11846 | return this.moment(now).stripZone(); |
||
11847 | }, |
||
11848 | |||
11849 | |||
11850 | // Produces a human-readable string for the given duration. |
||
11851 | // Side-effect: changes the locale of the given duration. |
||
11852 | humanizeDuration: function(duration) { |
||
11853 | return duration.locale(this.opt('locale')).humanize(); |
||
11854 | }, |
||
11855 | |||
11856 | |||
11857 | |||
11858 | // Event-Specific Date Utilities. TODO: move |
||
11859 | // ----------------------------------------------------------------------------------------------------------------- |
||
11860 | |||
11861 | |||
11862 | // Get an event's normalized end date. If not present, calculate it from the defaults. |
||
11863 | getEventEnd: function(event) { |
||
11864 | if (event.end) { |
||
11865 | return event.end.clone(); |
||
11866 | } |
||
11867 | else { |
||
11868 | return this.getDefaultEventEnd(event.allDay, event.start); |
||
11869 | } |
||
11870 | }, |
||
11871 | |||
11872 | |||
11873 | // Given an event's allDay status and start date, return what its fallback end date should be. |
||
11874 | // TODO: rename to computeDefaultEventEnd |
||
11875 | getDefaultEventEnd: function(allDay, zonedStart) { |
||
11876 | var end = zonedStart.clone(); |
||
11877 | |||
11878 | if (allDay) { |
||
11879 | end.stripTime().add(this.defaultAllDayEventDuration); |
||
11880 | } |
||
11881 | else { |
||
11882 | end.add(this.defaultTimedEventDuration); |
||
11883 | } |
||
11884 | |||
11885 | if (this.getIsAmbigTimezone()) { |
||
11886 | end.stripZone(); // we don't know what the tzo should be |
||
11887 | } |
||
11888 | |||
11889 | return end; |
||
11890 | } |
||
11891 | |||
11892 | }); |
||
11893 | |||
11894 | ;; |
||
11895 | |||
11896 | Calendar.mixin({ |
||
11897 | |||
11898 | viewSpecCache: null, // cache of view definitions (initialized in Calendar.js) |
||
11899 | |||
11900 | |||
11901 | // Gets information about how to create a view. Will use a cache. |
||
11902 | getViewSpec: function(viewType) { |
||
11903 | var cache = this.viewSpecCache; |
||
11904 | |||
11905 | return cache[viewType] || (cache[viewType] = this.buildViewSpec(viewType)); |
||
11906 | }, |
||
11907 | |||
11908 | |||
11909 | // Given a duration singular unit, like "week" or "day", finds a matching view spec. |
||
11910 | // Preference is given to views that have corresponding buttons. |
||
11911 | getUnitViewSpec: function(unit) { |
||
11912 | var viewTypes; |
||
11913 | var i; |
||
11914 | var spec; |
||
11915 | |||
11916 | if ($.inArray(unit, unitsDesc) != -1) { |
||
11917 | |||
11918 | // put views that have buttons first. there will be duplicates, but oh well |
||
11919 | viewTypes = this.header.getViewsWithButtons(); // TODO: include footer as well? |
||
11920 | $.each(FC.views, function(viewType) { // all views |
||
11921 | viewTypes.push(viewType); |
||
11922 | }); |
||
11923 | |||
11924 | for (i = 0; i < viewTypes.length; i++) { |
||
11925 | spec = this.getViewSpec(viewTypes[i]); |
||
11926 | if (spec) { |
||
11927 | if (spec.singleUnit == unit) { |
||
11928 | return spec; |
||
11929 | } |
||
11930 | } |
||
11931 | } |
||
11932 | } |
||
11933 | }, |
||
11934 | |||
11935 | |||
11936 | // Builds an object with information on how to create a given view |
||
11937 | buildViewSpec: function(requestedViewType) { |
||
11938 | var viewOverrides = this.overrides.views || {}; |
||
11939 | var specChain = []; // for the view. lowest to highest priority |
||
11940 | var defaultsChain = []; // for the view. lowest to highest priority |
||
11941 | var overridesChain = []; // for the view. lowest to highest priority |
||
11942 | var viewType = requestedViewType; |
||
11943 | var spec; // for the view |
||
11944 | var overrides; // for the view |
||
11945 | var durationInput; |
||
11946 | var duration; |
||
11947 | var unit; |
||
11948 | |||
11949 | // iterate from the specific view definition to a more general one until we hit an actual View class |
||
11950 | while (viewType) { |
||
11951 | spec = fcViews[viewType]; |
||
11952 | overrides = viewOverrides[viewType]; |
||
11953 | viewType = null; // clear. might repopulate for another iteration |
||
11954 | |||
11955 | if (typeof spec === 'function') { // TODO: deprecate |
||
11956 | spec = { 'class': spec }; |
||
11957 | } |
||
11958 | |||
11959 | if (spec) { |
||
11960 | specChain.unshift(spec); |
||
11961 | defaultsChain.unshift(spec.defaults || {}); |
||
11962 | durationInput = durationInput || spec.duration; |
||
11963 | viewType = viewType || spec.type; |
||
11964 | } |
||
11965 | |||
11966 | if (overrides) { |
||
11967 | overridesChain.unshift(overrides); // view-specific option hashes have options at zero-level |
||
11968 | durationInput = durationInput || overrides.duration; |
||
11969 | viewType = viewType || overrides.type; |
||
11970 | } |
||
11971 | } |
||
11972 | |||
11973 | spec = mergeProps(specChain); |
||
11974 | spec.type = requestedViewType; |
||
11975 | if (!spec['class']) { |
||
11976 | return false; |
||
11977 | } |
||
11978 | |||
11979 | // fall back to top-level `duration` option |
||
11980 | durationInput = durationInput || |
||
11981 | this.dynamicOverrides.duration || |
||
11982 | this.overrides.duration; |
||
11983 | |||
11984 | if (durationInput) { |
||
11985 | duration = moment.duration(durationInput); |
||
11986 | |||
11987 | if (duration.valueOf()) { // valid? |
||
11988 | |||
11989 | unit = computeDurationGreatestUnit(duration, durationInput); |
||
11990 | |||
11991 | spec.duration = duration; |
||
11992 | spec.durationUnit = unit; |
||
11993 | |||
11994 | // view is a single-unit duration, like "week" or "day" |
||
11995 | // incorporate options for this. lowest priority |
||
11996 | if (duration.as(unit) === 1) { |
||
11997 | spec.singleUnit = unit; |
||
11998 | overridesChain.unshift(viewOverrides[unit] || {}); |
||
11999 | } |
||
12000 | } |
||
12001 | } |
||
12002 | |||
12003 | spec.defaults = mergeOptions(defaultsChain); |
||
12004 | spec.overrides = mergeOptions(overridesChain); |
||
12005 | |||
12006 | this.buildViewSpecOptions(spec); |
||
12007 | this.buildViewSpecButtonText(spec, requestedViewType); |
||
12008 | |||
12009 | return spec; |
||
12010 | }, |
||
12011 | |||
12012 | |||
12013 | // Builds and assigns a view spec's options object from its already-assigned defaults and overrides |
||
12014 | buildViewSpecOptions: function(spec) { |
||
12015 | spec.options = mergeOptions([ // lowest to highest priority |
||
12016 | Calendar.defaults, // global defaults |
||
12017 | spec.defaults, // view's defaults (from ViewSubclass.defaults) |
||
12018 | this.dirDefaults, |
||
12019 | this.localeDefaults, // locale and dir take precedence over view's defaults! |
||
12020 | this.overrides, // calendar's overrides (options given to constructor) |
||
12021 | spec.overrides, // view's overrides (view-specific options) |
||
12022 | this.dynamicOverrides // dynamically set via setter. highest precedence |
||
12023 | ]); |
||
12024 | populateInstanceComputableOptions(spec.options); |
||
12025 | }, |
||
12026 | |||
12027 | |||
12028 | // Computes and assigns a view spec's buttonText-related options |
||
12029 | buildViewSpecButtonText: function(spec, requestedViewType) { |
||
12030 | |||
12031 | // given an options object with a possible `buttonText` hash, lookup the buttonText for the |
||
12032 | // requested view, falling back to a generic unit entry like "week" or "day" |
||
12033 | function queryButtonText(options) { |
||
12034 | var buttonText = options.buttonText || {}; |
||
12035 | return buttonText[requestedViewType] || |
||
12036 | // view can decide to look up a certain key |
||
12037 | (spec.buttonTextKey ? buttonText[spec.buttonTextKey] : null) || |
||
12038 | // a key like "month" |
||
12039 | (spec.singleUnit ? buttonText[spec.singleUnit] : null); |
||
12040 | } |
||
12041 | |||
12042 | // highest to lowest priority |
||
12043 | spec.buttonTextOverride = |
||
12044 | queryButtonText(this.dynamicOverrides) || |
||
12045 | queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence |
||
12046 | spec.overrides.buttonText; // `buttonText` for view-specific options is a string |
||
12047 | |||
12048 | // highest to lowest priority. mirrors buildViewSpecOptions |
||
12049 | spec.buttonTextDefault = |
||
12050 | queryButtonText(this.localeDefaults) || |
||
12051 | queryButtonText(this.dirDefaults) || |
||
12052 | spec.defaults.buttonText || // a single string. from ViewSubclass.defaults |
||
12053 | queryButtonText(Calendar.defaults) || |
||
12054 | (spec.duration ? this.humanizeDuration(spec.duration) : null) || // like "3 days" |
||
12055 | requestedViewType; // fall back to given view name |
||
12056 | } |
||
12057 | |||
12058 | }); |
||
12059 | |||
12060 | ;; |
||
12061 | |||
12062 | Calendar.mixin({ |
||
12063 | |||
12064 | el: null, |
||
12065 | contentEl: null, |
||
12066 | suggestedViewHeight: null, |
||
12067 | windowResizeProxy: null, |
||
12068 | ignoreWindowResize: 0, |
||
12069 | |||
12070 | |||
12071 | render: function() { |
||
12072 | if (!this.contentEl) { |
||
12073 | this.initialRender(); |
||
12074 | } |
||
12075 | else if (this.elementVisible()) { |
||
12076 | // mainly for the public API |
||
12077 | this.calcSize(); |
||
12078 | this.renderView(); |
||
12079 | } |
||
12080 | }, |
||
12081 | |||
12082 | |||
12083 | initialRender: function() { |
||
12084 | var _this = this; |
||
12085 | var el = this.el; |
||
12086 | |||
12087 | el.addClass('fc'); |
||
12088 | |||
12089 | // event delegation for nav links |
||
12090 | el.on('click.fc', 'a[data-goto]', function(ev) { |
||
12091 | var anchorEl = $(this); |
||
12092 | var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON |
||
12093 | var date = _this.moment(gotoOptions.date); |
||
12094 | var viewType = gotoOptions.type; |
||
12095 | |||
12096 | // property like "navLinkDayClick". might be a string or a function |
||
12097 | var customAction = _this.view.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click'); |
||
12098 | |||
12099 | if (typeof customAction === 'function') { |
||
12100 | customAction(date, ev); |
||
12101 | } |
||
12102 | else { |
||
12103 | if (typeof customAction === 'string') { |
||
12104 | viewType = customAction; |
||
12105 | } |
||
12106 | _this.zoomTo(date, viewType); |
||
12107 | } |
||
12108 | }); |
||
12109 | |||
12110 | // called immediately, and upon option change |
||
12111 | this.optionsModel.watch('applyingThemeClasses', [ '?theme' ], function(opts) { |
||
12112 | el.toggleClass('ui-widget', opts.theme); |
||
12113 | el.toggleClass('fc-unthemed', !opts.theme); |
||
12114 | }); |
||
12115 | |||
12116 | // called immediately, and upon option change. |
||
12117 | // HACK: locale often affects isRTL, so we explicitly listen to that too. |
||
12118 | this.optionsModel.watch('applyingDirClasses', [ '?isRTL', '?locale' ], function(opts) { |
||
12119 | el.toggleClass('fc-ltr', !opts.isRTL); |
||
12120 | el.toggleClass('fc-rtl', opts.isRTL); |
||
12121 | }); |
||
12122 | |||
12123 | this.contentEl = $("<div class='fc-view-container'/>").prependTo(el); |
||
12124 | |||
12125 | this.initToolbars(); |
||
12126 | this.renderHeader(); |
||
12127 | this.renderFooter(); |
||
12128 | this.renderView(this.opt('defaultView')); |
||
12129 | |||
12130 | if (this.opt('handleWindowResize')) { |
||
12131 | $(window).resize( |
||
12132 | this.windowResizeProxy = debounce( // prevents rapid calls |
||
12133 | this.windowResize.bind(this), |
||
12134 | this.opt('windowResizeDelay') |
||
12135 | ) |
||
12136 | ); |
||
12137 | } |
||
12138 | }, |
||
12139 | |||
12140 | |||
12141 | destroy: function() { |
||
12142 | |||
12143 | if (this.view) { |
||
12144 | this.view.removeElement(); |
||
12145 | |||
12146 | // NOTE: don't null-out this.view in case API methods are called after destroy. |
||
12147 | // It is still the "current" view, just not rendered. |
||
12148 | } |
||
12149 | |||
12150 | this.toolbarsManager.proxyCall('removeElement'); |
||
12151 | this.contentEl.remove(); |
||
12152 | this.el.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget'); |
||
12153 | |||
12154 | this.el.off('.fc'); // unbind nav link handlers |
||
12155 | |||
12156 | if (this.windowResizeProxy) { |
||
12157 | $(window).unbind('resize', this.windowResizeProxy); |
||
12158 | this.windowResizeProxy = null; |
||
12159 | } |
||
12160 | |||
12161 | GlobalEmitter.unneeded(); |
||
12162 | }, |
||
12163 | |||
12164 | |||
12165 | elementVisible: function() { |
||
12166 | return this.el.is(':visible'); |
||
12167 | }, |
||
12168 | |||
12169 | |||
12170 | |||
12171 | // View Rendering |
||
12172 | // ----------------------------------------------------------------------------------- |
||
12173 | |||
12174 | |||
12175 | // Renders a view because of a date change, view-type change, or for the first time. |
||
12176 | // If not given a viewType, keep the current view but render different dates. |
||
12177 | // Accepts an optional scroll state to restore to. |
||
12178 | renderView: function(viewType, forcedScroll) { |
||
12179 | |||
12180 | this.ignoreWindowResize++; |
||
12181 | |||
12182 | var needsClearView = this.view && viewType && this.view.type !== viewType; |
||
12183 | |||
12184 | // if viewType is changing, remove the old view's rendering |
||
12185 | if (needsClearView) { |
||
12186 | this.freezeContentHeight(); // prevent a scroll jump when view element is removed |
||
12187 | this.clearView(); |
||
12188 | } |
||
12189 | |||
12190 | // if viewType changed, or the view was never created, create a fresh view |
||
12191 | if (!this.view && viewType) { |
||
12192 | this.view = |
||
12193 | this.viewsByType[viewType] || |
||
12194 | (this.viewsByType[viewType] = this.instantiateView(viewType)); |
||
12195 | |||
12196 | this.view.setElement( |
||
12197 | $("<div class='fc-view fc-" + viewType + "-view' />").appendTo(this.contentEl) |
||
12198 | ); |
||
12199 | this.toolbarsManager.proxyCall('activateButton', viewType); |
||
12200 | } |
||
12201 | |||
12202 | if (this.view) { |
||
12203 | |||
12204 | if (forcedScroll) { |
||
12205 | this.view.addForcedScroll(forcedScroll); |
||
12206 | } |
||
12207 | |||
12208 | if (this.elementVisible()) { |
||
12209 | this.currentDate = this.view.setDate(this.currentDate); |
||
12210 | } |
||
12211 | } |
||
12212 | |||
12213 | if (needsClearView) { |
||
12214 | this.thawContentHeight(); |
||
12215 | } |
||
12216 | |||
12217 | this.ignoreWindowResize--; |
||
12218 | }, |
||
12219 | |||
12220 | |||
12221 | // Unrenders the current view and reflects this change in the Header. |
||
12222 | // Unregsiters the `view`, but does not remove from viewByType hash. |
||
12223 | clearView: function() { |
||
12224 | this.toolbarsManager.proxyCall('deactivateButton', this.view.type); |
||
12225 | this.view.removeElement(); |
||
12226 | this.view = null; |
||
12227 | }, |
||
12228 | |||
12229 | |||
12230 | // Destroys the view, including the view object. Then, re-instantiates it and renders it. |
||
12231 | // Maintains the same scroll state. |
||
12232 | // TODO: maintain any other user-manipulated state. |
||
12233 | reinitView: function() { |
||
12234 | this.ignoreWindowResize++; |
||
12235 | this.freezeContentHeight(); |
||
12236 | |||
12237 | var viewType = this.view.type; |
||
12238 | var scrollState = this.view.queryScroll(); |
||
12239 | this.clearView(); |
||
12240 | this.calcSize(); |
||
12241 | this.renderView(viewType, scrollState); |
||
12242 | |||
12243 | this.thawContentHeight(); |
||
12244 | this.ignoreWindowResize--; |
||
12245 | }, |
||
12246 | |||
12247 | |||
12248 | // Resizing |
||
12249 | // ----------------------------------------------------------------------------------- |
||
12250 | |||
12251 | |||
12252 | getSuggestedViewHeight: function() { |
||
12253 | if (this.suggestedViewHeight === null) { |
||
12254 | this.calcSize(); |
||
12255 | } |
||
12256 | return this.suggestedViewHeight; |
||
12257 | }, |
||
12258 | |||
12259 | |||
12260 | isHeightAuto: function() { |
||
12261 | return this.opt('contentHeight') === 'auto' || this.opt('height') === 'auto'; |
||
12262 | }, |
||
12263 | |||
12264 | |||
12265 | updateSize: function(shouldRecalc) { |
||
12266 | if (this.elementVisible()) { |
||
12267 | |||
12268 | if (shouldRecalc) { |
||
12269 | this._calcSize(); |
||
12270 | } |
||
12271 | |||
12272 | this.ignoreWindowResize++; |
||
12273 | this.view.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto() |
||
12274 | this.ignoreWindowResize--; |
||
12275 | |||
12276 | return true; // signal success |
||
12277 | } |
||
12278 | }, |
||
12279 | |||
12280 | |||
12281 | calcSize: function() { |
||
12282 | if (this.elementVisible()) { |
||
12283 | this._calcSize(); |
||
12284 | } |
||
12285 | }, |
||
12286 | |||
12287 | |||
12288 | _calcSize: function() { // assumes elementVisible |
||
12289 | var contentHeightInput = this.opt('contentHeight'); |
||
12290 | var heightInput = this.opt('height'); |
||
12291 | |||
12292 | if (typeof contentHeightInput === 'number') { // exists and not 'auto' |
||
12293 | this.suggestedViewHeight = contentHeightInput; |
||
12294 | } |
||
12295 | else if (typeof contentHeightInput === 'function') { // exists and is a function |
||
12296 | this.suggestedViewHeight = contentHeightInput(); |
||
12297 | } |
||
12298 | else if (typeof heightInput === 'number') { // exists and not 'auto' |
||
12299 | this.suggestedViewHeight = heightInput - this.queryToolbarsHeight(); |
||
12300 | } |
||
12301 | else if (typeof heightInput === 'function') { // exists and is a function |
||
12302 | this.suggestedViewHeight = heightInput() - this.queryToolbarsHeight(); |
||
12303 | } |
||
12304 | else if (heightInput === 'parent') { // set to height of parent element |
||
12305 | this.suggestedViewHeight = this.el.parent().height() - this.queryToolbarsHeight(); |
||
12306 | } |
||
12307 | else { |
||
12308 | this.suggestedViewHeight = Math.round( |
||
12309 | this.contentEl.width() / |
||
12310 | Math.max(this.opt('aspectRatio'), .5) |
||
12311 | ); |
||
12312 | } |
||
12313 | }, |
||
12314 | |||
12315 | |||
12316 | windowResize: function(ev) { |
||
12317 | if ( |
||
12318 | !this.ignoreWindowResize && |
||
12319 | ev.target === window && // so we don't process jqui "resize" events that have bubbled up |
||
12320 | this.view.renderRange // view has already been rendered |
||
12321 | ) { |
||
12322 | if (this.updateSize(true)) { |
||
12323 | this.view.publiclyTrigger('windowResize', this.el[0]); |
||
12324 | } |
||
12325 | } |
||
12326 | }, |
||
12327 | |||
12328 | |||
12329 | /* Height "Freezing" |
||
12330 | -----------------------------------------------------------------------------*/ |
||
12331 | |||
12332 | |||
12333 | freezeContentHeight: function() { |
||
12334 | this.contentEl.css({ |
||
12335 | width: '100%', |
||
12336 | height: this.contentEl.height(), |
||
12337 | overflow: 'hidden' |
||
12338 | }); |
||
12339 | }, |
||
12340 | |||
12341 | |||
12342 | thawContentHeight: function() { |
||
12343 | this.contentEl.css({ |
||
12344 | width: '', |
||
12345 | height: '', |
||
12346 | overflow: '' |
||
12347 | }); |
||
12348 | } |
||
12349 | |||
12350 | }); |
||
12351 | |||
12352 | ;; |
||
12353 | |||
12354 | Calendar.mixin({ |
||
12355 | |||
12356 | header: null, |
||
12357 | footer: null, |
||
12358 | toolbarsManager: null, |
||
12359 | |||
12360 | |||
12361 | initToolbars: function() { |
||
12362 | this.header = new Toolbar(this, this.computeHeaderOptions()); |
||
12363 | this.footer = new Toolbar(this, this.computeFooterOptions()); |
||
12364 | this.toolbarsManager = new Iterator([ this.header, this.footer ]); |
||
12365 | }, |
||
12366 | |||
12367 | |||
12368 | computeHeaderOptions: function() { |
||
12369 | return { |
||
12370 | extraClasses: 'fc-header-toolbar', |
||
12371 | layout: this.opt('header') |
||
12372 | }; |
||
12373 | }, |
||
12374 | |||
12375 | |||
12376 | computeFooterOptions: function() { |
||
12377 | return { |
||
12378 | extraClasses: 'fc-footer-toolbar', |
||
12379 | layout: this.opt('footer') |
||
12380 | }; |
||
12381 | }, |
||
12382 | |||
12383 | |||
12384 | // can be called repeatedly and Header will rerender |
||
12385 | renderHeader: function() { |
||
12386 | var header = this.header; |
||
12387 | |||
12388 | header.setToolbarOptions(this.computeHeaderOptions()); |
||
12389 | header.render(); |
||
12390 | |||
12391 | if (header.el) { |
||
12392 | this.el.prepend(header.el); |
||
12393 | } |
||
12394 | }, |
||
12395 | |||
12396 | |||
12397 | // can be called repeatedly and Footer will rerender |
||
12398 | renderFooter: function() { |
||
12399 | var footer = this.footer; |
||
12400 | |||
12401 | footer.setToolbarOptions(this.computeFooterOptions()); |
||
12402 | footer.render(); |
||
12403 | |||
12404 | if (footer.el) { |
||
12405 | this.el.append(footer.el); |
||
12406 | } |
||
12407 | }, |
||
12408 | |||
12409 | |||
12410 | setToolbarsTitle: function(title) { |
||
12411 | this.toolbarsManager.proxyCall('updateTitle', title); |
||
12412 | }, |
||
12413 | |||
12414 | |||
12415 | updateToolbarButtons: function() { |
||
12416 | var now = this.getNow(); |
||
12417 | var view = this.view; |
||
12418 | var todayInfo = view.buildDateProfile(now); |
||
12419 | var prevInfo = view.buildPrevDateProfile(this.currentDate); |
||
12420 | var nextInfo = view.buildNextDateProfile(this.currentDate); |
||
12421 | |||
12422 | this.toolbarsManager.proxyCall( |
||
12423 | (todayInfo.isValid && !isDateWithinRange(now, view.currentRange)) ? |
||
12424 | 'enableButton' : |
||
12425 | 'disableButton', |
||
12426 | 'today' |
||
12427 | ); |
||
12428 | |||
12429 | this.toolbarsManager.proxyCall( |
||
12430 | prevInfo.isValid ? |
||
12431 | 'enableButton' : |
||
12432 | 'disableButton', |
||
12433 | 'prev' |
||
12434 | ); |
||
12435 | |||
12436 | this.toolbarsManager.proxyCall( |
||
12437 | nextInfo.isValid ? |
||
12438 | 'enableButton' : |
||
12439 | 'disableButton', |
||
12440 | 'next' |
||
12441 | ); |
||
12442 | }, |
||
12443 | |||
12444 | |||
12445 | queryToolbarsHeight: function() { |
||
12446 | return this.toolbarsManager.items.reduce(function(accumulator, toolbar) { |
||
12447 | var toolbarHeight = toolbar.el ? toolbar.el.outerHeight(true) : 0; // includes margin |
||
12448 | return accumulator + toolbarHeight; |
||
12449 | }, 0); |
||
12450 | } |
||
12451 | |||
12452 | }); |
||
12453 | |||
12454 | ;; |
||
12455 | |||
12456 | Calendar.defaults = { |
||
12457 | |||
12458 | titleRangeSeparator: ' \u2013 ', // en dash |
||
12459 | monthYearFormat: 'MMMM YYYY', // required for en. other locales rely on datepicker computable option |
||
12460 | |||
12461 | defaultTimedEventDuration: '02:00:00', |
||
12462 | defaultAllDayEventDuration: { days: 1 }, |
||
12463 | forceEventDuration: false, |
||
12464 | nextDayThreshold: '09:00:00', // 9am |
||
12465 | |||
12466 | // display |
||
12467 | defaultView: 'month', |
||
12468 | aspectRatio: 1.35, |
||
12469 | header: { |
||
12470 | left: 'title', |
||
12471 | center: '', |
||
12472 | right: 'today prev,next' |
||
12473 | }, |
||
12474 | weekends: true, |
||
12475 | weekNumbers: false, |
||
12476 | |||
12477 | weekNumberTitle: 'W', |
||
12478 | weekNumberCalculation: 'local', |
||
12479 | |||
12480 | //editable: false, |
||
12481 | |||
12482 | //nowIndicator: false, |
||
12483 | |||
12484 | scrollTime: '06:00:00', |
||
12485 | minTime: '00:00:00', |
||
12486 | maxTime: '24:00:00', |
||
12487 | showNonCurrentDates: true, |
||
12488 | |||
12489 | // event ajax |
||
12490 | lazyFetching: true, |
||
12491 | startParam: 'start', |
||
12492 | endParam: 'end', |
||
12493 | timezoneParam: 'timezone', |
||
12494 | |||
12495 | timezone: false, |
||
12496 | |||
12497 | //allDayDefault: undefined, |
||
12498 | |||
12499 | // locale |
||
12500 | isRTL: false, |
||
12501 | buttonText: { |
||
12502 | prev: "prev", |
||
12503 | next: "next", |
||
12504 | prevYear: "prev year", |
||
12505 | nextYear: "next year", |
||
12506 | year: 'year', // TODO: locale files need to specify this |
||
12507 | today: 'today', |
||
12508 | month: 'month', |
||
12509 | week: 'week', |
||
12510 | day: 'day' |
||
12511 | }, |
||
12512 | |||
12513 | buttonIcons: { |
||
12514 | prev: 'left-single-arrow', |
||
12515 | next: 'right-single-arrow', |
||
12516 | prevYear: 'left-double-arrow', |
||
12517 | nextYear: 'right-double-arrow' |
||
12518 | }, |
||
12519 | |||
12520 | allDayText: 'all-day', |
||
12521 | |||
12522 | // jquery-ui theming |
||
12523 | theme: false, |
||
12524 | themeButtonIcons: { |
||
12525 | prev: 'circle-triangle-w', |
||
12526 | next: 'circle-triangle-e', |
||
12527 | prevYear: 'seek-prev', |
||
12528 | nextYear: 'seek-next' |
||
12529 | }, |
||
12530 | |||
12531 | //eventResizableFromStart: false, |
||
12532 | dragOpacity: .75, |
||
12533 | dragRevertDuration: 500, |
||
12534 | dragScroll: true, |
||
12535 | |||
12536 | //selectable: false, |
||
12537 | unselectAuto: true, |
||
12538 | //selectMinDistance: 0, |
||
12539 | |||
12540 | dropAccept: '*', |
||
12541 | |||
12542 | eventOrder: 'title', |
||
12543 | //eventRenderWait: null, |
||
12544 | |||
12545 | eventLimit: false, |
||
12546 | eventLimitText: 'more', |
||
12547 | eventLimitClick: 'popover', |
||
12548 | dayPopoverFormat: 'LL', |
||
12549 | |||
12550 | handleWindowResize: true, |
||
12551 | windowResizeDelay: 100, // milliseconds before an updateSize happens |
||
12552 | |||
12553 | longPressDelay: 1000 |
||
12554 | |||
12555 | }; |
||
12556 | |||
12557 | |||
12558 | Calendar.englishDefaults = { // used by locale.js |
||
12559 | dayPopoverFormat: 'dddd, MMMM D' |
||
12560 | }; |
||
12561 | |||
12562 | |||
12563 | Calendar.rtlDefaults = { // right-to-left defaults |
||
12564 | header: { // TODO: smarter solution (first/center/last ?) |
||
12565 | left: 'next,prev today', |
||
12566 | center: '', |
||
12567 | right: 'title' |
||
12568 | }, |
||
12569 | buttonIcons: { |
||
12570 | prev: 'right-single-arrow', |
||
12571 | next: 'left-single-arrow', |
||
12572 | prevYear: 'right-double-arrow', |
||
12573 | nextYear: 'left-double-arrow' |
||
12574 | }, |
||
12575 | themeButtonIcons: { |
||
12576 | prev: 'circle-triangle-e', |
||
12577 | next: 'circle-triangle-w', |
||
12578 | nextYear: 'seek-prev', |
||
12579 | prevYear: 'seek-next' |
||
12580 | } |
||
12581 | }; |
||
12582 | |||
12583 | ;; |
||
12584 | |||
12585 | var localeOptionHash = FC.locales = {}; // initialize and expose |
||
12586 | |||
12587 | |||
12588 | // TODO: document the structure and ordering of a FullCalendar locale file |
||
12589 | |||
12590 | |||
12591 | // Initialize jQuery UI datepicker translations while using some of the translations |
||
12592 | // Will set this as the default locales for datepicker. |
||
12593 | FC.datepickerLocale = function(localeCode, dpLocaleCode, dpOptions) { |
||
12594 | |||
12595 | // get the FullCalendar internal option hash for this locale. create if necessary |
||
12596 | var fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {}); |
||
12597 | |||
12598 | // transfer some simple options from datepicker to fc |
||
12599 | fcOptions.isRTL = dpOptions.isRTL; |
||
12600 | fcOptions.weekNumberTitle = dpOptions.weekHeader; |
||
12601 | |||
12602 | // compute some more complex options from datepicker |
||
12603 | $.each(dpComputableOptions, function(name, func) { |
||
12604 | fcOptions[name] = func(dpOptions); |
||
12605 | }); |
||
12606 | |||
12607 | // is jQuery UI Datepicker is on the page? |
||
12608 | if ($.datepicker) { |
||
12609 | |||
12610 | // Register the locale data. |
||
12611 | // FullCalendar and MomentJS use locale codes like "pt-br" but Datepicker |
||
12612 | // does it like "pt-BR" or if it doesn't have the locale, maybe just "pt". |
||
12613 | // Make an alias so the locale can be referenced either way. |
||
12614 | $.datepicker.regional[dpLocaleCode] = |
||
12615 | $.datepicker.regional[localeCode] = // alias |
||
12616 | dpOptions; |
||
12617 | |||
12618 | // Alias 'en' to the default locale data. Do this every time. |
||
12619 | $.datepicker.regional.en = $.datepicker.regional['']; |
||
12620 | |||
12621 | // Set as Datepicker's global defaults. |
||
12622 | $.datepicker.setDefaults(dpOptions); |
||
12623 | } |
||
12624 | }; |
||
12625 | |||
12626 | |||
12627 | // Sets FullCalendar-specific translations. Will set the locales as the global default. |
||
12628 | FC.locale = function(localeCode, newFcOptions) { |
||
12629 | var fcOptions; |
||
12630 | var momOptions; |
||
12631 | |||
12632 | // get the FullCalendar internal option hash for this locale. create if necessary |
||
12633 | fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {}); |
||
12634 | |||
12635 | // provided new options for this locales? merge them in |
||
12636 | if (newFcOptions) { |
||
12637 | fcOptions = localeOptionHash[localeCode] = mergeOptions([ fcOptions, newFcOptions ]); |
||
12638 | } |
||
12639 | |||
12640 | // compute locale options that weren't defined. |
||
12641 | // always do this. newFcOptions can be undefined when initializing from i18n file, |
||
12642 | // so no way to tell if this is an initialization or a default-setting. |
||
12643 | momOptions = getMomentLocaleData(localeCode); // will fall back to en |
||
12644 | $.each(momComputableOptions, function(name, func) { |
||
12645 | if (fcOptions[name] == null) { |
||
12646 | fcOptions[name] = func(momOptions, fcOptions); |
||
12647 | } |
||
12648 | }); |
||
12649 | |||
12650 | // set it as the default locale for FullCalendar |
||
12651 | Calendar.defaults.locale = localeCode; |
||
12652 | }; |
||
12653 | |||
12654 | |||
12655 | // NOTE: can't guarantee any of these computations will run because not every locale has datepicker |
||
12656 | // configs, so make sure there are English fallbacks for these in the defaults file. |
||
12657 | var dpComputableOptions = { |
||
12658 | |||
12659 | buttonText: function(dpOptions) { |
||
12660 | return { |
||
12661 | // the translations sometimes wrongly contain HTML entities |
||
12662 | prev: stripHtmlEntities(dpOptions.prevText), |
||
12663 | next: stripHtmlEntities(dpOptions.nextText), |
||
12664 | today: stripHtmlEntities(dpOptions.currentText) |
||
12665 | }; |
||
12666 | }, |
||
12667 | |||
12668 | // Produces format strings like "MMMM YYYY" -> "September 2014" |
||
12669 | monthYearFormat: function(dpOptions) { |
||
12670 | return dpOptions.showMonthAfterYear ? |
||
12671 | 'YYYY[' + dpOptions.yearSuffix + '] MMMM' : |
||
12672 | 'MMMM YYYY[' + dpOptions.yearSuffix + ']'; |
||
12673 | } |
||
12674 | |||
12675 | }; |
||
12676 | |||
12677 | var momComputableOptions = { |
||
12678 | |||
12679 | // Produces format strings like "ddd M/D" -> "Fri 9/15" |
||
12680 | dayOfMonthFormat: function(momOptions, fcOptions) { |
||
12681 | var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY" |
||
12682 | |||
12683 | // strip the year off the edge, as well as other misc non-whitespace chars |
||
12684 | format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, ''); |
||
12685 | |||
12686 | if (fcOptions.isRTL) { |
||
12687 | format += ' ddd'; // for RTL, add day-of-week to end |
||
12688 | } |
||
12689 | else { |
||
12690 | format = 'ddd ' + format; // for LTR, add day-of-week to beginning |
||
12691 | } |
||
12692 | return format; |
||
12693 | }, |
||
12694 | |||
12695 | // Produces format strings like "h:mma" -> "6:00pm" |
||
12696 | mediumTimeFormat: function(momOptions) { // can't be called `timeFormat` because collides with option |
||
12697 | return momOptions.longDateFormat('LT') |
||
12698 | .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand |
||
12699 | }, |
||
12700 | |||
12701 | // Produces format strings like "h(:mm)a" -> "6pm" / "6:30pm" |
||
12702 | smallTimeFormat: function(momOptions) { |
||
12703 | return momOptions.longDateFormat('LT') |
||
12704 | .replace(':mm', '(:mm)') |
||
12705 | .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales |
||
12706 | .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand |
||
12707 | }, |
||
12708 | |||
12709 | // Produces format strings like "h(:mm)t" -> "6p" / "6:30p" |
||
12710 | extraSmallTimeFormat: function(momOptions) { |
||
12711 | return momOptions.longDateFormat('LT') |
||
12712 | .replace(':mm', '(:mm)') |
||
12713 | .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales |
||
12714 | .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand |
||
12715 | }, |
||
12716 | |||
12717 | // Produces format strings like "ha" / "H" -> "6pm" / "18" |
||
12718 | hourFormat: function(momOptions) { |
||
12719 | return momOptions.longDateFormat('LT') |
||
12720 | .replace(':mm', '') |
||
12721 | .replace(/(\Wmm)$/, '') // like above, but for foreign locales |
||
12722 | .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand |
||
12723 | }, |
||
12724 | |||
12725 | // Produces format strings like "h:mm" -> "6:30" (with no AM/PM) |
||
12726 | noMeridiemTimeFormat: function(momOptions) { |
||
12727 | return momOptions.longDateFormat('LT') |
||
12728 | .replace(/\s*a$/i, ''); // remove trailing AM/PM |
||
12729 | } |
||
12730 | |||
12731 | }; |
||
12732 | |||
12733 | |||
12734 | // options that should be computed off live calendar options (considers override options) |
||
12735 | // TODO: best place for this? related to locale? |
||
12736 | // TODO: flipping text based on isRTL is a bad idea because the CSS `direction` might want to handle it |
||
12737 | var instanceComputableOptions = { |
||
12738 | |||
12739 | // Produces format strings for results like "Mo 16" |
||
12740 | smallDayDateFormat: function(options) { |
||
12741 | return options.isRTL ? |
||
12742 | 'D dd' : |
||
12743 | 'dd D'; |
||
12744 | }, |
||
12745 | |||
12746 | // Produces format strings for results like "Wk 5" |
||
12747 | weekFormat: function(options) { |
||
12748 | return options.isRTL ? |
||
12749 | 'w[ ' + options.weekNumberTitle + ']' : |
||
12750 | '[' + options.weekNumberTitle + ' ]w'; |
||
12751 | }, |
||
12752 | |||
12753 | // Produces format strings for results like "Wk5" |
||
12754 | smallWeekFormat: function(options) { |
||
12755 | return options.isRTL ? |
||
12756 | 'w[' + options.weekNumberTitle + ']' : |
||
12757 | '[' + options.weekNumberTitle + ']w'; |
||
12758 | } |
||
12759 | |||
12760 | }; |
||
12761 | |||
12762 | // TODO: make these computable properties in optionsModel |
||
12763 | function populateInstanceComputableOptions(options) { |
||
12764 | $.each(instanceComputableOptions, function(name, func) { |
||
12765 | if (options[name] == null) { |
||
12766 | options[name] = func(options); |
||
12767 | } |
||
12768 | }); |
||
12769 | } |
||
12770 | |||
12771 | |||
12772 | // Returns moment's internal locale data. If doesn't exist, returns English. |
||
12773 | function getMomentLocaleData(localeCode) { |
||
12774 | return moment.localeData(localeCode) || moment.localeData('en'); |
||
12775 | } |
||
12776 | |||
12777 | |||
12778 | // Initialize English by forcing computation of moment-derived options. |
||
12779 | // Also, sets it as the default. |
||
12780 | FC.locale('en', Calendar.englishDefaults); |
||
12781 | |||
12782 | ;; |
||
12783 | |||
12784 | FC.sourceNormalizers = []; |
||
12785 | FC.sourceFetchers = []; |
||
12786 | |||
12787 | var ajaxDefaults = { |
||
12788 | dataType: 'json', |
||
12789 | cache: false |
||
12790 | }; |
||
12791 | |||
12792 | var eventGUID = 1; |
||
12793 | |||
12794 | |||
12795 | function EventManager() { // assumed to be a calendar |
||
12796 | var t = this; |
||
12797 | |||
12798 | |||
12799 | // exports |
||
12800 | t.requestEvents = requestEvents; |
||
12801 | t.reportEventChange = reportEventChange; |
||
12802 | t.isFetchNeeded = isFetchNeeded; |
||
12803 | t.fetchEvents = fetchEvents; |
||
12804 | t.fetchEventSources = fetchEventSources; |
||
12805 | t.refetchEvents = refetchEvents; |
||
12806 | t.refetchEventSources = refetchEventSources; |
||
12807 | t.getEventSources = getEventSources; |
||
12808 | t.getEventSourceById = getEventSourceById; |
||
12809 | t.addEventSource = addEventSource; |
||
12810 | t.removeEventSource = removeEventSource; |
||
12811 | t.removeEventSources = removeEventSources; |
||
12812 | t.updateEvent = updateEvent; |
||
12813 | t.updateEvents = updateEvents; |
||
12814 | t.renderEvent = renderEvent; |
||
12815 | t.renderEvents = renderEvents; |
||
12816 | t.removeEvents = removeEvents; |
||
12817 | t.clientEvents = clientEvents; |
||
12818 | t.mutateEvent = mutateEvent; |
||
12819 | t.normalizeEventDates = normalizeEventDates; |
||
12820 | t.normalizeEventTimes = normalizeEventTimes; |
||
12821 | |||
12822 | |||
12823 | // locals |
||
12824 | var stickySource = { events: [] }; |
||
12825 | var sources = [ stickySource ]; |
||
12826 | var rangeStart, rangeEnd; |
||
12827 | var pendingSourceCnt = 0; // outstanding fetch requests, max one per source |
||
12828 | var cache = []; // holds events that have already been expanded |
||
12829 | var prunedCache; // like cache, but only events that intersect with rangeStart/rangeEnd |
||
12830 | |||
12831 | |||
12832 | $.each( |
||
12833 | (t.opt('events') ? [ t.opt('events') ] : []).concat(t.opt('eventSources') || []), |
||
12834 | function(i, sourceInput) { |
||
12835 | var source = buildEventSource(sourceInput); |
||
12836 | if (source) { |
||
12837 | sources.push(source); |
||
12838 | } |
||
12839 | } |
||
12840 | ); |
||
12841 | |||
12842 | |||
12843 | |||
12844 | function requestEvents(start, end) { |
||
12845 | if (!t.opt('lazyFetching') || isFetchNeeded(start, end)) { |
||
12846 | return fetchEvents(start, end); |
||
12847 | } |
||
12848 | else { |
||
12849 | return Promise.resolve(prunedCache); |
||
12850 | } |
||
12851 | } |
||
12852 | |||
12853 | |||
12854 | function reportEventChange() { |
||
12855 | prunedCache = filterEventsWithinRange(cache); |
||
12856 | t.trigger('eventsReset', prunedCache); |
||
12857 | } |
||
12858 | |||
12859 | |||
12860 | function filterEventsWithinRange(events) { |
||
12861 | var filteredEvents = []; |
||
12862 | var i, event; |
||
12863 | |||
12864 | for (i = 0; i < events.length; i++) { |
||
12865 | event = events[i]; |
||
12866 | |||
12867 | if ( |
||
12868 | event.start.clone().stripZone() < rangeEnd && |
||
12869 | t.getEventEnd(event).stripZone() > rangeStart |
||
12870 | ) { |
||
12871 | filteredEvents.push(event); |
||
12872 | } |
||
12873 | } |
||
12874 | |||
12875 | return filteredEvents; |
||
12876 | } |
||
12877 | |||
12878 | |||
12879 | t.getEventCache = function() { |
||
12880 | return cache; |
||
12881 | }; |
||
12882 | |||
12883 | |||
12884 | |||
12885 | /* Fetching |
||
12886 | -----------------------------------------------------------------------------*/ |
||
12887 | |||
12888 | |||
12889 | // start and end are assumed to be unzoned |
||
12890 | function isFetchNeeded(start, end) { |
||
12891 | return !rangeStart || // nothing has been fetched yet? |
||
12892 | start < rangeStart || end > rangeEnd; // is part of the new range outside of the old range? |
||
12893 | } |
||
12894 | |||
12895 | |||
12896 | function fetchEvents(start, end) { |
||
12897 | rangeStart = start; |
||
12898 | rangeEnd = end; |
||
12899 | return refetchEvents(); |
||
12900 | } |
||
12901 | |||
12902 | |||
12903 | // poorly named. fetches all sources with current `rangeStart` and `rangeEnd`. |
||
12904 | function refetchEvents() { |
||
12905 | return fetchEventSources(sources, 'reset'); |
||
12906 | } |
||
12907 | |||
12908 | |||
12909 | // poorly named. fetches a subset of event sources. |
||
12910 | function refetchEventSources(matchInputs) { |
||
12911 | return fetchEventSources(getEventSourcesByMatchArray(matchInputs)); |
||
12912 | } |
||
12913 | |||
12914 | |||
12915 | // expects an array of event source objects (the originals, not copies) |
||
12916 | // `specialFetchType` is an optimization parameter that affects purging of the event cache. |
||
12917 | function fetchEventSources(specificSources, specialFetchType) { |
||
12918 | var i, source; |
||
12919 | |||
12920 | if (specialFetchType === 'reset') { |
||
12921 | cache = []; |
||
12922 | } |
||
12923 | else if (specialFetchType !== 'add') { |
||
12924 | cache = excludeEventsBySources(cache, specificSources); |
||
12925 | } |
||
12926 | |||
12927 | for (i = 0; i < specificSources.length; i++) { |
||
12928 | source = specificSources[i]; |
||
12929 | |||
12930 | // already-pending sources have already been accounted for in pendingSourceCnt |
||
12931 | if (source._status !== 'pending') { |
||
12932 | pendingSourceCnt++; |
||
12933 | } |
||
12934 | |||
12935 | source._fetchId = (source._fetchId || 0) + 1; |
||
12936 | source._status = 'pending'; |
||
12937 | } |
||
12938 | |||
12939 | for (i = 0; i < specificSources.length; i++) { |
||
12940 | source = specificSources[i]; |
||
12941 | tryFetchEventSource(source, source._fetchId); |
||
12942 | } |
||
12943 | |||
12944 | if (pendingSourceCnt) { |
||
12945 | return Promise.construct(function(resolve) { |
||
12946 | t.one('eventsReceived', resolve); // will send prunedCache |
||
12947 | }); |
||
12948 | } |
||
12949 | else { // executed all synchronously, or no sources at all |
||
12950 | return Promise.resolve(prunedCache); |
||
12951 | } |
||
12952 | } |
||
12953 | |||
12954 | |||
12955 | // fetches an event source and processes its result ONLY if it is still the current fetch. |
||
12956 | // caller is responsible for incrementing pendingSourceCnt first. |
||
12957 | function tryFetchEventSource(source, fetchId) { |
||
12958 | _fetchEventSource(source, function(eventInputs) { |
||
12959 | var isArraySource = $.isArray(source.events); |
||
12960 | var i, eventInput; |
||
12961 | var abstractEvent; |
||
12962 | |||
12963 | if ( |
||
12964 | // is this the source's most recent fetch? |
||
12965 | // if not, rely on an upcoming fetch of this source to decrement pendingSourceCnt |
||
12966 | fetchId === source._fetchId && |
||
12967 | // event source no longer valid? |
||
12968 | source._status !== 'rejected' |
||
12969 | ) { |
||
12970 | source._status = 'resolved'; |
||
12971 | |||
12972 | if (eventInputs) { |
||
12973 | for (i = 0; i < eventInputs.length; i++) { |
||
12974 | eventInput = eventInputs[i]; |
||
12975 | |||
12976 | if (isArraySource) { // array sources have already been convert to Event Objects |
||
12977 | abstractEvent = eventInput; |
||
12978 | } |
||
12979 | else { |
||
12980 | abstractEvent = buildEventFromInput(eventInput, source); |
||
12981 | } |
||
12982 | |||
12983 | if (abstractEvent) { // not false (an invalid event) |
||
12984 | cache.push.apply( // append |
||
12985 | cache, |
||
12986 | expandEvent(abstractEvent) // add individual expanded events to the cache |
||
12987 | ); |
||
12988 | } |
||
12989 | } |
||
12990 | } |
||
12991 | |||
12992 | decrementPendingSourceCnt(); |
||
12993 | } |
||
12994 | }); |
||
12995 | } |
||
12996 | |||
12997 | |||
12998 | function rejectEventSource(source) { |
||
12999 | var wasPending = source._status === 'pending'; |
||
13000 | |||
13001 | source._status = 'rejected'; |
||
13002 | |||
13003 | if (wasPending) { |
||
13004 | decrementPendingSourceCnt(); |
||
13005 | } |
||
13006 | } |
||
13007 | |||
13008 | |||
13009 | function decrementPendingSourceCnt() { |
||
13010 | pendingSourceCnt--; |
||
13011 | if (!pendingSourceCnt) { |
||
13012 | reportEventChange(cache); // updates prunedCache |
||
13013 | t.trigger('eventsReceived', prunedCache); |
||
13014 | } |
||
13015 | } |
||
13016 | |||
13017 | |||
13018 | function _fetchEventSource(source, callback) { |
||
13019 | var i; |
||
13020 | var fetchers = FC.sourceFetchers; |
||
13021 | var res; |
||
13022 | |||
13023 | for (i=0; i<fetchers.length; i++) { |
||
13024 | res = fetchers[i].call( |
||
13025 | t, // this, the Calendar object |
||
13026 | source, |
||
13027 | rangeStart.clone(), |
||
13028 | rangeEnd.clone(), |
||
13029 | t.opt('timezone'), |
||
13030 | callback |
||
13031 | ); |
||
13032 | |||
13033 | if (res === true) { |
||
13034 | // the fetcher is in charge. made its own async request |
||
13035 | return; |
||
13036 | } |
||
13037 | else if (typeof res == 'object') { |
||
13038 | // the fetcher returned a new source. process it |
||
13039 | _fetchEventSource(res, callback); |
||
13040 | return; |
||
13041 | } |
||
13042 | } |
||
13043 | |||
13044 | var events = source.events; |
||
13045 | if (events) { |
||
13046 | if ($.isFunction(events)) { |
||
13047 | t.pushLoading(); |
||
13048 | events.call( |
||
13049 | t, // this, the Calendar object |
||
13050 | rangeStart.clone(), |
||
13051 | rangeEnd.clone(), |
||
13052 | t.opt('timezone'), |
||
13053 | function(events) { |
||
13054 | callback(events); |
||
13055 | t.popLoading(); |
||
13056 | } |
||
13057 | ); |
||
13058 | } |
||
13059 | else if ($.isArray(events)) { |
||
13060 | callback(events); |
||
13061 | } |
||
13062 | else { |
||
13063 | callback(); |
||
13064 | } |
||
13065 | }else{ |
||
13066 | var url = source.url; |
||
13067 | if (url) { |
||
13068 | var success = source.success; |
||
13069 | var error = source.error; |
||
13070 | var complete = source.complete; |
||
13071 | |||
13072 | // retrieve any outbound GET/POST $.ajax data from the options |
||
13073 | var customData; |
||
13074 | if ($.isFunction(source.data)) { |
||
13075 | // supplied as a function that returns a key/value object |
||
13076 | customData = source.data(); |
||
13077 | } |
||
13078 | else { |
||
13079 | // supplied as a straight key/value object |
||
13080 | customData = source.data; |
||
13081 | } |
||
13082 | |||
13083 | // use a copy of the custom data so we can modify the parameters |
||
13084 | // and not affect the passed-in object. |
||
13085 | var data = $.extend({}, customData || {}); |
||
13086 | |||
13087 | var startParam = firstDefined(source.startParam, t.opt('startParam')); |
||
13088 | var endParam = firstDefined(source.endParam, t.opt('endParam')); |
||
13089 | var timezoneParam = firstDefined(source.timezoneParam, t.opt('timezoneParam')); |
||
13090 | |||
13091 | if (startParam) { |
||
13092 | data[startParam] = rangeStart.format(); |
||
13093 | } |
||
13094 | if (endParam) { |
||
13095 | data[endParam] = rangeEnd.format(); |
||
13096 | } |
||
13097 | if (t.opt('timezone') && t.opt('timezone') != 'local') { |
||
13098 | data[timezoneParam] = t.opt('timezone'); |
||
13099 | } |
||
13100 | |||
13101 | t.pushLoading(); |
||
13102 | $.ajax($.extend({}, ajaxDefaults, source, { |
||
13103 | data: data, |
||
13104 | success: function(events) { |
||
13105 | events = events || []; |
||
13106 | var res = applyAll(success, this, arguments); |
||
13107 | if ($.isArray(res)) { |
||
13108 | events = res; |
||
13109 | } |
||
13110 | callback(events); |
||
13111 | }, |
||
13112 | error: function() { |
||
13113 | applyAll(error, this, arguments); |
||
13114 | callback(); |
||
13115 | }, |
||
13116 | complete: function() { |
||
13117 | applyAll(complete, this, arguments); |
||
13118 | t.popLoading(); |
||
13119 | } |
||
13120 | })); |
||
13121 | }else{ |
||
13122 | callback(); |
||
13123 | } |
||
13124 | } |
||
13125 | } |
||
13126 | |||
13127 | |||
13128 | |||
13129 | /* Sources |
||
13130 | -----------------------------------------------------------------------------*/ |
||
13131 | |||
13132 | |||
13133 | function addEventSource(sourceInput) { |
||
13134 | var source = buildEventSource(sourceInput); |
||
13135 | if (source) { |
||
13136 | sources.push(source); |
||
13137 | fetchEventSources([ source ], 'add'); // will eventually call reportEventChange |
||
13138 | } |
||
13139 | } |
||
13140 | |||
13141 | |||
13142 | function buildEventSource(sourceInput) { // will return undefined if invalid source |
||
13143 | var normalizers = FC.sourceNormalizers; |
||
13144 | var source; |
||
13145 | var i; |
||
13146 | |||
13147 | if ($.isFunction(sourceInput) || $.isArray(sourceInput)) { |
||
13148 | source = { events: sourceInput }; |
||
13149 | } |
||
13150 | else if (typeof sourceInput === 'string') { |
||
13151 | source = { url: sourceInput }; |
||
13152 | } |
||
13153 | else if (typeof sourceInput === 'object') { |
||
13154 | source = $.extend({}, sourceInput); // shallow copy |
||
13155 | } |
||
13156 | |||
13157 | if (source) { |
||
13158 | |||
13159 | // TODO: repeat code, same code for event classNames |
||
13160 | if (source.className) { |
||
13161 | if (typeof source.className === 'string') { |
||
13162 | source.className = source.className.split(/\s+/); |
||
13163 | } |
||
13164 | // otherwise, assumed to be an array |
||
13165 | } |
||
13166 | else { |
||
13167 | source.className = []; |
||
13168 | } |
||
13169 | |||
13170 | // for array sources, we convert to standard Event Objects up front |
||
13171 | if ($.isArray(source.events)) { |
||
13172 | source.origArray = source.events; // for removeEventSource |
||
13173 | source.events = $.map(source.events, function(eventInput) { |
||
13174 | return buildEventFromInput(eventInput, source); |
||
13175 | }); |
||
13176 | } |
||
13177 | |||
13178 | for (i=0; i<normalizers.length; i++) { |
||
13179 | normalizers[i].call(t, source); |
||
13180 | } |
||
13181 | |||
13182 | return source; |
||
13183 | } |
||
13184 | } |
||
13185 | |||
13186 | |||
13187 | function removeEventSource(matchInput) { |
||
13188 | removeSpecificEventSources( |
||
13189 | getEventSourcesByMatch(matchInput) |
||
13190 | ); |
||
13191 | } |
||
13192 | |||
13193 | |||
13194 | // if called with no arguments, removes all. |
||
13195 | function removeEventSources(matchInputs) { |
||
13196 | if (matchInputs == null) { |
||
13197 | removeSpecificEventSources(sources, true); // isAll=true |
||
13198 | } |
||
13199 | else { |
||
13200 | removeSpecificEventSources( |
||
13201 | getEventSourcesByMatchArray(matchInputs) |
||
13202 | ); |
||
13203 | } |
||
13204 | } |
||
13205 | |||
13206 | |||
13207 | function removeSpecificEventSources(targetSources, isAll) { |
||
13208 | var i; |
||
13209 | |||
13210 | // cancel pending requests |
||
13211 | for (i = 0; i < targetSources.length; i++) { |
||
13212 | rejectEventSource(targetSources[i]); |
||
13213 | } |
||
13214 | |||
13215 | if (isAll) { // an optimization |
||
13216 | sources = []; |
||
13217 | cache = []; |
||
13218 | } |
||
13219 | else { |
||
13220 | // remove from persisted source list |
||
13221 | sources = $.grep(sources, function(source) { |
||
13222 | for (i = 0; i < targetSources.length; i++) { |
||
13223 | if (source === targetSources[i]) { |
||
13224 | return false; // exclude |
||
13225 | } |
||
13226 | } |
||
13227 | return true; // include |
||
13228 | }); |
||
13229 | |||
13230 | cache = excludeEventsBySources(cache, targetSources); |
||
13231 | } |
||
13232 | |||
13233 | reportEventChange(); |
||
13234 | } |
||
13235 | |||
13236 | |||
13237 | function getEventSources() { |
||
13238 | return sources.slice(1); // returns a shallow copy of sources with stickySource removed |
||
13239 | } |
||
13240 | |||
13241 | |||
13242 | function getEventSourceById(id) { |
||
13243 | return $.grep(sources, function(source) { |
||
13244 | return source.id && source.id === id; |
||
13245 | })[0]; |
||
13246 | } |
||
13247 | |||
13248 | |||
13249 | // like getEventSourcesByMatch, but accepts multple match criteria (like multiple IDs) |
||
13250 | function getEventSourcesByMatchArray(matchInputs) { |
||
13251 | |||
13252 | // coerce into an array |
||
13253 | if (!matchInputs) { |
||
13254 | matchInputs = []; |
||
13255 | } |
||
13256 | else if (!$.isArray(matchInputs)) { |
||
13257 | matchInputs = [ matchInputs ]; |
||
13258 | } |
||
13259 | |||
13260 | var matchingSources = []; |
||
13261 | var i; |
||
13262 | |||
13263 | // resolve raw inputs to real event source objects |
||
13264 | for (i = 0; i < matchInputs.length; i++) { |
||
13265 | matchingSources.push.apply( // append |
||
13266 | matchingSources, |
||
13267 | getEventSourcesByMatch(matchInputs[i]) |
||
13268 | ); |
||
13269 | } |
||
13270 | |||
13271 | return matchingSources; |
||
13272 | } |
||
13273 | |||
13274 | |||
13275 | // matchInput can either by a real event source object, an ID, or the function/URL for the source. |
||
13276 | // returns an array of matching source objects. |
||
13277 | function getEventSourcesByMatch(matchInput) { |
||
13278 | var i, source; |
||
13279 | |||
13280 | // given an proper event source object |
||
13281 | for (i = 0; i < sources.length; i++) { |
||
13282 | source = sources[i]; |
||
13283 | if (source === matchInput) { |
||
13284 | return [ source ]; |
||
13285 | } |
||
13286 | } |
||
13287 | |||
13288 | // an ID match |
||
13289 | source = getEventSourceById(matchInput); |
||
13290 | if (source) { |
||
13291 | return [ source ]; |
||
13292 | } |
||
13293 | |||
13294 | return $.grep(sources, function(source) { |
||
13295 | return isSourcesEquivalent(matchInput, source); |
||
13296 | }); |
||
13297 | } |
||
13298 | |||
13299 | |||
13300 | function isSourcesEquivalent(source1, source2) { |
||
13301 | return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2); |
||
13302 | } |
||
13303 | |||
13304 | |||
13305 | function getSourcePrimitive(source) { |
||
13306 | return ( |
||
13307 | (typeof source === 'object') ? // a normalized event source? |
||
13308 | (source.origArray || source.googleCalendarId || source.url || source.events) : // get the primitive |
||
13309 | null |
||
13310 | ) || |
||
13311 | source; // the given argument *is* the primitive |
||
13312 | } |
||
13313 | |||
13314 | |||
13315 | // util |
||
13316 | // returns a filtered array without events that are part of any of the given sources |
||
13317 | function excludeEventsBySources(specificEvents, specificSources) { |
||
13318 | return $.grep(specificEvents, function(event) { |
||
13319 | for (var i = 0; i < specificSources.length; i++) { |
||
13320 | if (event.source === specificSources[i]) { |
||
13321 | return false; // exclude |
||
13322 | } |
||
13323 | } |
||
13324 | return true; // keep |
||
13325 | }); |
||
13326 | } |
||
13327 | |||
13328 | |||
13329 | |||
13330 | /* Manipulation |
||
13331 | -----------------------------------------------------------------------------*/ |
||
13332 | |||
13333 | |||
13334 | // Only ever called from the externally-facing API |
||
13335 | function updateEvent(event) { |
||
13336 | updateEvents([ event ]); |
||
13337 | } |
||
13338 | |||
13339 | |||
13340 | // Only ever called from the externally-facing API |
||
13341 | function updateEvents(events) { |
||
13342 | var i, event; |
||
13343 | |||
13344 | for (i = 0; i < events.length; i++) { |
||
13345 | event = events[i]; |
||
13346 | |||
13347 | // massage start/end values, even if date string values |
||
13348 | event.start = t.moment(event.start); |
||
13349 | if (event.end) { |
||
13350 | event.end = t.moment(event.end); |
||
13351 | } |
||
13352 | else { |
||
13353 | event.end = null; |
||
13354 | } |
||
13355 | |||
13356 | mutateEvent(event, getMiscEventProps(event)); // will handle start/end/allDay normalization |
||
13357 | } |
||
13358 | |||
13359 | reportEventChange(); // reports event modifications (so we can redraw) |
||
13360 | } |
||
13361 | |||
13362 | |||
13363 | // Returns a hash of misc event properties that should be copied over to related events. |
||
13364 | function getMiscEventProps(event) { |
||
13365 | var props = {}; |
||
13366 | |||
13367 | $.each(event, function(name, val) { |
||
13368 | if (isMiscEventPropName(name)) { |
||
13369 | if (val !== undefined && isAtomic(val)) { // a defined non-object |
||
13370 | props[name] = val; |
||
13371 | } |
||
13372 | } |
||
13373 | }); |
||
13374 | |||
13375 | return props; |
||
13376 | } |
||
13377 | |||
13378 | // non-date-related, non-id-related, non-secret |
||
13379 | function isMiscEventPropName(name) { |
||
13380 | return !/^_|^(id|allDay|start|end)$/.test(name); |
||
13381 | } |
||
13382 | |||
13383 | |||
13384 | // returns the expanded events that were created |
||
13385 | function renderEvent(eventInput, stick) { |
||
13386 | return renderEvents([ eventInput ], stick); |
||
13387 | } |
||
13388 | |||
13389 | |||
13390 | // returns the expanded events that were created |
||
13391 | function renderEvents(eventInputs, stick) { |
||
13392 | var renderedEvents = []; |
||
13393 | var renderableEvents; |
||
13394 | var abstractEvent; |
||
13395 | var i, j, event; |
||
13396 | |||
13397 | for (i = 0; i < eventInputs.length; i++) { |
||
13398 | abstractEvent = buildEventFromInput(eventInputs[i]); |
||
13399 | |||
13400 | if (abstractEvent) { // not false (a valid input) |
||
13401 | renderableEvents = expandEvent(abstractEvent); |
||
13402 | |||
13403 | for (j = 0; j < renderableEvents.length; j++) { |
||
13404 | event = renderableEvents[j]; |
||
13405 | |||
13406 | if (!event.source) { |
||
13407 | if (stick) { |
||
13408 | stickySource.events.push(event); |
||
13409 | event.source = stickySource; |
||
13410 | } |
||
13411 | cache.push(event); |
||
13412 | } |
||
13413 | } |
||
13414 | |||
13415 | renderedEvents = renderedEvents.concat(renderableEvents); |
||
13416 | } |
||
13417 | } |
||
13418 | |||
13419 | if (renderedEvents.length) { // any new events rendered? |
||
13420 | reportEventChange(); |
||
13421 | } |
||
13422 | |||
13423 | return renderedEvents; |
||
13424 | } |
||
13425 | |||
13426 | |||
13427 | function removeEvents(filter) { |
||
13428 | var eventID; |
||
13429 | var i; |
||
13430 | |||
13431 | if (filter == null) { // null or undefined. remove all events |
||
13432 | filter = function() { return true; }; // will always match |
||
13433 | } |
||
13434 | else if (!$.isFunction(filter)) { // an event ID |
||
13435 | eventID = filter + ''; |
||
13436 | filter = function(event) { |
||
13437 | return event._id == eventID; |
||
13438 | }; |
||
13439 | } |
||
13440 | |||
13441 | // Purge event(s) from our local cache |
||
13442 | cache = $.grep(cache, filter, true); // inverse=true |
||
13443 | |||
13444 | // Remove events from array sources. |
||
13445 | // This works because they have been converted to official Event Objects up front. |
||
13446 | // (and as a result, event._id has been calculated). |
||
13447 | for (i=0; i<sources.length; i++) { |
||
13448 | if ($.isArray(sources[i].events)) { |
||
13449 | sources[i].events = $.grep(sources[i].events, filter, true); |
||
13450 | } |
||
13451 | } |
||
13452 | |||
13453 | reportEventChange(); |
||
13454 | } |
||
13455 | |||
13456 | |||
13457 | function clientEvents(filter) { |
||
13458 | if ($.isFunction(filter)) { |
||
13459 | return $.grep(cache, filter); |
||
13460 | } |
||
13461 | else if (filter != null) { // not null, not undefined. an event ID |
||
13462 | filter += ''; |
||
13463 | return $.grep(cache, function(e) { |
||
13464 | return e._id == filter; |
||
13465 | }); |
||
13466 | } |
||
13467 | return cache; // else, return all |
||
13468 | } |
||
13469 | |||
13470 | |||
13471 | // Makes sure all array event sources have their internal event objects |
||
13472 | // converted over to the Calendar's current timezone. |
||
13473 | t.rezoneArrayEventSources = function() { |
||
13474 | var i; |
||
13475 | var events; |
||
13476 | var j; |
||
13477 | |||
13478 | for (i = 0; i < sources.length; i++) { |
||
13479 | events = sources[i].events; |
||
13480 | if ($.isArray(events)) { |
||
13481 | |||
13482 | for (j = 0; j < events.length; j++) { |
||
13483 | rezoneEventDates(events[j]); |
||
13484 | } |
||
13485 | } |
||
13486 | } |
||
13487 | }; |
||
13488 | |||
13489 | function rezoneEventDates(event) { |
||
13490 | event.start = t.moment(event.start); |
||
13491 | if (event.end) { |
||
13492 | event.end = t.moment(event.end); |
||
13493 | } |
||
13494 | backupEventDates(event); |
||
13495 | } |
||
13496 | |||
13497 | |||
13498 | /* Event Normalization |
||
13499 | -----------------------------------------------------------------------------*/ |
||
13500 | |||
13501 | |||
13502 | // Given a raw object with key/value properties, returns an "abstract" Event object. |
||
13503 | // An "abstract" event is an event that, if recurring, will not have been expanded yet. |
||
13504 | // Will return `false` when input is invalid. |
||
13505 | // `source` is optional |
||
13506 | function buildEventFromInput(input, source) { |
||
13507 | var calendarEventDataTransform = t.opt('eventDataTransform'); |
||
13508 | var out = {}; |
||
13509 | var start, end; |
||
13510 | var allDay; |
||
13511 | |||
13512 | if (calendarEventDataTransform) { |
||
13513 | input = calendarEventDataTransform(input); |
||
13514 | } |
||
13515 | if (source && source.eventDataTransform) { |
||
13516 | input = source.eventDataTransform(input); |
||
13517 | } |
||
13518 | |||
13519 | // Copy all properties over to the resulting object. |
||
13520 | // The special-case properties will be copied over afterwards. |
||
13521 | $.extend(out, input); |
||
13522 | |||
13523 | if (source) { |
||
13524 | out.source = source; |
||
13525 | } |
||
13526 | |||
13527 | out._id = input._id || (input.id === undefined ? '_fc' + eventGUID++ : input.id + ''); |
||
13528 | |||
13529 | if (input.className) { |
||
13530 | if (typeof input.className == 'string') { |
||
13531 | out.className = input.className.split(/\s+/); |
||
13532 | } |
||
13533 | else { // assumed to be an array |
||
13534 | out.className = input.className; |
||
13535 | } |
||
13536 | } |
||
13537 | else { |
||
13538 | out.className = []; |
||
13539 | } |
||
13540 | |||
13541 | start = input.start || input.date; // "date" is an alias for "start" |
||
13542 | end = input.end; |
||
13543 | |||
13544 | // parse as a time (Duration) if applicable |
||
13545 | if (isTimeString(start)) { |
||
13546 | start = moment.duration(start); |
||
13547 | } |
||
13548 | if (isTimeString(end)) { |
||
13549 | end = moment.duration(end); |
||
13550 | } |
||
13551 | |||
13552 | if (input.dow || moment.isDuration(start) || moment.isDuration(end)) { |
||
13553 | |||
13554 | // the event is "abstract" (recurring) so don't calculate exact start/end dates just yet |
||
13555 | out.start = start ? moment.duration(start) : null; // will be a Duration or null |
||
13556 | out.end = end ? moment.duration(end) : null; // will be a Duration or null |
||
13557 | out._recurring = true; // our internal marker |
||
13558 | } |
||
13559 | else { |
||
13560 | |||
13561 | if (start) { |
||
13562 | start = t.moment(start); |
||
13563 | if (!start.isValid()) { |
||
13564 | return false; |
||
13565 | } |
||
13566 | } |
||
13567 | |||
13568 | if (end) { |
||
13569 | end = t.moment(end); |
||
13570 | if (!end.isValid()) { |
||
13571 | end = null; // let defaults take over |
||
13572 | } |
||
13573 | } |
||
13574 | |||
13575 | allDay = input.allDay; |
||
13576 | if (allDay === undefined) { // still undefined? fallback to default |
||
13577 | allDay = firstDefined( |
||
13578 | source ? source.allDayDefault : undefined, |
||
13579 | t.opt('allDayDefault') |
||
13580 | ); |
||
13581 | // still undefined? normalizeEventDates will calculate it |
||
13582 | } |
||
13583 | |||
13584 | assignDatesToEvent(start, end, allDay, out); |
||
13585 | } |
||
13586 | |||
13587 | t.normalizeEvent(out); // hook for external use. a prototype method |
||
13588 | |||
13589 | return out; |
||
13590 | } |
||
13591 | t.buildEventFromInput = buildEventFromInput; |
||
13592 | |||
13593 | |||
13594 | // Normalizes and assigns the given dates to the given partially-formed event object. |
||
13595 | // NOTE: mutates the given start/end moments. does not make a copy. |
||
13596 | function assignDatesToEvent(start, end, allDay, event) { |
||
13597 | event.start = start; |
||
13598 | event.end = end; |
||
13599 | event.allDay = allDay; |
||
13600 | normalizeEventDates(event); |
||
13601 | backupEventDates(event); |
||
13602 | } |
||
13603 | |||
13604 | |||
13605 | // Ensures proper values for allDay/start/end. Accepts an Event object, or a plain object with event-ish properties. |
||
13606 | // NOTE: Will modify the given object. |
||
13607 | function normalizeEventDates(eventProps) { |
||
13608 | |||
13609 | normalizeEventTimes(eventProps); |
||
13610 | |||
13611 | if (eventProps.end && !eventProps.end.isAfter(eventProps.start)) { |
||
13612 | eventProps.end = null; |
||
13613 | } |
||
13614 | |||
13615 | if (!eventProps.end) { |
||
13616 | if (t.opt('forceEventDuration')) { |
||
13617 | eventProps.end = t.getDefaultEventEnd(eventProps.allDay, eventProps.start); |
||
13618 | } |
||
13619 | else { |
||
13620 | eventProps.end = null; |
||
13621 | } |
||
13622 | } |
||
13623 | } |
||
13624 | |||
13625 | |||
13626 | // Ensures the allDay property exists and the timeliness of the start/end dates are consistent |
||
13627 | function normalizeEventTimes(eventProps) { |
||
13628 | if (eventProps.allDay == null) { |
||
13629 | eventProps.allDay = !(eventProps.start.hasTime() || (eventProps.end && eventProps.end.hasTime())); |
||
13630 | } |
||
13631 | |||
13632 | if (eventProps.allDay) { |
||
13633 | eventProps.start.stripTime(); |
||
13634 | if (eventProps.end) { |
||
13635 | // TODO: consider nextDayThreshold here? If so, will require a lot of testing and adjustment |
||
13636 | eventProps.end.stripTime(); |
||
13637 | } |
||
13638 | } |
||
13639 | else { |
||
13640 | if (!eventProps.start.hasTime()) { |
||
13641 | eventProps.start = t.applyTimezone(eventProps.start.time(0)); // will assign a 00:00 time |
||
13642 | } |
||
13643 | if (eventProps.end && !eventProps.end.hasTime()) { |
||
13644 | eventProps.end = t.applyTimezone(eventProps.end.time(0)); // will assign a 00:00 time |
||
13645 | } |
||
13646 | } |
||
13647 | } |
||
13648 | |||
13649 | |||
13650 | // If the given event is a recurring event, break it down into an array of individual instances. |
||
13651 | // If not a recurring event, return an array with the single original event. |
||
13652 | // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array. |
||
13653 | // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours). |
||
13654 | function expandEvent(abstractEvent, _rangeStart, _rangeEnd) { |
||
13655 | var events = []; |
||
13656 | var dowHash; |
||
13657 | var dow; |
||
13658 | var i; |
||
13659 | var date; |
||
13660 | var startTime, endTime; |
||
13661 | var start, end; |
||
13662 | var event; |
||
13663 | |||
13664 | _rangeStart = _rangeStart || rangeStart; |
||
13665 | _rangeEnd = _rangeEnd || rangeEnd; |
||
13666 | |||
13667 | if (abstractEvent) { |
||
13668 | if (abstractEvent._recurring) { |
||
13669 | |||
13670 | // make a boolean hash as to whether the event occurs on each day-of-week |
||
13671 | if ((dow = abstractEvent.dow)) { |
||
13672 | dowHash = {}; |
||
13673 | for (i = 0; i < dow.length; i++) { |
||
13674 | dowHash[dow[i]] = true; |
||
13675 | } |
||
13676 | } |
||
13677 | |||
13678 | // iterate through every day in the current range |
||
13679 | date = _rangeStart.clone().stripTime(); // holds the date of the current day |
||
13680 | while (date.isBefore(_rangeEnd)) { |
||
13681 | |||
13682 | if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week |
||
13683 | |||
13684 | startTime = abstractEvent.start; // the stored start and end properties are times (Durations) |
||
13685 | endTime = abstractEvent.end; // " |
||
13686 | start = date.clone(); |
||
13687 | end = null; |
||
13688 | |||
13689 | if (startTime) { |
||
13690 | start = start.time(startTime); |
||
13691 | } |
||
13692 | if (endTime) { |
||
13693 | end = date.clone().time(endTime); |
||
13694 | } |
||
13695 | |||
13696 | event = $.extend({}, abstractEvent); // make a copy of the original |
||
13697 | assignDatesToEvent( |
||
13698 | start, end, |
||
13699 | !startTime && !endTime, // allDay? |
||
13700 | event |
||
13701 | ); |
||
13702 | events.push(event); |
||
13703 | } |
||
13704 | |||
13705 | date.add(1, 'days'); |
||
13706 | } |
||
13707 | } |
||
13708 | else { |
||
13709 | events.push(abstractEvent); // return the original event. will be a one-item array |
||
13710 | } |
||
13711 | } |
||
13712 | |||
13713 | return events; |
||
13714 | } |
||
13715 | t.expandEvent = expandEvent; |
||
13716 | |||
13717 | |||
13718 | |||
13719 | /* Event Modification Math |
||
13720 | -----------------------------------------------------------------------------------------*/ |
||
13721 | |||
13722 | |||
13723 | // Modifies an event and all related events by applying the given properties. |
||
13724 | // Special date-diffing logic is used for manipulation of dates. |
||
13725 | // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end. |
||
13726 | // All date comparisons are done against the event's pristine _start and _end dates. |
||
13727 | // Returns an object with delta information and a function to undo all operations. |
||
13728 | // For making computations in a granularity greater than day/time, specify largeUnit. |
||
13729 | // NOTE: The given `newProps` might be mutated for normalization purposes. |
||
13730 | function mutateEvent(event, newProps, largeUnit) { |
||
13731 | var miscProps = {}; |
||
13732 | var oldProps; |
||
13733 | var clearEnd; |
||
13734 | var startDelta; |
||
13735 | var endDelta; |
||
13736 | var durationDelta; |
||
13737 | var undoFunc; |
||
13738 | |||
13739 | // diffs the dates in the appropriate way, returning a duration |
||
13740 | function diffDates(date1, date0) { // date1 - date0 |
||
13741 | if (largeUnit) { |
||
13742 | return diffByUnit(date1, date0, largeUnit); |
||
13743 | } |
||
13744 | else if (newProps.allDay) { |
||
13745 | return diffDay(date1, date0); |
||
13746 | } |
||
13747 | else { |
||
13748 | return diffDayTime(date1, date0); |
||
13749 | } |
||
13750 | } |
||
13751 | |||
13752 | newProps = newProps || {}; |
||
13753 | |||
13754 | // normalize new date-related properties |
||
13755 | if (!newProps.start) { |
||
13756 | newProps.start = event.start.clone(); |
||
13757 | } |
||
13758 | if (newProps.end === undefined) { |
||
13759 | newProps.end = event.end ? event.end.clone() : null; |
||
13760 | } |
||
13761 | if (newProps.allDay == null) { // is null or undefined? |
||
13762 | newProps.allDay = event.allDay; |
||
13763 | } |
||
13764 | normalizeEventDates(newProps); |
||
13765 | |||
13766 | // create normalized versions of the original props to compare against |
||
13767 | // need a real end value, for diffing |
||
13768 | oldProps = { |
||
13769 | start: event._start.clone(), |
||
13770 | end: event._end ? event._end.clone() : t.getDefaultEventEnd(event._allDay, event._start), |
||
13771 | allDay: newProps.allDay // normalize the dates in the same regard as the new properties |
||
13772 | }; |
||
13773 | normalizeEventDates(oldProps); |
||
13774 | |||
13775 | // need to clear the end date if explicitly changed to null |
||
13776 | clearEnd = event._end !== null && newProps.end === null; |
||
13777 | |||
13778 | // compute the delta for moving the start date |
||
13779 | startDelta = diffDates(newProps.start, oldProps.start); |
||
13780 | |||
13781 | // compute the delta for moving the end date |
||
13782 | if (newProps.end) { |
||
13783 | endDelta = diffDates(newProps.end, oldProps.end); |
||
13784 | durationDelta = endDelta.subtract(startDelta); |
||
13785 | } |
||
13786 | else { |
||
13787 | durationDelta = null; |
||
13788 | } |
||
13789 | |||
13790 | // gather all non-date-related properties |
||
13791 | $.each(newProps, function(name, val) { |
||
13792 | if (isMiscEventPropName(name)) { |
||
13793 | if (val !== undefined) { |
||
13794 | miscProps[name] = val; |
||
13795 | } |
||
13796 | } |
||
13797 | }); |
||
13798 | |||
13799 | // apply the operations to the event and all related events |
||
13800 | undoFunc = mutateEvents( |
||
13801 | clientEvents(event._id), // get events with this ID |
||
13802 | clearEnd, |
||
13803 | newProps.allDay, |
||
13804 | startDelta, |
||
13805 | durationDelta, |
||
13806 | miscProps |
||
13807 | ); |
||
13808 | |||
13809 | return { |
||
13810 | dateDelta: startDelta, |
||
13811 | durationDelta: durationDelta, |
||
13812 | undo: undoFunc |
||
13813 | }; |
||
13814 | } |
||
13815 | |||
13816 | |||
13817 | // Modifies an array of events in the following ways (operations are in order): |
||
13818 | // - clear the event's `end` |
||
13819 | // - convert the event to allDay |
||
13820 | // - add `dateDelta` to the start and end |
||
13821 | // - add `durationDelta` to the event's duration |
||
13822 | // - assign `miscProps` to the event |
||
13823 | // |
||
13824 | // Returns a function that can be called to undo all the operations. |
||
13825 | // |
||
13826 | // TODO: don't use so many closures. possible memory issues when lots of events with same ID. |
||
13827 | // |
||
13828 | function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) { |
||
13829 | var isAmbigTimezone = t.getIsAmbigTimezone(); |
||
13830 | var undoFunctions = []; |
||
13831 | |||
13832 | // normalize zero-length deltas to be null |
||
13833 | if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; } |
||
13834 | if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; } |
||
13835 | |||
13836 | $.each(events, function(i, event) { |
||
13837 | var oldProps; |
||
13838 | var newProps; |
||
13839 | |||
13840 | // build an object holding all the old values, both date-related and misc. |
||
13841 | // for the undo function. |
||
13842 | oldProps = { |
||
13843 | start: event.start.clone(), |
||
13844 | end: event.end ? event.end.clone() : null, |
||
13845 | allDay: event.allDay |
||
13846 | }; |
||
13847 | $.each(miscProps, function(name) { |
||
13848 | oldProps[name] = event[name]; |
||
13849 | }); |
||
13850 | |||
13851 | // new date-related properties. work off the original date snapshot. |
||
13852 | // ok to use references because they will be thrown away when backupEventDates is called. |
||
13853 | newProps = { |
||
13854 | start: event._start, |
||
13855 | end: event._end, |
||
13856 | allDay: allDay // normalize the dates in the same regard as the new properties |
||
13857 | }; |
||
13858 | normalizeEventDates(newProps); // massages start/end/allDay |
||
13859 | |||
13860 | // strip or ensure the end date |
||
13861 | if (clearEnd) { |
||
13862 | newProps.end = null; |
||
13863 | } |
||
13864 | else if (durationDelta && !newProps.end) { // the duration translation requires an end date |
||
13865 | newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start); |
||
13866 | } |
||
13867 | |||
13868 | if (dateDelta) { |
||
13869 | newProps.start.add(dateDelta); |
||
13870 | if (newProps.end) { |
||
13871 | newProps.end.add(dateDelta); |
||
13872 | } |
||
13873 | } |
||
13874 | |||
13875 | if (durationDelta) { |
||
13876 | newProps.end.add(durationDelta); // end already ensured above |
||
13877 | } |
||
13878 | |||
13879 | // if the dates have changed, and we know it is impossible to recompute the |
||
13880 | // timezone offsets, strip the zone. |
||
13881 | if ( |
||
13882 | isAmbigTimezone && |
||
13883 | !newProps.allDay && |
||
13884 | (dateDelta || durationDelta) |
||
13885 | ) { |
||
13886 | newProps.start.stripZone(); |
||
13887 | if (newProps.end) { |
||
13888 | newProps.end.stripZone(); |
||
13889 | } |
||
13890 | } |
||
13891 | |||
13892 | $.extend(event, miscProps, newProps); // copy over misc props, then date-related props |
||
13893 | backupEventDates(event); // regenerate internal _start/_end/_allDay |
||
13894 | |||
13895 | undoFunctions.push(function() { |
||
13896 | $.extend(event, oldProps); |
||
13897 | backupEventDates(event); // regenerate internal _start/_end/_allDay |
||
13898 | }); |
||
13899 | }); |
||
13900 | |||
13901 | return function() { |
||
13902 | for (var i = 0; i < undoFunctions.length; i++) { |
||
13903 | undoFunctions[i](); |
||
13904 | } |
||
13905 | }; |
||
13906 | } |
||
13907 | |||
13908 | } |
||
13909 | |||
13910 | |||
13911 | // returns an undo function |
||
13912 | Calendar.prototype.mutateSeg = function(seg, newProps) { |
||
13913 | return this.mutateEvent(seg.event, newProps); |
||
13914 | }; |
||
13915 | |||
13916 | |||
13917 | // hook for external libs to manipulate event properties upon creation. |
||
13918 | // should manipulate the event in-place. |
||
13919 | Calendar.prototype.normalizeEvent = function(event) { |
||
13920 | }; |
||
13921 | |||
13922 | |||
13923 | // Does the given span (start, end, and other location information) |
||
13924 | // fully contain the other? |
||
13925 | Calendar.prototype.spanContainsSpan = function(outerSpan, innerSpan) { |
||
13926 | var eventStart = outerSpan.start.clone().stripZone(); |
||
13927 | var eventEnd = this.getEventEnd(outerSpan).stripZone(); |
||
13928 | |||
13929 | return innerSpan.start >= eventStart && innerSpan.end <= eventEnd; |
||
13930 | }; |
||
13931 | |||
13932 | |||
13933 | // Returns a list of events that the given event should be compared against when being considered for a move to |
||
13934 | // the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar. |
||
13935 | Calendar.prototype.getPeerEvents = function(span, event) { |
||
13936 | var cache = this.getEventCache(); |
||
13937 | var peerEvents = []; |
||
13938 | var i, otherEvent; |
||
13939 | |||
13940 | for (i = 0; i < cache.length; i++) { |
||
13941 | otherEvent = cache[i]; |
||
13942 | if ( |
||
13943 | !event || |
||
13944 | event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events |
||
13945 | ) { |
||
13946 | peerEvents.push(otherEvent); |
||
13947 | } |
||
13948 | } |
||
13949 | |||
13950 | return peerEvents; |
||
13951 | }; |
||
13952 | |||
13953 | |||
13954 | // updates the "backup" properties, which are preserved in order to compute diffs later on. |
||
13955 | function backupEventDates(event) { |
||
13956 | event._allDay = event.allDay; |
||
13957 | event._start = event.start.clone(); |
||
13958 | event._end = event.end ? event.end.clone() : null; |
||
13959 | } |
||
13960 | |||
13961 | |||
13962 | /* Overlapping / Constraining |
||
13963 | -----------------------------------------------------------------------------------------*/ |
||
13964 | |||
13965 | |||
13966 | // Determines if the given event can be relocated to the given span (unzoned start/end with other misc data) |
||
13967 | Calendar.prototype.isEventSpanAllowed = function(span, event) { |
||
13968 | var source = event.source || {}; |
||
13969 | var eventAllowFunc = this.opt('eventAllow'); |
||
13970 | |||
13971 | var constraint = firstDefined( |
||
13972 | event.constraint, |
||
13973 | source.constraint, |
||
13974 | this.opt('eventConstraint') |
||
13975 | ); |
||
13976 | |||
13977 | var overlap = firstDefined( |
||
13978 | event.overlap, |
||
13979 | source.overlap, |
||
13980 | this.opt('eventOverlap') |
||
13981 | ); |
||
13982 | |||
13983 | return this.isSpanAllowed(span, constraint, overlap, event) && |
||
13984 | (!eventAllowFunc || eventAllowFunc(span, event) !== false); |
||
13985 | }; |
||
13986 | |||
13987 | |||
13988 | // Determines if an external event can be relocated to the given span (unzoned start/end with other misc data) |
||
13989 | Calendar.prototype.isExternalSpanAllowed = function(eventSpan, eventLocation, eventProps) { |
||
13990 | var eventInput; |
||
13991 | var event; |
||
13992 | |||
13993 | // note: very similar logic is in View's reportExternalDrop |
||
13994 | if (eventProps) { |
||
13995 | eventInput = $.extend({}, eventProps, eventLocation); |
||
13996 | event = this.expandEvent( |
||
13997 | this.buildEventFromInput(eventInput) |
||
13998 | )[0]; |
||
13999 | } |
||
14000 | |||
14001 | if (event) { |
||
14002 | return this.isEventSpanAllowed(eventSpan, event); |
||
14003 | } |
||
14004 | else { // treat it as a selection |
||
14005 | |||
14006 | return this.isSelectionSpanAllowed(eventSpan); |
||
14007 | } |
||
14008 | }; |
||
14009 | |||
14010 | |||
14011 | // Determines the given span (unzoned start/end with other misc data) can be selected. |
||
14012 | Calendar.prototype.isSelectionSpanAllowed = function(span) { |
||
14013 | var selectAllowFunc = this.opt('selectAllow'); |
||
14014 | |||
14015 | return this.isSpanAllowed(span, this.opt('selectConstraint'), this.opt('selectOverlap')) && |
||
14016 | (!selectAllowFunc || selectAllowFunc(span) !== false); |
||
14017 | }; |
||
14018 | |||
14019 | |||
14020 | // Returns true if the given span (caused by an event drop/resize or a selection) is allowed to exist |
||
14021 | // according to the constraint/overlap settings. |
||
14022 | // `event` is not required if checking a selection. |
||
14023 | Calendar.prototype.isSpanAllowed = function(span, constraint, overlap, event) { |
||
14024 | var constraintEvents; |
||
14025 | var anyContainment; |
||
14026 | var peerEvents; |
||
14027 | var i, peerEvent; |
||
14028 | var peerOverlap; |
||
14029 | |||
14030 | // the range must be fully contained by at least one of produced constraint events |
||
14031 | if (constraint != null) { |
||
14032 | |||
14033 | // not treated as an event! intermediate data structure |
||
14034 | // TODO: use ranges in the future |
||
14035 | constraintEvents = this.constraintToEvents(constraint); |
||
14036 | if (constraintEvents) { // not invalid |
||
14037 | |||
14038 | anyContainment = false; |
||
14039 | for (i = 0; i < constraintEvents.length; i++) { |
||
14040 | if (this.spanContainsSpan(constraintEvents[i], span)) { |
||
14041 | anyContainment = true; |
||
14042 | break; |
||
14043 | } |
||
14044 | } |
||
14045 | |||
14046 | if (!anyContainment) { |
||
14047 | return false; |
||
14048 | } |
||
14049 | } |
||
14050 | } |
||
14051 | |||
14052 | peerEvents = this.getPeerEvents(span, event); |
||
14053 | |||
14054 | for (i = 0; i < peerEvents.length; i++) { |
||
14055 | peerEvent = peerEvents[i]; |
||
14056 | |||
14057 | // there needs to be an actual intersection before disallowing anything |
||
14058 | if (this.eventIntersectsRange(peerEvent, span)) { |
||
14059 | |||
14060 | // evaluate overlap for the given range and short-circuit if necessary |
||
14061 | if (overlap === false) { |
||
14062 | return false; |
||
14063 | } |
||
14064 | // if the event's overlap is a test function, pass the peer event in question as the first param |
||
14065 | else if (typeof overlap === 'function' && !overlap(peerEvent, event)) { |
||
14066 | return false; |
||
14067 | } |
||
14068 | |||
14069 | // if we are computing if the given range is allowable for an event, consider the other event's |
||
14070 | // EventObject-specific or Source-specific `overlap` property |
||
14071 | if (event) { |
||
14072 | peerOverlap = firstDefined( |
||
14073 | peerEvent.overlap, |
||
14074 | (peerEvent.source || {}).overlap |
||
14075 | // we already considered the global `eventOverlap` |
||
14076 | ); |
||
14077 | if (peerOverlap === false) { |
||
14078 | return false; |
||
14079 | } |
||
14080 | // if the peer event's overlap is a test function, pass the subject event as the first param |
||
14081 | if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) { |
||
14082 | return false; |
||
14083 | } |
||
14084 | } |
||
14085 | } |
||
14086 | } |
||
14087 | |||
14088 | return true; |
||
14089 | }; |
||
14090 | |||
14091 | |||
14092 | // Given an event input from the API, produces an array of event objects. Possible event inputs: |
||
14093 | // 'businessHours' |
||
14094 | // An event ID (number or string) |
||
14095 | // An object with specific start/end dates or a recurring event (like what businessHours accepts) |
||
14096 | Calendar.prototype.constraintToEvents = function(constraintInput) { |
||
14097 | |||
14098 | if (constraintInput === 'businessHours') { |
||
14099 | return this.getCurrentBusinessHourEvents(); |
||
14100 | } |
||
14101 | |||
14102 | if (typeof constraintInput === 'object') { |
||
14103 | if (constraintInput.start != null) { // needs to be event-like input |
||
14104 | return this.expandEvent(this.buildEventFromInput(constraintInput)); |
||
14105 | } |
||
14106 | else { |
||
14107 | return null; // invalid |
||
14108 | } |
||
14109 | } |
||
14110 | |||
14111 | return this.clientEvents(constraintInput); // probably an ID |
||
14112 | }; |
||
14113 | |||
14114 | |||
14115 | // Does the event's date range intersect with the given range? |
||
14116 | // start/end already assumed to have stripped zones :( |
||
14117 | Calendar.prototype.eventIntersectsRange = function(event, range) { |
||
14118 | var eventStart = event.start.clone().stripZone(); |
||
14119 | var eventEnd = this.getEventEnd(event).stripZone(); |
||
14120 | |||
14121 | return range.start < eventEnd && range.end > eventStart; |
||
14122 | }; |
||
14123 | |||
14124 | |||
14125 | /* Business Hours |
||
14126 | -----------------------------------------------------------------------------------------*/ |
||
14127 | |||
14128 | var BUSINESS_HOUR_EVENT_DEFAULTS = { |
||
14129 | id: '_fcBusinessHours', // will relate events from different calls to expandEvent |
||
14130 | start: '09:00', |
||
14131 | end: '17:00', |
||
14132 | dow: [ 1, 2, 3, 4, 5 ], // monday - friday |
||
14133 | rendering: 'inverse-background' |
||
14134 | // classNames are defined in businessHoursSegClasses |
||
14135 | }; |
||
14136 | |||
14137 | // Return events objects for business hours within the current view. |
||
14138 | // Abuse of our event system :( |
||
14139 | Calendar.prototype.getCurrentBusinessHourEvents = function(wholeDay) { |
||
14140 | return this.computeBusinessHourEvents(wholeDay, this.opt('businessHours')); |
||
14141 | }; |
||
14142 | |||
14143 | // Given a raw input value from options, return events objects for business hours within the current view. |
||
14144 | Calendar.prototype.computeBusinessHourEvents = function(wholeDay, input) { |
||
14145 | if (input === true) { |
||
14146 | return this.expandBusinessHourEvents(wholeDay, [ {} ]); |
||
14147 | } |
||
14148 | else if ($.isPlainObject(input)) { |
||
14149 | return this.expandBusinessHourEvents(wholeDay, [ input ]); |
||
14150 | } |
||
14151 | else if ($.isArray(input)) { |
||
14152 | return this.expandBusinessHourEvents(wholeDay, input, true); |
||
14153 | } |
||
14154 | else { |
||
14155 | return []; |
||
14156 | } |
||
14157 | }; |
||
14158 | |||
14159 | // inputs expected to be an array of objects. |
||
14160 | // if ignoreNoDow is true, will ignore entries that don't specify a day-of-week (dow) key. |
||
14161 | Calendar.prototype.expandBusinessHourEvents = function(wholeDay, inputs, ignoreNoDow) { |
||
14162 | var view = this.getView(); |
||
14163 | var events = []; |
||
14164 | var i, input; |
||
14165 | |||
14166 | for (i = 0; i < inputs.length; i++) { |
||
14167 | input = inputs[i]; |
||
14168 | |||
14169 | if (ignoreNoDow && !input.dow) { |
||
14170 | continue; |
||
14171 | } |
||
14172 | |||
14173 | // give defaults. will make a copy |
||
14174 | input = $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, input); |
||
14175 | |||
14176 | // if a whole-day series is requested, clear the start/end times |
||
14177 | if (wholeDay) { |
||
14178 | input.start = null; |
||
14179 | input.end = null; |
||
14180 | } |
||
14181 | |||
14182 | events.push.apply(events, // append |
||
14183 | this.expandEvent( |
||
14184 | this.buildEventFromInput(input), |
||
14185 | view.activeRange.start, |
||
14186 | view.activeRange.end |
||
14187 | ) |
||
14188 | ); |
||
14189 | } |
||
14190 | |||
14191 | return events; |
||
14192 | }; |
||
14193 | |||
14194 | ;; |
||
14195 | |||
14196 | /* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells. |
||
14197 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
14198 | // It is a manager for a DayGrid subcomponent, which does most of the heavy lifting. |
||
14199 | // It is responsible for managing width/height. |
||
14200 | |||
14201 | var BasicView = FC.BasicView = View.extend({ |
||
14202 | |||
14203 | scroller: null, |
||
14204 | |||
14205 | dayGridClass: DayGrid, // class the dayGrid will be instantiated from (overridable by subclasses) |
||
14206 | dayGrid: null, // the main subcomponent that does most of the heavy lifting |
||
14207 | |||
14208 | dayNumbersVisible: false, // display day numbers on each day cell? |
||
14209 | colWeekNumbersVisible: false, // display week numbers along the side? |
||
14210 | cellWeekNumbersVisible: false, // display week numbers in day cell? |
||
14211 | |||
14212 | weekNumberWidth: null, // width of all the week-number cells running down the side |
||
14213 | |||
14214 | headContainerEl: null, // div that hold's the dayGrid's rendered date header |
||
14215 | headRowEl: null, // the fake row element of the day-of-week header |
||
14216 | |||
14217 | |||
14218 | initialize: function() { |
||
14219 | this.dayGrid = this.instantiateDayGrid(); |
||
14220 | |||
14221 | this.scroller = new Scroller({ |
||
14222 | overflowX: 'hidden', |
||
14223 | overflowY: 'auto' |
||
14224 | }); |
||
14225 | }, |
||
14226 | |||
14227 | |||
14228 | // Generates the DayGrid object this view needs. Draws from this.dayGridClass |
||
14229 | instantiateDayGrid: function() { |
||
14230 | // generate a subclass on the fly with BasicView-specific behavior |
||
14231 | // TODO: cache this subclass |
||
14232 | var subclass = this.dayGridClass.extend(basicDayGridMethods); |
||
14233 | |||
14234 | return new subclass(this); |
||
14235 | }, |
||
14236 | |||
14237 | |||
14238 | // Computes the date range that will be rendered. |
||
14239 | buildRenderRange: function(currentRange, currentRangeUnit) { |
||
14240 | var renderRange = View.prototype.buildRenderRange.apply(this, arguments); |
||
14241 | |||
14242 | // year and month views should be aligned with weeks. this is already done for week |
||
14243 | if (/^(year|month)$/.test(currentRangeUnit)) { |
||
14244 | renderRange.start.startOf('week'); |
||
14245 | |||
14246 | // make end-of-week if not already |
||
14247 | if (renderRange.end.weekday()) { |
||
14248 | renderRange.end.add(1, 'week').startOf('week'); // exclusively move backwards |
||
14249 | } |
||
14250 | } |
||
14251 | |||
14252 | return this.trimHiddenDays(renderRange); |
||
14253 | }, |
||
14254 | |||
14255 | |||
14256 | // Renders the view into `this.el`, which should already be assigned |
||
14257 | renderDates: function() { |
||
14258 | |||
14259 | this.dayGrid.breakOnWeeks = /year|month|week/.test(this.currentRangeUnit); // do before Grid::setRange |
||
14260 | this.dayGrid.setRange(this.renderRange); |
||
14261 | |||
14262 | this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible |
||
14263 | if (this.opt('weekNumbers')) { |
||
14264 | if (this.opt('weekNumbersWithinDays')) { |
||
14265 | this.cellWeekNumbersVisible = true; |
||
14266 | this.colWeekNumbersVisible = false; |
||
14267 | } |
||
14268 | else { |
||
14269 | this.cellWeekNumbersVisible = false; |
||
14270 | this.colWeekNumbersVisible = true; |
||
14271 | }; |
||
14272 | } |
||
14273 | this.dayGrid.numbersVisible = this.dayNumbersVisible || |
||
14274 | this.cellWeekNumbersVisible || this.colWeekNumbersVisible; |
||
14275 | |||
14276 | this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml()); |
||
14277 | this.renderHead(); |
||
14278 | |||
14279 | this.scroller.render(); |
||
14280 | var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container'); |
||
14281 | var dayGridEl = $('<div class="fc-day-grid" />').appendTo(dayGridContainerEl); |
||
14282 | this.el.find('.fc-body > tr > td').append(dayGridContainerEl); |
||
14283 | |||
14284 | this.dayGrid.setElement(dayGridEl); |
||
14285 | this.dayGrid.renderDates(this.hasRigidRows()); |
||
14286 | }, |
||
14287 | |||
14288 | |||
14289 | // render the day-of-week headers |
||
14290 | renderHead: function() { |
||
14291 | this.headContainerEl = |
||
14292 | this.el.find('.fc-head-container') |
||
14293 | .html(this.dayGrid.renderHeadHtml()); |
||
14294 | this.headRowEl = this.headContainerEl.find('.fc-row'); |
||
14295 | }, |
||
14296 | |||
14297 | |||
14298 | // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, |
||
14299 | // always completely kill the dayGrid's rendering. |
||
14300 | unrenderDates: function() { |
||
14301 | this.dayGrid.unrenderDates(); |
||
14302 | this.dayGrid.removeElement(); |
||
14303 | this.scroller.destroy(); |
||
14304 | }, |
||
14305 | |||
14306 | |||
14307 | renderBusinessHours: function() { |
||
14308 | this.dayGrid.renderBusinessHours(); |
||
14309 | }, |
||
14310 | |||
14311 | |||
14312 | unrenderBusinessHours: function() { |
||
14313 | this.dayGrid.unrenderBusinessHours(); |
||
14314 | }, |
||
14315 | |||
14316 | |||
14317 | // Builds the HTML skeleton for the view. |
||
14318 | // The day-grid component will render inside of a container defined by this HTML. |
||
14319 | renderSkeletonHtml: function() { |
||
14320 | return '' + |
||
14321 | '<table>' + |
||
14322 | '<thead class="fc-head">' + |
||
14323 | '<tr>' + |
||
14324 | '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' + |
||
14325 | '</tr>' + |
||
14326 | '</thead>' + |
||
14327 | '<tbody class="fc-body">' + |
||
14328 | '<tr>' + |
||
14329 | '<td class="' + this.widgetContentClass + '"></td>' + |
||
14330 | '</tr>' + |
||
14331 | '</tbody>' + |
||
14332 | '</table>'; |
||
14333 | }, |
||
14334 | |||
14335 | |||
14336 | // Generates an HTML attribute string for setting the width of the week number column, if it is known |
||
14337 | weekNumberStyleAttr: function() { |
||
14338 | if (this.weekNumberWidth !== null) { |
||
14339 | return 'style="width:' + this.weekNumberWidth + 'px"'; |
||
14340 | } |
||
14341 | return ''; |
||
14342 | }, |
||
14343 | |||
14344 | |||
14345 | // Determines whether each row should have a constant height |
||
14346 | hasRigidRows: function() { |
||
14347 | var eventLimit = this.opt('eventLimit'); |
||
14348 | return eventLimit && typeof eventLimit !== 'number'; |
||
14349 | }, |
||
14350 | |||
14351 | |||
14352 | /* Dimensions |
||
14353 | ------------------------------------------------------------------------------------------------------------------*/ |
||
14354 | |||
14355 | |||
14356 | // Refreshes the horizontal dimensions of the view |
||
14357 | updateWidth: function() { |
||
14358 | if (this.colWeekNumbersVisible) { |
||
14359 | // Make sure all week number cells running down the side have the same width. |
||
14360 | // Record the width for cells created later. |
||
14361 | this.weekNumberWidth = matchCellWidths( |
||
14362 | this.el.find('.fc-week-number') |
||
14363 | ); |
||
14364 | } |
||
14365 | }, |
||
14366 | |||
14367 | |||
14368 | // Adjusts the vertical dimensions of the view to the specified values |
||
14369 | setHeight: function(totalHeight, isAuto) { |
||
14370 | var eventLimit = this.opt('eventLimit'); |
||
14371 | var scrollerHeight; |
||
14372 | var scrollbarWidths; |
||
14373 | |||
14374 | // reset all heights to be natural |
||
14375 | this.scroller.clear(); |
||
14376 | uncompensateScroll(this.headRowEl); |
||
14377 | |||
14378 | this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed |
||
14379 | |||
14380 | // is the event limit a constant level number? |
||
14381 | if (eventLimit && typeof eventLimit === 'number') { |
||
14382 | this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after |
||
14383 | } |
||
14384 | |||
14385 | // distribute the height to the rows |
||
14386 | // (totalHeight is a "recommended" value if isAuto) |
||
14387 | scrollerHeight = this.computeScrollerHeight(totalHeight); |
||
14388 | this.setGridHeight(scrollerHeight, isAuto); |
||
14389 | |||
14390 | // is the event limit dynamically calculated? |
||
14391 | if (eventLimit && typeof eventLimit !== 'number') { |
||
14392 | this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set |
||
14393 | } |
||
14394 | |||
14395 | if (!isAuto) { // should we force dimensions of the scroll container? |
||
14396 | |||
14397 | this.scroller.setHeight(scrollerHeight); |
||
14398 | scrollbarWidths = this.scroller.getScrollbarWidths(); |
||
14399 | |||
14400 | if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars? |
||
14401 | |||
14402 | compensateScroll(this.headRowEl, scrollbarWidths); |
||
14403 | |||
14404 | // doing the scrollbar compensation might have created text overflow which created more height. redo |
||
14405 | scrollerHeight = this.computeScrollerHeight(totalHeight); |
||
14406 | this.scroller.setHeight(scrollerHeight); |
||
14407 | } |
||
14408 | |||
14409 | // guarantees the same scrollbar widths |
||
14410 | this.scroller.lockOverflow(scrollbarWidths); |
||
14411 | } |
||
14412 | }, |
||
14413 | |||
14414 | |||
14415 | // given a desired total height of the view, returns what the height of the scroller should be |
||
14416 | computeScrollerHeight: function(totalHeight) { |
||
14417 | return totalHeight - |
||
14418 | subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller |
||
14419 | }, |
||
14420 | |||
14421 | |||
14422 | // Sets the height of just the DayGrid component in this view |
||
14423 | setGridHeight: function(height, isAuto) { |
||
14424 | if (isAuto) { |
||
14425 | undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding |
||
14426 | } |
||
14427 | else { |
||
14428 | distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows |
||
14429 | } |
||
14430 | }, |
||
14431 | |||
14432 | |||
14433 | /* Scroll |
||
14434 | ------------------------------------------------------------------------------------------------------------------*/ |
||
14435 | |||
14436 | |||
14437 | computeInitialDateScroll: function() { |
||
14438 | return { top: 0 }; |
||
14439 | }, |
||
14440 | |||
14441 | |||
14442 | queryDateScroll: function() { |
||
14443 | return { top: this.scroller.getScrollTop() }; |
||
14444 | }, |
||
14445 | |||
14446 | |||
14447 | applyDateScroll: function(scroll) { |
||
14448 | if (scroll.top !== undefined) { |
||
14449 | this.scroller.setScrollTop(scroll.top); |
||
14450 | } |
||
14451 | }, |
||
14452 | |||
14453 | |||
14454 | /* Hit Areas |
||
14455 | ------------------------------------------------------------------------------------------------------------------*/ |
||
14456 | // forward all hit-related method calls to dayGrid |
||
14457 | |||
14458 | |||
14459 | hitsNeeded: function() { |
||
14460 | this.dayGrid.hitsNeeded(); |
||
14461 | }, |
||
14462 | |||
14463 | |||
14464 | hitsNotNeeded: function() { |
||
14465 | this.dayGrid.hitsNotNeeded(); |
||
14466 | }, |
||
14467 | |||
14468 | |||
14469 | prepareHits: function() { |
||
14470 | this.dayGrid.prepareHits(); |
||
14471 | }, |
||
14472 | |||
14473 | |||
14474 | releaseHits: function() { |
||
14475 | this.dayGrid.releaseHits(); |
||
14476 | }, |
||
14477 | |||
14478 | |||
14479 | queryHit: function(left, top) { |
||
14480 | return this.dayGrid.queryHit(left, top); |
||
14481 | }, |
||
14482 | |||
14483 | |||
14484 | getHitSpan: function(hit) { |
||
14485 | return this.dayGrid.getHitSpan(hit); |
||
14486 | }, |
||
14487 | |||
14488 | |||
14489 | getHitEl: function(hit) { |
||
14490 | return this.dayGrid.getHitEl(hit); |
||
14491 | }, |
||
14492 | |||
14493 | |||
14494 | /* Events |
||
14495 | ------------------------------------------------------------------------------------------------------------------*/ |
||
14496 | |||
14497 | |||
14498 | // Renders the given events onto the view and populates the segments array |
||
14499 | renderEvents: function(events) { |
||
14500 | this.dayGrid.renderEvents(events); |
||
14501 | |||
14502 | this.updateHeight(); // must compensate for events that overflow the row |
||
14503 | }, |
||
14504 | |||
14505 | |||
14506 | // Retrieves all segment objects that are rendered in the view |
||
14507 | getEventSegs: function() { |
||
14508 | return this.dayGrid.getEventSegs(); |
||
14509 | }, |
||
14510 | |||
14511 | |||
14512 | // Unrenders all event elements and clears internal segment data |
||
14513 | unrenderEvents: function() { |
||
14514 | this.dayGrid.unrenderEvents(); |
||
14515 | |||
14516 | // we DON'T need to call updateHeight() because |
||
14517 | // a renderEvents() call always happens after this, which will eventually call updateHeight() |
||
14518 | }, |
||
14519 | |||
14520 | |||
14521 | /* Dragging (for both events and external elements) |
||
14522 | ------------------------------------------------------------------------------------------------------------------*/ |
||
14523 | |||
14524 | |||
14525 | // A returned value of `true` signals that a mock "helper" event has been rendered. |
||
14526 | renderDrag: function(dropLocation, seg) { |
||
14527 | return this.dayGrid.renderDrag(dropLocation, seg); |
||
14528 | }, |
||
14529 | |||
14530 | |||
14531 | unrenderDrag: function() { |
||
14532 | this.dayGrid.unrenderDrag(); |
||
14533 | }, |
||
14534 | |||
14535 | |||
14536 | /* Selection |
||
14537 | ------------------------------------------------------------------------------------------------------------------*/ |
||
14538 | |||
14539 | |||
14540 | // Renders a visual indication of a selection |
||
14541 | renderSelection: function(span) { |
||
14542 | this.dayGrid.renderSelection(span); |
||
14543 | }, |
||
14544 | |||
14545 | |||
14546 | // Unrenders a visual indications of a selection |
||
14547 | unrenderSelection: function() { |
||
14548 | this.dayGrid.unrenderSelection(); |
||
14549 | } |
||
14550 | |||
14551 | }); |
||
14552 | |||
14553 | |||
14554 | // Methods that will customize the rendering behavior of the BasicView's dayGrid |
||
14555 | var basicDayGridMethods = { |
||
14556 | |||
14557 | |||
14558 | // Generates the HTML that will go before the day-of week header cells |
||
14559 | renderHeadIntroHtml: function() { |
||
14560 | var view = this.view; |
||
14561 | |||
14562 | if (view.colWeekNumbersVisible) { |
||
14563 | return '' + |
||
14564 | '<th class="fc-week-number ' + view.widgetHeaderClass + '" ' + view.weekNumberStyleAttr() + '>' + |
||
14565 | '<span>' + // needed for matchCellWidths |
||
14566 | htmlEscape(view.opt('weekNumberTitle')) + |
||
14567 | '</span>' + |
||
14568 | '</th>'; |
||
14569 | } |
||
14570 | |||
14571 | return ''; |
||
14572 | }, |
||
14573 | |||
14574 | |||
14575 | // Generates the HTML that will go before content-skeleton cells that display the day/week numbers |
||
14576 | renderNumberIntroHtml: function(row) { |
||
14577 | var view = this.view; |
||
14578 | var weekStart = this.getCellDate(row, 0); |
||
14579 | |||
14580 | if (view.colWeekNumbersVisible) { |
||
14581 | return '' + |
||
14582 | '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '>' + |
||
14583 | view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths |
||
14584 | { date: weekStart, type: 'week', forceOff: this.colCnt === 1 }, |
||
14585 | weekStart.format('w') // inner HTML |
||
14586 | ) + |
||
14587 | '</td>'; |
||
14588 | } |
||
14589 | |||
14590 | return ''; |
||
14591 | }, |
||
14592 | |||
14593 | |||
14594 | // Generates the HTML that goes before the day bg cells for each day-row |
||
14595 | renderBgIntroHtml: function() { |
||
14596 | var view = this.view; |
||
14597 | |||
14598 | if (view.colWeekNumbersVisible) { |
||
14599 | return '<td class="fc-week-number ' + view.widgetContentClass + '" ' + |
||
14600 | view.weekNumberStyleAttr() + '></td>'; |
||
14601 | } |
||
14602 | |||
14603 | return ''; |
||
14604 | }, |
||
14605 | |||
14606 | |||
14607 | // Generates the HTML that goes before every other type of row generated by DayGrid. |
||
14608 | // Affects helper-skeleton and highlight-skeleton rows. |
||
14609 | renderIntroHtml: function() { |
||
14610 | var view = this.view; |
||
14611 | |||
14612 | if (view.colWeekNumbersVisible) { |
||
14613 | return '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '></td>'; |
||
14614 | } |
||
14615 | |||
14616 | return ''; |
||
14617 | } |
||
14618 | |||
14619 | }; |
||
14620 | |||
14621 | ;; |
||
14622 | |||
14623 | /* A month view with day cells running in rows (one-per-week) and columns |
||
14624 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
14625 | |||
14626 | var MonthView = FC.MonthView = BasicView.extend({ |
||
14627 | |||
14628 | |||
14629 | // Computes the date range that will be rendered. |
||
14630 | buildRenderRange: function() { |
||
14631 | var renderRange = BasicView.prototype.buildRenderRange.apply(this, arguments); |
||
14632 | var rowCnt; |
||
14633 | |||
14634 | // ensure 6 weeks |
||
14635 | if (this.isFixedWeeks()) { |
||
14636 | rowCnt = Math.ceil( // could be partial weeks due to hiddenDays |
||
14637 | renderRange.end.diff(renderRange.start, 'weeks', true) // dontRound=true |
||
14638 | ); |
||
14639 | renderRange.end.add(6 - rowCnt, 'weeks'); |
||
14640 | } |
||
14641 | |||
14642 | return renderRange; |
||
14643 | }, |
||
14644 | |||
14645 | |||
14646 | // Overrides the default BasicView behavior to have special multi-week auto-height logic |
||
14647 | setGridHeight: function(height, isAuto) { |
||
14648 | |||
14649 | // if auto, make the height of each row the height that it would be if there were 6 weeks |
||
14650 | if (isAuto) { |
||
14651 | height *= this.rowCnt / 6; |
||
14652 | } |
||
14653 | |||
14654 | distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows |
||
14655 | }, |
||
14656 | |||
14657 | |||
14658 | isFixedWeeks: function() { |
||
14659 | return this.opt('fixedWeekCount'); |
||
14660 | } |
||
14661 | |||
14662 | }); |
||
14663 | |||
14664 | ;; |
||
14665 | |||
14666 | fcViews.basic = { |
||
14667 | 'class': BasicView |
||
14668 | }; |
||
14669 | |||
14670 | fcViews.basicDay = { |
||
14671 | type: 'basic', |
||
14672 | duration: { days: 1 } |
||
14673 | }; |
||
14674 | |||
14675 | fcViews.basicWeek = { |
||
14676 | type: 'basic', |
||
14677 | duration: { weeks: 1 } |
||
14678 | }; |
||
14679 | |||
14680 | fcViews.month = { |
||
14681 | 'class': MonthView, |
||
14682 | duration: { months: 1 }, // important for prev/next |
||
14683 | defaults: { |
||
14684 | fixedWeekCount: true |
||
14685 | } |
||
14686 | }; |
||
14687 | ;; |
||
14688 | |||
14689 | /* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically. |
||
14690 | ----------------------------------------------------------------------------------------------------------------------*/ |
||
14691 | // Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on). |
||
14692 | // Responsible for managing width/height. |
||
14693 | |||
14694 | var AgendaView = FC.AgendaView = View.extend({ |
||
14695 | |||
14696 | scroller: null, |
||
14697 | |||
14698 | timeGridClass: TimeGrid, // class used to instantiate the timeGrid. subclasses can override |
||
14699 | timeGrid: null, // the main time-grid subcomponent of this view |
||
14700 | |||
14701 | dayGridClass: DayGrid, // class used to instantiate the dayGrid. subclasses can override |
||
14702 | dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null |
||
14703 | |||
14704 | axisWidth: null, // the width of the time axis running down the side |
||
14705 | |||
14706 | headContainerEl: null, // div that hold's the timeGrid's rendered date header |
||
14707 | noScrollRowEls: null, // set of fake row elements that must compensate when scroller has scrollbars |
||
14708 | |||
14709 | // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath |
||
14710 | bottomRuleEl: null, |
||
14711 | |||
14712 | // indicates that minTime/maxTime affects rendering |
||
14713 | usesMinMaxTime: true, |
||
14714 | |||
14715 | |||
14716 | initialize: function() { |
||
14717 | this.timeGrid = this.instantiateTimeGrid(); |
||
14718 | |||
14719 | if (this.opt('allDaySlot')) { // should we display the "all-day" area? |
||
14720 | this.dayGrid = this.instantiateDayGrid(); // the all-day subcomponent of this view |
||
14721 | } |
||
14722 | |||
14723 | this.scroller = new Scroller({ |
||
14724 | overflowX: 'hidden', |
||
14725 | overflowY: 'auto' |
||
14726 | }); |
||
14727 | }, |
||
14728 | |||
14729 | |||
14730 | // Instantiates the TimeGrid object this view needs. Draws from this.timeGridClass |
||
14731 | instantiateTimeGrid: function() { |
||
14732 | var subclass = this.timeGridClass.extend(agendaTimeGridMethods); |
||
14733 | |||
14734 | return new subclass(this); |
||
14735 | }, |
||
14736 | |||
14737 | |||
14738 | // Instantiates the DayGrid object this view might need. Draws from this.dayGridClass |
||
14739 | instantiateDayGrid: function() { |
||
14740 | var subclass = this.dayGridClass.extend(agendaDayGridMethods); |
||
14741 | |||
14742 | return new subclass(this); |
||
14743 | }, |
||
14744 | |||
14745 | |||
14746 | /* Rendering |
||
14747 | ------------------------------------------------------------------------------------------------------------------*/ |
||
14748 | |||
14749 | |||
14750 | // Renders the view into `this.el`, which has already been assigned |
||
14751 | renderDates: function() { |
||
14752 | |||
14753 | this.timeGrid.setRange(this.renderRange); |
||
14754 | |||
14755 | if (this.dayGrid) { |
||
14756 | this.dayGrid.setRange(this.renderRange); |
||
14757 | } |
||
14758 | |||
14759 | this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml()); |
||
14760 | this.renderHead(); |
||
14761 | |||
14762 | this.scroller.render(); |
||
14763 | var timeGridWrapEl = this.scroller.el.addClass('fc-time-grid-container'); |
||
14764 | var timeGridEl = $('<div class="fc-time-grid" />').appendTo(timeGridWrapEl); |
||
14765 | this.el.find('.fc-body > tr > td').append(timeGridWrapEl); |
||
14766 | |||
14767 | this.timeGrid.setElement(timeGridEl); |
||
14768 | this.timeGrid.renderDates(); |
||
14769 | |||
14770 | // the <hr> that sometimes displays under the time-grid |
||
14771 | this.bottomRuleEl = $('<hr class="fc-divider ' + this.widgetHeaderClass + '"/>') |
||
14772 | .appendTo(this.timeGrid.el); // inject it into the time-grid |
||
14773 | |||
14774 | if (this.dayGrid) { |
||
14775 | this.dayGrid.setElement(this.el.find('.fc-day-grid')); |
||
14776 | this.dayGrid.renderDates(); |
||
14777 | |||
14778 | // have the day-grid extend it's coordinate area over the <hr> dividing the two grids |
||
14779 | this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight(); |
||
14780 | } |
||
14781 | |||
14782 | this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller |
||
14783 | }, |
||
14784 | |||
14785 | |||
14786 | // render the day-of-week headers |
||
14787 | renderHead: function() { |
||
14788 | this.headContainerEl = |
||
14789 | this.el.find('.fc-head-container') |
||
14790 | .html(this.timeGrid.renderHeadHtml()); |
||
14791 | }, |
||
14792 | |||
14793 | |||
14794 | // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, |
||
14795 | // always completely kill each grid's rendering. |
||
14796 | unrenderDates: function() { |
||
14797 | this.timeGrid.unrenderDates(); |
||
14798 | this.timeGrid.removeElement(); |
||
14799 | |||
14800 | if (this.dayGrid) { |
||
14801 | this.dayGrid.unrenderDates(); |
||
14802 | this.dayGrid.removeElement(); |
||
14803 | } |
||
14804 | |||
14805 | this.scroller.destroy(); |
||
14806 | }, |
||
14807 | |||
14808 | |||
14809 | // Builds the HTML skeleton for the view. |
||
14810 | // The day-grid and time-grid components will render inside containers defined by this HTML. |
||
14811 | renderSkeletonHtml: function() { |
||
14812 | return '' + |
||
14813 | '<table>' + |
||
14814 | '<thead class="fc-head">' + |
||
14815 | '<tr>' + |
||
14816 | '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' + |
||
14817 | '</tr>' + |
||
14818 | '</thead>' + |
||
14819 | '<tbody class="fc-body">' + |
||
14820 | '<tr>' + |
||
14821 | '<td class="' + this.widgetContentClass + '">' + |
||
14822 | (this.dayGrid ? |
||
14823 | '<div class="fc-day-grid"/>' + |
||
14824 | '<hr class="fc-divider ' + this.widgetHeaderClass + '"/>' : |
||
14825 | '' |
||
14826 | ) + |
||
14827 | '</td>' + |
||
14828 | '</tr>' + |
||
14829 | '</tbody>' + |
||
14830 | '</table>'; |
||
14831 | }, |
||
14832 | |||
14833 | |||
14834 | // Generates an HTML attribute string for setting the width of the axis, if it is known |
||
14835 | axisStyleAttr: function() { |
||
14836 | if (this.axisWidth !== null) { |
||
14837 | return 'style="width:' + this.axisWidth + 'px"'; |
||
14838 | } |
||
14839 | return ''; |
||
14840 | }, |
||
14841 | |||
14842 | |||
14843 | /* Business Hours |
||
14844 | ------------------------------------------------------------------------------------------------------------------*/ |
||
14845 | |||
14846 | |||
14847 | renderBusinessHours: function() { |
||
14848 | this.timeGrid.renderBusinessHours(); |
||
14849 | |||
14850 | if (this.dayGrid) { |
||
14851 | this.dayGrid.renderBusinessHours(); |
||
14852 | } |
||
14853 | }, |
||
14854 | |||
14855 | |||
14856 | unrenderBusinessHours: function() { |
||
14857 | this.timeGrid.unrenderBusinessHours(); |
||
14858 | |||
14859 | if (this.dayGrid) { |
||
14860 | this.dayGrid.unrenderBusinessHours(); |
||
14861 | } |
||
14862 | }, |
||
14863 | |||
14864 | |||
14865 | /* Now Indicator |
||
14866 | ------------------------------------------------------------------------------------------------------------------*/ |
||
14867 | |||
14868 | |||
14869 | getNowIndicatorUnit: function() { |
||
14870 | return this.timeGrid.getNowIndicatorUnit(); |
||
14871 | }, |
||
14872 | |||
14873 | |||
14874 | renderNowIndicator: function(date) { |
||
14875 | this.timeGrid.renderNowIndicator(date); |
||
14876 | }, |
||
14877 | |||
14878 | |||
14879 | unrenderNowIndicator: function() { |
||
14880 | this.timeGrid.unrenderNowIndicator(); |
||
14881 | }, |
||
14882 | |||
14883 | |||
14884 | /* Dimensions |
||
14885 | ------------------------------------------------------------------------------------------------------------------*/ |
||
14886 | |||
14887 | |||
14888 | updateSize: function(isResize) { |
||
14889 | this.timeGrid.updateSize(isResize); |
||
14890 | |||
14891 | View.prototype.updateSize.call(this, isResize); // call the super-method |
||
14892 | }, |
||
14893 | |||
14894 | |||
14895 | // Refreshes the horizontal dimensions of the view |
||
14896 | updateWidth: function() { |
||
14897 | // make all axis cells line up, and record the width so newly created axis cells will have it |
||
14898 | this.axisWidth = matchCellWidths(this.el.find('.fc-axis')); |
||
14899 | }, |
||
14900 | |||
14901 | |||
14902 | // Adjusts the vertical dimensions of the view to the specified values |
||
14903 | setHeight: function(totalHeight, isAuto) { |
||
14904 | var eventLimit; |
||
14905 | var scrollerHeight; |
||
14906 | var scrollbarWidths; |
||
14907 | |||
14908 | // reset all dimensions back to the original state |
||
14909 | this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary |
||
14910 | this.scroller.clear(); // sets height to 'auto' and clears overflow |
||
14911 | uncompensateScroll(this.noScrollRowEls); |
||
14912 | |||
14913 | // limit number of events in the all-day area |
||
14914 | if (this.dayGrid) { |
||
14915 | this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed |
||
14916 | |||
14917 | eventLimit = this.opt('eventLimit'); |
||
14918 | if (eventLimit && typeof eventLimit !== 'number') { |
||
14919 | eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number |
||
14920 | } |
||
14921 | if (eventLimit) { |
||
14922 | this.dayGrid.limitRows(eventLimit); |
||
14923 | } |
||
14924 | } |
||
14925 | |||
14926 | if (!isAuto) { // should we force dimensions of the scroll container? |
||
14927 | |||
14928 | scrollerHeight = this.computeScrollerHeight(totalHeight); |
||
14929 | this.scroller.setHeight(scrollerHeight); |
||
14930 | scrollbarWidths = this.scroller.getScrollbarWidths(); |
||
14931 | |||
14932 | if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars? |
||
14933 | |||
14934 | // make the all-day and header rows lines up |
||
14935 | compensateScroll(this.noScrollRowEls, scrollbarWidths); |
||
14936 | |||
14937 | // the scrollbar compensation might have changed text flow, which might affect height, so recalculate |
||
14938 | // and reapply the desired height to the scroller. |
||
14939 | scrollerHeight = this.computeScrollerHeight(totalHeight); |
||
14940 | this.scroller.setHeight(scrollerHeight); |
||
14941 | } |
||
14942 | |||
14943 | // guarantees the same scrollbar widths |
||
14944 | this.scroller.lockOverflow(scrollbarWidths); |
||
14945 | |||
14946 | // if there's any space below the slats, show the horizontal rule. |
||
14947 | // this won't cause any new overflow, because lockOverflow already called. |
||
14948 | if (this.timeGrid.getTotalSlatHeight() < scrollerHeight) { |
||
14949 | this.bottomRuleEl.show(); |
||
14950 | } |
||
14951 | } |
||
14952 | }, |
||
14953 | |||
14954 | |||
14955 | // given a desired total height of the view, returns what the height of the scroller should be |
||
14956 | computeScrollerHeight: function(totalHeight) { |
||
14957 | return totalHeight - |
||
14958 | subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller |
||
14959 | }, |
||
14960 | |||
14961 | |||
14962 | /* Scroll |
||
14963 | ------------------------------------------------------------------------------------------------------------------*/ |
||
14964 | |||
14965 | |||
14966 | // Computes the initial pre-configured scroll state prior to allowing the user to change it |
||
14967 | computeInitialDateScroll: function() { |
||
14968 | var scrollTime = moment.duration(this.opt('scrollTime')); |
||
14969 | var top = this.timeGrid.computeTimeTop(scrollTime); |
||
14970 | |||
14971 | // zoom can give weird floating-point values. rather scroll a little bit further |
||
14972 | top = Math.ceil(top); |
||
14973 | |||
14974 | if (top) { |
||
14975 | top++; // to overcome top border that slots beyond the first have. looks better |
||
14976 | } |
||
14977 | |||
14978 | return { top: top }; |
||
14979 | }, |
||
14980 | |||
14981 | |||
14982 | queryDateScroll: function() { |
||
14983 | return { top: this.scroller.getScrollTop() }; |
||
14984 | }, |
||
14985 | |||
14986 | |||
14987 | applyDateScroll: function(scroll) { |
||
14988 | if (scroll.top !== undefined) { |
||
14989 | this.scroller.setScrollTop(scroll.top); |
||
14990 | } |
||
14991 | }, |
||
14992 | |||
14993 | |||
14994 | /* Hit Areas |
||
14995 | ------------------------------------------------------------------------------------------------------------------*/ |
||
14996 | // forward all hit-related method calls to the grids (dayGrid might not be defined) |
||
14997 | |||
14998 | |||
14999 | hitsNeeded: function() { |
||
15000 | this.timeGrid.hitsNeeded(); |
||
15001 | if (this.dayGrid) { |
||
15002 | this.dayGrid.hitsNeeded(); |
||
15003 | } |
||
15004 | }, |
||
15005 | |||
15006 | |||
15007 | hitsNotNeeded: function() { |
||
15008 | this.timeGrid.hitsNotNeeded(); |
||
15009 | if (this.dayGrid) { |
||
15010 | this.dayGrid.hitsNotNeeded(); |
||
15011 | } |
||
15012 | }, |
||
15013 | |||
15014 | |||
15015 | prepareHits: function() { |
||
15016 | this.timeGrid.prepareHits(); |
||
15017 | if (this.dayGrid) { |
||
15018 | this.dayGrid.prepareHits(); |
||
15019 | } |
||
15020 | }, |
||
15021 | |||
15022 | |||
15023 | releaseHits: function() { |
||
15024 | this.timeGrid.releaseHits(); |
||
15025 | if (this.dayGrid) { |
||
15026 | this.dayGrid.releaseHits(); |
||
15027 | } |
||
15028 | }, |
||
15029 | |||
15030 | |||
15031 | queryHit: function(left, top) { |
||
15032 | var hit = this.timeGrid.queryHit(left, top); |
||
15033 | |||
15034 | if (!hit && this.dayGrid) { |
||
15035 | hit = this.dayGrid.queryHit(left, top); |
||
15036 | } |
||
15037 | |||
15038 | return hit; |
||
15039 | }, |
||
15040 | |||
15041 | |||
15042 | getHitSpan: function(hit) { |
||
15043 | // TODO: hit.component is set as a hack to identify where the hit came from |
||
15044 | return hit.component.getHitSpan(hit); |
||
15045 | }, |
||
15046 | |||
15047 | |||
15048 | getHitEl: function(hit) { |
||
15049 | // TODO: hit.component is set as a hack to identify where the hit came from |
||
15050 | return hit.component.getHitEl(hit); |
||
15051 | }, |
||
15052 | |||
15053 | |||
15054 | /* Events |
||
15055 | ------------------------------------------------------------------------------------------------------------------*/ |
||
15056 | |||
15057 | |||
15058 | // Renders events onto the view and populates the View's segment array |
||
15059 | renderEvents: function(events) { |
||
15060 | var dayEvents = []; |
||
15061 | var timedEvents = []; |
||
15062 | var daySegs = []; |
||
15063 | var timedSegs; |
||
15064 | var i; |
||
15065 | |||
15066 | // separate the events into all-day and timed |
||
15067 | for (i = 0; i < events.length; i++) { |
||
15068 | if (events[i].allDay) { |
||
15069 | dayEvents.push(events[i]); |
||
15070 | } |
||
15071 | else { |
||
15072 | timedEvents.push(events[i]); |
||
15073 | } |
||
15074 | } |
||
15075 | |||
15076 | // render the events in the subcomponents |
||
15077 | timedSegs = this.timeGrid.renderEvents(timedEvents); |
||
15078 | if (this.dayGrid) { |
||
15079 | daySegs = this.dayGrid.renderEvents(dayEvents); |
||
15080 | } |
||
15081 | |||
15082 | // the all-day area is flexible and might have a lot of events, so shift the height |
||
15083 | this.updateHeight(); |
||
15084 | }, |
||
15085 | |||
15086 | |||
15087 | // Retrieves all segment objects that are rendered in the view |
||
15088 | getEventSegs: function() { |
||
15089 | return this.timeGrid.getEventSegs().concat( |
||
15090 | this.dayGrid ? this.dayGrid.getEventSegs() : [] |
||
15091 | ); |
||
15092 | }, |
||
15093 | |||
15094 | |||
15095 | // Unrenders all event elements and clears internal segment data |
||
15096 | unrenderEvents: function() { |
||
15097 | |||
15098 | // unrender the events in the subcomponents |
||
15099 | this.timeGrid.unrenderEvents(); |
||
15100 | if (this.dayGrid) { |
||
15101 | this.dayGrid.unrenderEvents(); |
||
15102 | } |
||
15103 | |||
15104 | // we DON'T need to call updateHeight() because |
||
15105 | // a renderEvents() call always happens after this, which will eventually call updateHeight() |
||
15106 | }, |
||
15107 | |||
15108 | |||
15109 | /* Dragging (for events and external elements) |
||
15110 | ------------------------------------------------------------------------------------------------------------------*/ |
||
15111 | |||
15112 | |||
15113 | // A returned value of `true` signals that a mock "helper" event has been rendered. |
||
15114 | renderDrag: function(dropLocation, seg) { |
||
15115 | if (dropLocation.start.hasTime()) { |
||
15116 | return this.timeGrid.renderDrag(dropLocation, seg); |
||
15117 | } |
||
15118 | else if (this.dayGrid) { |
||
15119 | return this.dayGrid.renderDrag(dropLocation, seg); |
||
15120 | } |
||
15121 | }, |
||
15122 | |||
15123 | |||
15124 | unrenderDrag: function() { |
||
15125 | this.timeGrid.unrenderDrag(); |
||
15126 | if (this.dayGrid) { |
||
15127 | this.dayGrid.unrenderDrag(); |
||
15128 | } |
||
15129 | }, |
||
15130 | |||
15131 | |||
15132 | /* Selection |
||
15133 | ------------------------------------------------------------------------------------------------------------------*/ |
||
15134 | |||
15135 | |||
15136 | // Renders a visual indication of a selection |
||
15137 | renderSelection: function(span) { |
||
15138 | if (span.start.hasTime() || span.end.hasTime()) { |
||
15139 | this.timeGrid.renderSelection(span); |
||
15140 | } |
||
15141 | else if (this.dayGrid) { |
||
15142 | this.dayGrid.renderSelection(span); |
||
15143 | } |
||
15144 | }, |
||
15145 | |||
15146 | |||
15147 | // Unrenders a visual indications of a selection |
||
15148 | unrenderSelection: function() { |
||
15149 | this.timeGrid.unrenderSelection(); |
||
15150 | if (this.dayGrid) { |
||
15151 | this.dayGrid.unrenderSelection(); |
||
15152 | } |
||
15153 | } |
||
15154 | |||
15155 | }); |
||
15156 | |||
15157 | |||
15158 | // Methods that will customize the rendering behavior of the AgendaView's timeGrid |
||
15159 | // TODO: move into TimeGrid |
||
15160 | var agendaTimeGridMethods = { |
||
15161 | |||
15162 | |||
15163 | // Generates the HTML that will go before the day-of week header cells |
||
15164 | renderHeadIntroHtml: function() { |
||
15165 | var view = this.view; |
||
15166 | var weekText; |
||
15167 | |||
15168 | if (view.opt('weekNumbers')) { |
||
15169 | weekText = this.start.format(view.opt('smallWeekFormat')); |
||
15170 | |||
15171 | return '' + |
||
15172 | '<th class="fc-axis fc-week-number ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '>' + |
||
15173 | view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths |
||
15174 | { date: this.start, type: 'week', forceOff: this.colCnt > 1 }, |
||
15175 | htmlEscape(weekText) // inner HTML |
||
15176 | ) + |
||
15177 | '</th>'; |
||
15178 | } |
||
15179 | else { |
||
15180 | return '<th class="fc-axis ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '></th>'; |
||
15181 | } |
||
15182 | }, |
||
15183 | |||
15184 | |||
15185 | // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column. |
||
15186 | renderBgIntroHtml: function() { |
||
15187 | var view = this.view; |
||
15188 | |||
15189 | return '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '></td>'; |
||
15190 | }, |
||
15191 | |||
15192 | |||
15193 | // Generates the HTML that goes before all other types of cells. |
||
15194 | // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. |
||
15195 | renderIntroHtml: function() { |
||
15196 | var view = this.view; |
||
15197 | |||
15198 | return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>'; |
||
15199 | } |
||
15200 | |||
15201 | }; |
||
15202 | |||
15203 | |||
15204 | // Methods that will customize the rendering behavior of the AgendaView's dayGrid |
||
15205 | var agendaDayGridMethods = { |
||
15206 | |||
15207 | |||
15208 | // Generates the HTML that goes before the all-day cells |
||
15209 | renderBgIntroHtml: function() { |
||
15210 | var view = this.view; |
||
15211 | |||
15212 | return '' + |
||
15213 | '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' + |
||
15214 | '<span>' + // needed for matchCellWidths |
||
15215 | view.getAllDayHtml() + |
||
15216 | '</span>' + |
||
15217 | '</td>'; |
||
15218 | }, |
||
15219 | |||
15220 | |||
15221 | // Generates the HTML that goes before all other types of cells. |
||
15222 | // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. |
||
15223 | renderIntroHtml: function() { |
||
15224 | var view = this.view; |
||
15225 | |||
15226 | return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>'; |
||
15227 | } |
||
15228 | |||
15229 | }; |
||
15230 | |||
15231 | ;; |
||
15232 | |||
15233 | var AGENDA_ALL_DAY_EVENT_LIMIT = 5; |
||
15234 | |||
15235 | // potential nice values for the slot-duration and interval-duration |
||
15236 | // from largest to smallest |
||
15237 | var AGENDA_STOCK_SUB_DURATIONS = [ |
||
15238 | { hours: 1 }, |
||
15239 | { minutes: 30 }, |
||
15240 | { minutes: 15 }, |
||
15241 | { seconds: 30 }, |
||
15242 | { seconds: 15 } |
||
15243 | ]; |
||
15244 | |||
15245 | fcViews.agenda = { |
||
15246 | 'class': AgendaView, |
||
15247 | defaults: { |
||
15248 | allDaySlot: true, |
||
15249 | slotDuration: '00:30:00', |
||
15250 | slotEventOverlap: true // a bad name. confused with overlap/constraint system |
||
15251 | } |
||
15252 | }; |
||
15253 | |||
15254 | fcViews.agendaDay = { |
||
15255 | type: 'agenda', |
||
15256 | duration: { days: 1 } |
||
15257 | }; |
||
15258 | |||
15259 | fcViews.agendaWeek = { |
||
15260 | type: 'agenda', |
||
15261 | duration: { weeks: 1 } |
||
15262 | }; |
||
15263 | ;; |
||
15264 | |||
15265 | /* |
||
15266 | Responsible for the scroller, and forwarding event-related actions into the "grid" |
||
15267 | */ |
||
15268 | var ListView = View.extend({ |
||
15269 | |||
15270 | grid: null, |
||
15271 | scroller: null, |
||
15272 | |||
15273 | initialize: function() { |
||
15274 | this.grid = new ListViewGrid(this); |
||
15275 | this.scroller = new Scroller({ |
||
15276 | overflowX: 'hidden', |
||
15277 | overflowY: 'auto' |
||
15278 | }); |
||
15279 | }, |
||
15280 | |||
15281 | renderSkeleton: function() { |
||
15282 | this.el.addClass( |
||
15283 | 'fc-list-view ' + |
||
15284 | this.widgetContentClass |
||
15285 | ); |
||
15286 | |||
15287 | this.scroller.render(); |
||
15288 | this.scroller.el.appendTo(this.el); |
||
15289 | |||
15290 | this.grid.setElement(this.scroller.scrollEl); |
||
15291 | }, |
||
15292 | |||
15293 | unrenderSkeleton: function() { |
||
15294 | this.scroller.destroy(); // will remove the Grid too |
||
15295 | }, |
||
15296 | |||
15297 | setHeight: function(totalHeight, isAuto) { |
||
15298 | this.scroller.setHeight(this.computeScrollerHeight(totalHeight)); |
||
15299 | }, |
||
15300 | |||
15301 | computeScrollerHeight: function(totalHeight) { |
||
15302 | return totalHeight - |
||
15303 | subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller |
||
15304 | }, |
||
15305 | |||
15306 | renderDates: function() { |
||
15307 | this.grid.setRange(this.renderRange); // needs to process range-related options |
||
15308 | }, |
||
15309 | |||
15310 | renderEvents: function(events) { |
||
15311 | this.grid.renderEvents(events); |
||
15312 | }, |
||
15313 | |||
15314 | unrenderEvents: function() { |
||
15315 | this.grid.unrenderEvents(); |
||
15316 | }, |
||
15317 | |||
15318 | isEventResizable: function(event) { |
||
15319 | return false; |
||
15320 | }, |
||
15321 | |||
15322 | isEventDraggable: function(event) { |
||
15323 | return false; |
||
15324 | } |
||
15325 | |||
15326 | }); |
||
15327 | |||
15328 | /* |
||
15329 | Responsible for event rendering and user-interaction. |
||
15330 | Its "el" is the inner-content of the above view's scroller. |
||
15331 | */ |
||
15332 | var ListViewGrid = Grid.extend({ |
||
15333 | |||
15334 | segSelector: '.fc-list-item', // which elements accept event actions |
||
15335 | hasDayInteractions: false, // no day selection or day clicking |
||
15336 | |||
15337 | // slices by day |
||
15338 | spanToSegs: function(span) { |
||
15339 | var view = this.view; |
||
15340 | var dayStart = view.renderRange.start.clone().time(0); // timed, so segs get times! |
||
15341 | var dayIndex = 0; |
||
15342 | var seg; |
||
15343 | var segs = []; |
||
15344 | |||
15345 | while (dayStart < view.renderRange.end) { |
||
15346 | |||
15347 | seg = intersectRanges(span, { |
||
15348 | start: dayStart, |
||
15349 | end: dayStart.clone().add(1, 'day') |
||
15350 | }); |
||
15351 | |||
15352 | if (seg) { |
||
15353 | seg.dayIndex = dayIndex; |
||
15354 | segs.push(seg); |
||
15355 | } |
||
15356 | |||
15357 | dayStart.add(1, 'day'); |
||
15358 | dayIndex++; |
||
15359 | |||
15360 | // detect when span won't go fully into the next day, |
||
15361 | // and mutate the latest seg to the be the end. |
||
15362 | if ( |
||
15363 | seg && !seg.isEnd && span.end.hasTime() && |
||
15364 | span.end < dayStart.clone().add(this.view.nextDayThreshold) |
||
15365 | ) { |
||
15366 | seg.end = span.end.clone(); |
||
15367 | seg.isEnd = true; |
||
15368 | break; |
||
15369 | } |
||
15370 | } |
||
15371 | |||
15372 | return segs; |
||
15373 | }, |
||
15374 | |||
15375 | // like "4:00am" |
||
15376 | computeEventTimeFormat: function() { |
||
15377 | return this.view.opt('mediumTimeFormat'); |
||
15378 | }, |
||
15379 | |||
15380 | // for events with a url, the whole <tr> should be clickable, |
||
15381 | // but it's impossible to wrap with an <a> tag. simulate this. |
||
15382 | handleSegClick: function(seg, ev) { |
||
15383 | var url; |
||
15384 | |||
15385 | Grid.prototype.handleSegClick.apply(this, arguments); // super. might prevent the default action |
||
15386 | |||
15387 | // not clicking on or within an <a> with an href |
||
15388 | if (!$(ev.target).closest('a[href]').length) { |
||
15389 | url = seg.event.url; |
||
15390 | if (url && !ev.isDefaultPrevented()) { // jsEvent not cancelled in handler |
||
15391 | window.location.href = url; // simulate link click |
||
15392 | } |
||
15393 | } |
||
15394 | }, |
||
15395 | |||
15396 | // returns list of foreground segs that were actually rendered |
||
15397 | renderFgSegs: function(segs) { |
||
15398 | segs = this.renderFgSegEls(segs); // might filter away hidden events |
||
15399 | |||
15400 | if (!segs.length) { |
||
15401 | this.renderEmptyMessage(); |
||
15402 | } |
||
15403 | else { |
||
15404 | this.renderSegList(segs); |
||
15405 | } |
||
15406 | |||
15407 | return segs; |
||
15408 | }, |
||
15409 | |||
15410 | renderEmptyMessage: function() { |
||
15411 | this.el.html( |
||
15412 | '<div class="fc-list-empty-wrap2">' + // TODO: try less wraps |
||
15413 | '<div class="fc-list-empty-wrap1">' + |
||
15414 | '<div class="fc-list-empty">' + |
||
15415 | htmlEscape(this.view.opt('noEventsMessage')) + |
||
15416 | '</div>' + |
||
15417 | '</div>' + |
||
15418 | '</div>' |
||
15419 | ); |
||
15420 | }, |
||
15421 | |||
15422 | // render the event segments in the view |
||
15423 | renderSegList: function(allSegs) { |
||
15424 | var segsByDay = this.groupSegsByDay(allSegs); // sparse array |
||
15425 | var dayIndex; |
||
15426 | var daySegs; |
||
15427 | var i; |
||
15428 | var tableEl = $('<table class="fc-list-table"><tbody/></table>'); |
||
15429 | var tbodyEl = tableEl.find('tbody'); |
||
15430 | |||
15431 | for (dayIndex = 0; dayIndex < segsByDay.length; dayIndex++) { |
||
15432 | daySegs = segsByDay[dayIndex]; |
||
15433 | if (daySegs) { // sparse array, so might be undefined |
||
15434 | |||
15435 | // append a day header |
||
15436 | tbodyEl.append(this.dayHeaderHtml( |
||
15437 | this.view.renderRange.start.clone().add(dayIndex, 'days') |
||
15438 | )); |
||
15439 | |||
15440 | this.sortEventSegs(daySegs); |
||
15441 | |||
15442 | for (i = 0; i < daySegs.length; i++) { |
||
15443 | tbodyEl.append(daySegs[i].el); // append event row |
||
15444 | } |
||
15445 | } |
||
15446 | } |
||
15447 | |||
15448 | this.el.empty().append(tableEl); |
||
15449 | }, |
||
15450 | |||
15451 | // Returns a sparse array of arrays, segs grouped by their dayIndex |
||
15452 | groupSegsByDay: function(segs) { |
||
15453 | var segsByDay = []; // sparse array |
||
15454 | var i, seg; |
||
15455 | |||
15456 | for (i = 0; i < segs.length; i++) { |
||
15457 | seg = segs[i]; |
||
15458 | (segsByDay[seg.dayIndex] || (segsByDay[seg.dayIndex] = [])) |
||
15459 | .push(seg); |
||
15460 | } |
||
15461 | |||
15462 | return segsByDay; |
||
15463 | }, |
||
15464 | |||
15465 | // generates the HTML for the day headers that live amongst the event rows |
||
15466 | dayHeaderHtml: function(dayDate) { |
||
15467 | var view = this.view; |
||
15468 | var mainFormat = view.opt('listDayFormat'); |
||
15469 | var altFormat = view.opt('listDayAltFormat'); |
||
15470 | |||
15471 | return '<tr class="fc-list-heading" data-date="' + dayDate.format('YYYY-MM-DD') + '">' + |
||
15472 | '<td class="' + view.widgetHeaderClass + '" colspan="3">' + |
||
15473 | (mainFormat ? |
||
15474 | view.buildGotoAnchorHtml( |
||
15475 | dayDate, |
||
15476 | { 'class': 'fc-list-heading-main' }, |
||
15477 | htmlEscape(dayDate.format(mainFormat)) // inner HTML |
||
15478 | ) : |
||
15479 | '') + |
||
15480 | (altFormat ? |
||
15481 | view.buildGotoAnchorHtml( |
||
15482 | dayDate, |
||
15483 | { 'class': 'fc-list-heading-alt' }, |
||
15484 | htmlEscape(dayDate.format(altFormat)) // inner HTML |
||
15485 | ) : |
||
15486 | '') + |
||
15487 | '</td>' + |
||
15488 | '</tr>'; |
||
15489 | }, |
||
15490 | |||
15491 | // generates the HTML for a single event row |
||
15492 | fgSegHtml: function(seg) { |
||
15493 | var view = this.view; |
||
15494 | var classes = [ 'fc-list-item' ].concat(this.getSegCustomClasses(seg)); |
||
15495 | var bgColor = this.getSegBackgroundColor(seg); |
||
15496 | var event = seg.event; |
||
15497 | var url = event.url; |
||
15498 | var timeHtml; |
||
15499 | |||
15500 | if (event.allDay) { |
||
15501 | timeHtml = view.getAllDayHtml(); |
||
15502 | } |
||
15503 | else if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day |
||
15504 | if (seg.isStart || seg.isEnd) { // outer segment that probably lasts part of the day |
||
15505 | timeHtml = htmlEscape(this.getEventTimeText(seg)); |
||
15506 | } |
||
15507 | else { // inner segment that lasts the whole day |
||
15508 | timeHtml = view.getAllDayHtml(); |
||
15509 | } |
||
15510 | } |
||
15511 | else { |
||
15512 | // Display the normal time text for the *event's* times |
||
15513 | timeHtml = htmlEscape(this.getEventTimeText(event)); |
||
15514 | } |
||
15515 | |||
15516 | if (url) { |
||
15517 | classes.push('fc-has-url'); |
||
15518 | } |
||
15519 | |||
15520 | return '<tr class="' + classes.join(' ') + '">' + |
||
15521 | (this.displayEventTime ? |
||
15522 | '<td class="fc-list-item-time ' + view.widgetContentClass + '">' + |
||
15523 | (timeHtml || '') + |
||
15524 | '</td>' : |
||
15525 | '') + |
||
15526 | '<td class="fc-list-item-marker ' + view.widgetContentClass + '">' + |
||
15527 | '<span class="fc-event-dot"' + |
||
15528 | (bgColor ? |
||
15529 | ' style="background-color:' + bgColor + '"' : |
||
15530 | '') + |
||
15531 | '></span>' + |
||
15532 | '</td>' + |
||
15533 | '<td class="fc-list-item-title ' + view.widgetContentClass + '">' + |
||
15534 | '<a' + (url ? ' href="' + htmlEscape(url) + '"' : '') + '>' + |
||
15535 | htmlEscape(seg.event.title || '') + |
||
15536 | '</a>' + |
||
15537 | '</td>' + |
||
15538 | '</tr>'; |
||
15539 | } |
||
15540 | |||
15541 | }); |
||
15542 | |||
15543 | ;; |
||
15544 | |||
15545 | fcViews.list = { |
||
15546 | 'class': ListView, |
||
15547 | buttonTextKey: 'list', // what to lookup in locale files |
||
15548 | defaults: { |
||
15549 | buttonText: 'list', // text to display for English |
||
15550 | listDayFormat: 'LL', // like "January 1, 2016" |
||
15551 | noEventsMessage: 'No events to display' |
||
15552 | } |
||
15553 | }; |
||
15554 | |||
15555 | fcViews.listDay = { |
||
15556 | type: 'list', |
||
15557 | duration: { days: 1 }, |
||
15558 | defaults: { |
||
15559 | listDayFormat: 'dddd' // day-of-week is all we need. full date is probably in header |
||
15560 | } |
||
15561 | }; |
||
15562 | |||
15563 | fcViews.listWeek = { |
||
15564 | type: 'list', |
||
15565 | duration: { weeks: 1 }, |
||
15566 | defaults: { |
||
15567 | listDayFormat: 'dddd', // day-of-week is more important |
||
15568 | listDayAltFormat: 'LL' |
||
15569 | } |
||
15570 | }; |
||
15571 | |||
15572 | fcViews.listMonth = { |
||
15573 | type: 'list', |
||
15574 | duration: { month: 1 }, |
||
15575 | defaults: { |
||
15576 | listDayAltFormat: 'dddd' // day-of-week is nice-to-have |
||
15577 | } |
||
15578 | }; |
||
15579 | |||
15580 | fcViews.listYear = { |
||
15581 | type: 'list', |
||
15582 | duration: { year: 1 }, |
||
15583 | defaults: { |
||
15584 | listDayAltFormat: 'dddd' // day-of-week is nice-to-have |
||
15585 | } |
||
15586 | }; |
||
15587 | |||
15588 | ;; |
||
15589 | |||
15590 | return FC; // export for Node/CommonJS |
||
15591 | }); |