]>
Commit | Line | Data |
---|---|---|
31f18b77 FG |
1 | /*! |
2 | * FullCalendar v2.2.5 | |
3 | * Docs & License: http://arshaw.com/fullcalendar/ | |
4 | * (c) 2013 Adam Shaw | |
5 | */ | |
6 | ||
7 | (function(factory) { | |
8 | if (typeof define === 'function' && define.amd) { | |
9 | define([ 'jquery', 'moment' ], factory); | |
10 | } | |
11 | else { | |
12 | factory(jQuery, moment); | |
13 | } | |
14 | })(function($, moment) { | |
15 | ||
16 | var defaults = { | |
17 | ||
18 | titleRangeSeparator: ' \u2014 ', // emphasized dash | |
19 | monthYearFormat: 'MMMM YYYY', // required for en. other languages rely on datepicker computable option | |
20 | ||
21 | defaultTimedEventDuration: '02:00:00', | |
22 | defaultAllDayEventDuration: { days: 1 }, | |
23 | forceEventDuration: false, | |
24 | nextDayThreshold: '09:00:00', // 9am | |
25 | ||
26 | // display | |
27 | defaultView: 'month', | |
28 | aspectRatio: 1.35, | |
29 | header: { | |
30 | left: 'title', | |
31 | center: '', | |
32 | right: 'today prev,next' | |
33 | }, | |
34 | weekends: true, | |
35 | weekNumbers: false, | |
36 | ||
37 | weekNumberTitle: 'W', | |
38 | weekNumberCalculation: 'local', | |
39 | ||
40 | //editable: false, | |
41 | ||
42 | // event ajax | |
43 | lazyFetching: true, | |
44 | startParam: 'start', | |
45 | endParam: 'end', | |
46 | timezoneParam: 'timezone', | |
47 | ||
48 | timezone: false, | |
49 | ||
50 | //allDayDefault: undefined, | |
51 | ||
52 | // locale | |
53 | isRTL: false, | |
54 | defaultButtonText: { | |
55 | prev: "prev", | |
56 | next: "next", | |
57 | prevYear: "prev year", | |
58 | nextYear: "next year", | |
59 | today: 'today', | |
60 | month: 'month', | |
61 | week: 'week', | |
62 | day: 'day' | |
63 | }, | |
64 | ||
65 | buttonIcons: { | |
66 | prev: 'left-single-arrow', | |
67 | next: 'right-single-arrow', | |
68 | prevYear: 'left-double-arrow', | |
69 | nextYear: 'right-double-arrow' | |
70 | }, | |
71 | ||
72 | // jquery-ui theming | |
73 | theme: false, | |
74 | themeButtonIcons: { | |
75 | prev: 'circle-triangle-w', | |
76 | next: 'circle-triangle-e', | |
77 | prevYear: 'seek-prev', | |
78 | nextYear: 'seek-next' | |
79 | }, | |
80 | ||
81 | dragOpacity: .75, | |
82 | dragRevertDuration: 500, | |
83 | dragScroll: true, | |
84 | ||
85 | //selectable: false, | |
86 | unselectAuto: true, | |
87 | ||
88 | dropAccept: '*', | |
89 | ||
90 | eventLimit: false, | |
91 | eventLimitText: 'more', | |
92 | eventLimitClick: 'popover', | |
93 | dayPopoverFormat: 'LL', | |
94 | ||
95 | handleWindowResize: true, | |
96 | windowResizeDelay: 200 // milliseconds before an updateSize happens | |
97 | ||
98 | }; | |
99 | ||
100 | ||
101 | var englishDefaults = { | |
102 | dayPopoverFormat: 'dddd, MMMM D' | |
103 | }; | |
104 | ||
105 | ||
106 | // right-to-left defaults | |
107 | var rtlDefaults = { | |
108 | header: { | |
109 | left: 'next,prev today', | |
110 | center: '', | |
111 | right: 'title' | |
112 | }, | |
113 | buttonIcons: { | |
114 | prev: 'right-single-arrow', | |
115 | next: 'left-single-arrow', | |
116 | prevYear: 'right-double-arrow', | |
117 | nextYear: 'left-double-arrow' | |
118 | }, | |
119 | themeButtonIcons: { | |
120 | prev: 'circle-triangle-e', | |
121 | next: 'circle-triangle-w', | |
122 | nextYear: 'seek-prev', | |
123 | prevYear: 'seek-next' | |
124 | } | |
125 | }; | |
126 | ||
127 | var fc = $.fullCalendar = { version: "2.2.5" }; | |
128 | var fcViews = fc.views = {}; | |
129 | ||
130 | ||
131 | $.fn.fullCalendar = function(options) { | |
132 | var args = Array.prototype.slice.call(arguments, 1); // for a possible method call | |
133 | var res = this; // what this function will return (this jQuery object by default) | |
134 | ||
135 | this.each(function(i, _element) { // loop each DOM element involved | |
136 | var element = $(_element); | |
137 | var calendar = element.data('fullCalendar'); // get the existing calendar object (if any) | |
138 | var singleRes; // the returned value of this single method call | |
139 | ||
140 | // a method call | |
141 | if (typeof options === 'string') { | |
142 | if (calendar && $.isFunction(calendar[options])) { | |
143 | singleRes = calendar[options].apply(calendar, args); | |
144 | if (!i) { | |
145 | res = singleRes; // record the first method call result | |
146 | } | |
147 | if (options === 'destroy') { // for the destroy method, must remove Calendar object data | |
148 | element.removeData('fullCalendar'); | |
149 | } | |
150 | } | |
151 | } | |
152 | // a new calendar initialization | |
153 | else if (!calendar) { // don't initialize twice | |
154 | calendar = new Calendar(element, options); | |
155 | element.data('fullCalendar', calendar); | |
156 | calendar.render(); | |
157 | } | |
158 | }); | |
159 | ||
160 | return res; | |
161 | }; | |
162 | ||
163 | ||
164 | // function for adding/overriding defaults | |
165 | function setDefaults(d) { | |
166 | mergeOptions(defaults, d); | |
167 | } | |
168 | ||
169 | ||
170 | // Recursively combines option hash-objects. | |
171 | // Better than `$.extend(true, ...)` because arrays are not traversed/copied. | |
172 | // | |
173 | // called like: | |
174 | // mergeOptions(target, obj1, obj2, ...) | |
175 | // | |
176 | function mergeOptions(target) { | |
177 | ||
178 | function mergeIntoTarget(name, value) { | |
179 | if ($.isPlainObject(value) && $.isPlainObject(target[name]) && !isForcedAtomicOption(name)) { | |
180 | // merge into a new object to avoid destruction | |
181 | target[name] = mergeOptions({}, target[name], value); // combine. `value` object takes precedence | |
182 | } | |
183 | else if (value !== undefined) { // only use values that are set and not undefined | |
184 | target[name] = value; | |
185 | } | |
186 | } | |
187 | ||
188 | for (var i=1; i<arguments.length; i++) { | |
189 | $.each(arguments[i], mergeIntoTarget); | |
190 | } | |
191 | ||
192 | return target; | |
193 | } | |
194 | ||
195 | ||
196 | // overcome sucky view-option-hash and option-merging behavior messing with options it shouldn't | |
197 | function isForcedAtomicOption(name) { | |
198 | // Any option that ends in "Time" or "Duration" is probably a Duration, | |
199 | // and these will commonly be specified as plain objects, which we don't want to mess up. | |
200 | return /(Time|Duration)$/.test(name); | |
201 | } | |
202 | // FIX: find a different solution for view-option-hashes and have a whitelist | |
203 | // for options that can be recursively merged. | |
204 | ||
205 | var langOptionHash = fc.langs = {}; // initialize and expose | |
206 | ||
207 | ||
208 | // TODO: document the structure and ordering of a FullCalendar lang file | |
209 | // TODO: rename everything "lang" to "locale", like what the moment project did | |
210 | ||
211 | ||
212 | // Initialize jQuery UI datepicker translations while using some of the translations | |
213 | // Will set this as the default language for datepicker. | |
214 | fc.datepickerLang = function(langCode, dpLangCode, dpOptions) { | |
215 | ||
216 | // get the FullCalendar internal option hash for this language. create if necessary | |
217 | var fcOptions = langOptionHash[langCode] || (langOptionHash[langCode] = {}); | |
218 | ||
219 | // transfer some simple options from datepicker to fc | |
220 | fcOptions.isRTL = dpOptions.isRTL; | |
221 | fcOptions.weekNumberTitle = dpOptions.weekHeader; | |
222 | ||
223 | // compute some more complex options from datepicker | |
224 | $.each(dpComputableOptions, function(name, func) { | |
225 | fcOptions[name] = func(dpOptions); | |
226 | }); | |
227 | ||
228 | // is jQuery UI Datepicker is on the page? | |
229 | if ($.datepicker) { | |
230 | ||
231 | // Register the language data. | |
232 | // FullCalendar and MomentJS use language codes like "pt-br" but Datepicker | |
233 | // does it like "pt-BR" or if it doesn't have the language, maybe just "pt". | |
234 | // Make an alias so the language can be referenced either way. | |
235 | $.datepicker.regional[dpLangCode] = | |
236 | $.datepicker.regional[langCode] = // alias | |
237 | dpOptions; | |
238 | ||
239 | // Alias 'en' to the default language data. Do this every time. | |
240 | $.datepicker.regional.en = $.datepicker.regional['']; | |
241 | ||
242 | // Set as Datepicker's global defaults. | |
243 | $.datepicker.setDefaults(dpOptions); | |
244 | } | |
245 | }; | |
246 | ||
247 | ||
248 | // Sets FullCalendar-specific translations. Will set the language as the global default. | |
249 | fc.lang = function(langCode, newFcOptions) { | |
250 | var fcOptions; | |
251 | var momOptions; | |
252 | ||
253 | // get the FullCalendar internal option hash for this language. create if necessary | |
254 | fcOptions = langOptionHash[langCode] || (langOptionHash[langCode] = {}); | |
255 | ||
256 | // provided new options for this language? merge them in | |
257 | if (newFcOptions) { | |
258 | mergeOptions(fcOptions, newFcOptions); | |
259 | } | |
260 | ||
261 | // compute language options that weren't defined. | |
262 | // always do this. newFcOptions can be undefined when initializing from i18n file, | |
263 | // so no way to tell if this is an initialization or a default-setting. | |
264 | momOptions = getMomentLocaleData(langCode); // will fall back to en | |
265 | $.each(momComputableOptions, function(name, func) { | |
266 | if (fcOptions[name] === undefined) { | |
267 | fcOptions[name] = func(momOptions, fcOptions); | |
268 | } | |
269 | }); | |
270 | ||
271 | // set it as the default language for FullCalendar | |
272 | defaults.lang = langCode; | |
273 | }; | |
274 | ||
275 | ||
276 | // NOTE: can't guarantee any of these computations will run because not every language has datepicker | |
277 | // configs, so make sure there are English fallbacks for these in the defaults file. | |
278 | var dpComputableOptions = { | |
279 | ||
280 | defaultButtonText: function(dpOptions) { | |
281 | return { | |
282 | // the translations sometimes wrongly contain HTML entities | |
283 | prev: stripHtmlEntities(dpOptions.prevText), | |
284 | next: stripHtmlEntities(dpOptions.nextText), | |
285 | today: stripHtmlEntities(dpOptions.currentText) | |
286 | }; | |
287 | }, | |
288 | ||
289 | // Produces format strings like "MMMM YYYY" -> "September 2014" | |
290 | monthYearFormat: function(dpOptions) { | |
291 | return dpOptions.showMonthAfterYear ? | |
292 | 'YYYY[' + dpOptions.yearSuffix + '] MMMM' : | |
293 | 'MMMM YYYY[' + dpOptions.yearSuffix + ']'; | |
294 | } | |
295 | ||
296 | }; | |
297 | ||
298 | var momComputableOptions = { | |
299 | ||
300 | // Produces format strings like "ddd MM/DD" -> "Fri 12/10" | |
301 | dayOfMonthFormat: function(momOptions, fcOptions) { | |
302 | var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY" | |
303 | ||
304 | // strip the year off the edge, as well as other misc non-whitespace chars | |
305 | format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, ''); | |
306 | ||
307 | if (fcOptions.isRTL) { | |
308 | format += ' ddd'; // for RTL, add day-of-week to end | |
309 | } | |
310 | else { | |
311 | format = 'ddd ' + format; // for LTR, add day-of-week to beginning | |
312 | } | |
313 | return format; | |
314 | }, | |
315 | ||
316 | // Produces format strings like "H(:mm)a" -> "6pm" or "6:30pm" | |
317 | smallTimeFormat: function(momOptions) { | |
318 | return momOptions.longDateFormat('LT') | |
319 | .replace(':mm', '(:mm)') | |
320 | .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs | |
321 | .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand | |
322 | }, | |
323 | ||
324 | // Produces format strings like "H(:mm)t" -> "6p" or "6:30p" | |
325 | extraSmallTimeFormat: function(momOptions) { | |
326 | return momOptions.longDateFormat('LT') | |
327 | .replace(':mm', '(:mm)') | |
328 | .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs | |
329 | .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand | |
330 | }, | |
331 | ||
332 | // Produces format strings like "H:mm" -> "6:30" (with no AM/PM) | |
333 | noMeridiemTimeFormat: function(momOptions) { | |
334 | return momOptions.longDateFormat('LT') | |
335 | .replace(/\s*a$/i, ''); // remove trailing AM/PM | |
336 | } | |
337 | ||
338 | }; | |
339 | ||
340 | ||
341 | // Returns moment's internal locale data. If doesn't exist, returns English. | |
342 | // Works with moment-pre-2.8 | |
343 | function getMomentLocaleData(langCode) { | |
344 | var func = moment.localeData || moment.langData; | |
345 | return func.call(moment, langCode) || | |
346 | func.call(moment, 'en'); // the newer localData could return null, so fall back to en | |
347 | } | |
348 | ||
349 | ||
350 | // Initialize English by forcing computation of moment-derived options. | |
351 | // Also, sets it as the default. | |
352 | fc.lang('en', englishDefaults); | |
353 | ||
354 | // exports | |
355 | fc.intersectionToSeg = intersectionToSeg; | |
356 | fc.applyAll = applyAll; | |
357 | fc.debounce = debounce; | |
358 | ||
359 | ||
360 | /* FullCalendar-specific DOM Utilities | |
361 | ----------------------------------------------------------------------------------------------------------------------*/ | |
362 | ||
363 | ||
364 | // Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left | |
365 | // and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that. | |
366 | function compensateScroll(rowEls, scrollbarWidths) { | |
367 | if (scrollbarWidths.left) { | |
368 | rowEls.css({ | |
369 | 'border-left-width': 1, | |
370 | 'margin-left': scrollbarWidths.left - 1 | |
371 | }); | |
372 | } | |
373 | if (scrollbarWidths.right) { | |
374 | rowEls.css({ | |
375 | 'border-right-width': 1, | |
376 | 'margin-right': scrollbarWidths.right - 1 | |
377 | }); | |
378 | } | |
379 | } | |
380 | ||
381 | ||
382 | // Undoes compensateScroll and restores all borders/margins | |
383 | function uncompensateScroll(rowEls) { | |
384 | rowEls.css({ | |
385 | 'margin-left': '', | |
386 | 'margin-right': '', | |
387 | 'border-left-width': '', | |
388 | 'border-right-width': '' | |
389 | }); | |
390 | } | |
391 | ||
392 | ||
393 | // Make the mouse cursor express that an event is not allowed in the current area | |
394 | function disableCursor() { | |
395 | $('body').addClass('fc-not-allowed'); | |
396 | } | |
397 | ||
398 | ||
399 | // Returns the mouse cursor to its original look | |
400 | function enableCursor() { | |
401 | $('body').removeClass('fc-not-allowed'); | |
402 | } | |
403 | ||
404 | ||
405 | // Given a total available height to fill, have `els` (essentially child rows) expand to accomodate. | |
406 | // By default, all elements that are shorter than the recommended height are expanded uniformly, not considering | |
407 | // any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and | |
408 | // reduces the available height. | |
409 | function distributeHeight(els, availableHeight, shouldRedistribute) { | |
410 | ||
411 | // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions, | |
412 | // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars. | |
413 | ||
414 | var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element | |
415 | var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE* | |
416 | var flexEls = []; // elements that are allowed to expand. array of DOM nodes | |
417 | var flexOffsets = []; // amount of vertical space it takes up | |
418 | var flexHeights = []; // actual css height | |
419 | var usedHeight = 0; | |
420 | ||
421 | undistributeHeight(els); // give all elements their natural height | |
422 | ||
423 | // find elements that are below the recommended height (expandable). | |
424 | // important to query for heights in a single first pass (to avoid reflow oscillation). | |
425 | els.each(function(i, el) { | |
426 | var minOffset = i === els.length - 1 ? minOffset2 : minOffset1; | |
427 | var naturalOffset = $(el).outerHeight(true); | |
428 | ||
429 | if (naturalOffset < minOffset) { | |
430 | flexEls.push(el); | |
431 | flexOffsets.push(naturalOffset); | |
432 | flexHeights.push($(el).height()); | |
433 | } | |
434 | else { | |
435 | // this element stretches past recommended height (non-expandable). mark the space as occupied. | |
436 | usedHeight += naturalOffset; | |
437 | } | |
438 | }); | |
439 | ||
440 | // readjust the recommended height to only consider the height available to non-maxed-out rows. | |
441 | if (shouldRedistribute) { | |
442 | availableHeight -= usedHeight; | |
443 | minOffset1 = Math.floor(availableHeight / flexEls.length); | |
444 | minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE* | |
445 | } | |
446 | ||
447 | // assign heights to all expandable elements | |
448 | $(flexEls).each(function(i, el) { | |
449 | var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1; | |
450 | var naturalOffset = flexOffsets[i]; | |
451 | var naturalHeight = flexHeights[i]; | |
452 | var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding | |
453 | ||
454 | if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things | |
455 | $(el).height(newHeight); | |
456 | } | |
457 | }); | |
458 | } | |
459 | ||
460 | ||
461 | // Undoes distrubuteHeight, restoring all els to their natural height | |
462 | function undistributeHeight(els) { | |
463 | els.height(''); | |
464 | } | |
465 | ||
466 | ||
467 | // Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the | |
468 | // cells to be that width. | |
469 | // PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline | |
470 | function matchCellWidths(els) { | |
471 | var maxInnerWidth = 0; | |
472 | ||
473 | els.find('> *').each(function(i, innerEl) { | |
474 | var innerWidth = $(innerEl).outerWidth(); | |
475 | if (innerWidth > maxInnerWidth) { | |
476 | maxInnerWidth = innerWidth; | |
477 | } | |
478 | }); | |
479 | ||
480 | maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance | |
481 | ||
482 | els.width(maxInnerWidth); | |
483 | ||
484 | return maxInnerWidth; | |
485 | } | |
486 | ||
487 | ||
488 | // Turns a container element into a scroller if its contents is taller than the allotted height. | |
489 | // Returns true if the element is now a scroller, false otherwise. | |
490 | // NOTE: this method is best because it takes weird zooming dimensions into account | |
491 | function setPotentialScroller(containerEl, height) { | |
492 | containerEl.height(height).addClass('fc-scroller'); | |
493 | ||
494 | // are scrollbars needed? | |
495 | if (containerEl[0].scrollHeight - 1 > containerEl[0].clientHeight) { // !!! -1 because IE is often off-by-one :( | |
496 | return true; | |
497 | } | |
498 | ||
499 | unsetScroller(containerEl); // undo | |
500 | return false; | |
501 | } | |
502 | ||
503 | ||
504 | // Takes an element that might have been a scroller, and turns it back into a normal element. | |
505 | function unsetScroller(containerEl) { | |
506 | containerEl.height('').removeClass('fc-scroller'); | |
507 | } | |
508 | ||
509 | ||
510 | /* General DOM Utilities | |
511 | ----------------------------------------------------------------------------------------------------------------------*/ | |
512 | ||
513 | ||
514 | // borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51 | |
515 | function getScrollParent(el) { | |
516 | var position = el.css('position'), | |
517 | scrollParent = el.parents().filter(function() { | |
518 | var parent = $(this); | |
519 | return (/(auto|scroll)/).test( | |
520 | parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x') | |
521 | ); | |
522 | }).eq(0); | |
523 | ||
524 | return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent; | |
525 | } | |
526 | ||
527 | ||
528 | // Given a container element, return an object with the pixel values of the left/right scrollbars. | |
529 | // Left scrollbars might occur on RTL browsers (IE maybe?) but I have not tested. | |
530 | // PREREQUISITE: container element must have a single child with display:block | |
531 | function getScrollbarWidths(container) { | |
532 | var containerLeft = container.offset().left; | |
533 | var containerRight = containerLeft + container.width(); | |
534 | var inner = container.children(); | |
535 | var innerLeft = inner.offset().left; | |
536 | var innerRight = innerLeft + inner.outerWidth(); | |
537 | ||
538 | return { | |
539 | left: innerLeft - containerLeft, | |
540 | right: containerRight - innerRight | |
541 | }; | |
542 | } | |
543 | ||
544 | ||
545 | // Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac) | |
546 | function isPrimaryMouseButton(ev) { | |
547 | return ev.which == 1 && !ev.ctrlKey; | |
548 | } | |
549 | ||
550 | ||
551 | /* FullCalendar-specific Misc Utilities | |
552 | ----------------------------------------------------------------------------------------------------------------------*/ | |
553 | ||
554 | ||
555 | // Creates a basic segment with the intersection of the two ranges. Returns undefined if no intersection. | |
556 | // Expects all dates to be normalized to the same timezone beforehand. | |
557 | // TODO: move to date section? | |
558 | function intersectionToSeg(subjectRange, constraintRange) { | |
559 | var subjectStart = subjectRange.start; | |
560 | var subjectEnd = subjectRange.end; | |
561 | var constraintStart = constraintRange.start; | |
562 | var constraintEnd = constraintRange.end; | |
563 | var segStart, segEnd; | |
564 | var isStart, isEnd; | |
565 | ||
566 | if (subjectEnd > constraintStart && subjectStart < constraintEnd) { // in bounds at all? | |
567 | ||
568 | if (subjectStart >= constraintStart) { | |
569 | segStart = subjectStart.clone(); | |
570 | isStart = true; | |
571 | } | |
572 | else { | |
573 | segStart = constraintStart.clone(); | |
574 | isStart = false; | |
575 | } | |
576 | ||
577 | if (subjectEnd <= constraintEnd) { | |
578 | segEnd = subjectEnd.clone(); | |
579 | isEnd = true; | |
580 | } | |
581 | else { | |
582 | segEnd = constraintEnd.clone(); | |
583 | isEnd = false; | |
584 | } | |
585 | ||
586 | return { | |
587 | start: segStart, | |
588 | end: segEnd, | |
589 | isStart: isStart, | |
590 | isEnd: isEnd | |
591 | }; | |
592 | } | |
593 | } | |
594 | ||
595 | ||
596 | function smartProperty(obj, name) { // get a camel-cased/namespaced property of an object | |
597 | obj = obj || {}; | |
598 | if (obj[name] !== undefined) { | |
599 | return obj[name]; | |
600 | } | |
601 | var parts = name.split(/(?=[A-Z])/), | |
602 | i = parts.length - 1, res; | |
603 | for (; i>=0; i--) { | |
604 | res = obj[parts[i].toLowerCase()]; | |
605 | if (res !== undefined) { | |
606 | return res; | |
607 | } | |
608 | } | |
609 | return obj['default']; | |
610 | } | |
611 | ||
612 | ||
613 | /* Date Utilities | |
614 | ----------------------------------------------------------------------------------------------------------------------*/ | |
615 | ||
616 | var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ]; | |
617 | var intervalUnits = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ]; | |
618 | ||
619 | ||
620 | // Diffs the two moments into a Duration where full-days are recorded first, then the remaining time. | |
621 | // Moments will have their timezones normalized. | |
622 | function diffDayTime(a, b) { | |
623 | return moment.duration({ | |
624 | days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'), | |
625 | ms: a.time() - b.time() // time-of-day from day start. disregards timezone | |
626 | }); | |
627 | } | |
628 | ||
629 | ||
630 | // Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations. | |
631 | function diffDay(a, b) { | |
632 | return moment.duration({ | |
633 | days: a.clone().stripTime().diff(b.clone().stripTime(), 'days') | |
634 | }); | |
635 | } | |
636 | ||
637 | ||
638 | // Computes the larges whole-unit period of time, as a duration object. | |
639 | // For example, 48 hours will be {days:2} whereas 49 hours will be {hours:49}. | |
640 | // Accepts start/end, a range object, or an original duration object. | |
641 | /* (never used) | |
642 | function computeIntervalDuration(start, end) { | |
643 | var durationInput = {}; | |
644 | var i, unit; | |
645 | var val; | |
646 | ||
647 | for (i = 0; i < intervalUnits.length; i++) { | |
648 | unit = intervalUnits[i]; | |
649 | val = computeIntervalAs(unit, start, end); | |
650 | if (val) { | |
651 | break; | |
652 | } | |
653 | } | |
654 | ||
655 | durationInput[unit] = val; | |
656 | return moment.duration(durationInput); | |
657 | } | |
658 | */ | |
659 | ||
660 | ||
661 | // Computes the unit name of the largest whole-unit period of time. | |
662 | // For example, 48 hours will be "days" wherewas 49 hours will be "hours". | |
663 | // Accepts start/end, a range object, or an original duration object. | |
664 | function computeIntervalUnit(start, end) { | |
665 | var i, unit; | |
666 | ||
667 | for (i = 0; i < intervalUnits.length; i++) { | |
668 | unit = intervalUnits[i]; | |
669 | if (computeIntervalAs(unit, start, end)) { | |
670 | break; | |
671 | } | |
672 | } | |
673 | ||
674 | return unit; // will be "milliseconds" if nothing else matches | |
675 | } | |
676 | ||
677 | ||
678 | // Computes the number of units the interval is cleanly comprised of. | |
679 | // If the given unit does not cleanly divide the interval a whole number of times, `false` is returned. | |
680 | // Accepts start/end, a range object, or an original duration object. | |
681 | function computeIntervalAs(unit, start, end) { | |
682 | var val; | |
683 | ||
684 | if (end != null) { // given start, end | |
685 | val = end.diff(start, unit, true); | |
686 | } | |
687 | else if (moment.isDuration(start)) { // given duration | |
688 | val = start.as(unit); | |
689 | } | |
690 | else { // given { start, end } range object | |
691 | val = start.end.diff(start.start, unit, true); | |
692 | } | |
693 | ||
694 | if (val >= 1 && isInt(val)) { | |
695 | return val; | |
696 | } | |
697 | ||
698 | return false; | |
699 | } | |
700 | ||
701 | ||
702 | function isNativeDate(input) { | |
703 | return Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date; | |
704 | } | |
705 | ||
706 | ||
707 | // Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00" | |
708 | function isTimeString(str) { | |
709 | return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str); | |
710 | } | |
711 | ||
712 | ||
713 | /* General Utilities | |
714 | ----------------------------------------------------------------------------------------------------------------------*/ | |
715 | ||
716 | var hasOwnPropMethod = {}.hasOwnProperty; | |
717 | ||
718 | ||
719 | // Create an object that has the given prototype. Just like Object.create | |
720 | function createObject(proto) { | |
721 | var f = function() {}; | |
722 | f.prototype = proto; | |
723 | return new f(); | |
724 | } | |
725 | ||
726 | ||
727 | function copyOwnProps(src, dest) { | |
728 | for (var name in src) { | |
729 | if (hasOwnProp(src, name)) { | |
730 | dest[name] = src[name]; | |
731 | } | |
732 | } | |
733 | } | |
734 | ||
735 | ||
736 | function hasOwnProp(obj, name) { | |
737 | return hasOwnPropMethod.call(obj, name); | |
738 | } | |
739 | ||
740 | ||
741 | // Is the given value a non-object non-function value? | |
742 | function isAtomic(val) { | |
743 | return /undefined|null|boolean|number|string/.test($.type(val)); | |
744 | } | |
745 | ||
746 | ||
747 | function applyAll(functions, thisObj, args) { | |
748 | if ($.isFunction(functions)) { | |
749 | functions = [ functions ]; | |
750 | } | |
751 | if (functions) { | |
752 | var i; | |
753 | var ret; | |
754 | for (i=0; i<functions.length; i++) { | |
755 | ret = functions[i].apply(thisObj, args) || ret; | |
756 | } | |
757 | return ret; | |
758 | } | |
759 | } | |
760 | ||
761 | ||
762 | function firstDefined() { | |
763 | for (var i=0; i<arguments.length; i++) { | |
764 | if (arguments[i] !== undefined) { | |
765 | return arguments[i]; | |
766 | } | |
767 | } | |
768 | } | |
769 | ||
770 | ||
771 | function htmlEscape(s) { | |
772 | return (s + '').replace(/&/g, '&') | |
773 | .replace(/</g, '<') | |
774 | .replace(/>/g, '>') | |
775 | .replace(/'/g, ''') | |
776 | .replace(/"/g, '"') | |
777 | .replace(/\n/g, '<br />'); | |
778 | } | |
779 | ||
780 | ||
781 | function stripHtmlEntities(text) { | |
782 | return text.replace(/&.*?;/g, ''); | |
783 | } | |
784 | ||
785 | ||
786 | function capitaliseFirstLetter(str) { | |
787 | return str.charAt(0).toUpperCase() + str.slice(1); | |
788 | } | |
789 | ||
790 | ||
791 | function compareNumbers(a, b) { // for .sort() | |
792 | return a - b; | |
793 | } | |
794 | ||
795 | ||
796 | function isInt(n) { | |
797 | return n % 1 === 0; | |
798 | } | |
799 | ||
800 | ||
801 | // Returns a function, that, as long as it continues to be invoked, will not | |
802 | // be triggered. The function will be called after it stops being called for | |
803 | // N milliseconds. | |
804 | // https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714 | |
805 | function debounce(func, wait) { | |
806 | var timeoutId; | |
807 | var args; | |
808 | var context; | |
809 | var timestamp; // of most recent call | |
810 | var later = function() { | |
811 | var last = +new Date() - timestamp; | |
812 | if (last < wait && last > 0) { | |
813 | timeoutId = setTimeout(later, wait - last); | |
814 | } | |
815 | else { | |
816 | timeoutId = null; | |
817 | func.apply(context, args); | |
818 | if (!timeoutId) { | |
819 | context = args = null; | |
820 | } | |
821 | } | |
822 | }; | |
823 | ||
824 | return function() { | |
825 | context = this; | |
826 | args = arguments; | |
827 | timestamp = +new Date(); | |
828 | if (!timeoutId) { | |
829 | timeoutId = setTimeout(later, wait); | |
830 | } | |
831 | }; | |
832 | } | |
833 | ||
834 | var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/; | |
835 | var ambigTimeOrZoneRegex = | |
836 | /^\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+)?)?)?)?)?$/; | |
837 | var newMomentProto = moment.fn; // where we will attach our new methods | |
838 | var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods | |
839 | var allowValueOptimization; | |
840 | var setUTCValues; // function defined below | |
841 | var setLocalValues; // function defined below | |
842 | ||
843 | ||
844 | // Creating | |
845 | // ------------------------------------------------------------------------------------------------- | |
846 | ||
847 | // Creates a new moment, similar to the vanilla moment(...) constructor, but with | |
848 | // extra features (ambiguous time, enhanced formatting). When given an existing moment, | |
849 | // it will function as a clone (and retain the zone of the moment). Anything else will | |
850 | // result in a moment in the local zone. | |
851 | fc.moment = function() { | |
852 | return makeMoment(arguments); | |
853 | }; | |
854 | ||
855 | // Sames as fc.moment, but forces the resulting moment to be in the UTC timezone. | |
856 | fc.moment.utc = function() { | |
857 | var mom = makeMoment(arguments, true); | |
858 | ||
859 | // Force it into UTC because makeMoment doesn't guarantee it | |
860 | // (if given a pre-existing moment for example) | |
861 | if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone | |
862 | mom.utc(); | |
863 | } | |
864 | ||
865 | return mom; | |
866 | }; | |
867 | ||
868 | // Same as fc.moment, but when given an ISO8601 string, the timezone offset is preserved. | |
869 | // ISO8601 strings with no timezone offset will become ambiguously zoned. | |
870 | fc.moment.parseZone = function() { | |
871 | return makeMoment(arguments, true, true); | |
872 | }; | |
873 | ||
874 | // Builds an enhanced moment from args. When given an existing moment, it clones. When given a | |
875 | // native Date, or called with no arguments (the current time), the resulting moment will be local. | |
876 | // Anything else needs to be "parsed" (a string or an array), and will be affected by: | |
877 | // parseAsUTC - if there is no zone information, should we parse the input in UTC? | |
878 | // parseZone - if there is zone information, should we force the zone of the moment? | |
879 | function makeMoment(args, parseAsUTC, parseZone) { | |
880 | var input = args[0]; | |
881 | var isSingleString = args.length == 1 && typeof input === 'string'; | |
882 | var isAmbigTime; | |
883 | var isAmbigZone; | |
884 | var ambigMatch; | |
885 | var mom; | |
886 | ||
887 | if (moment.isMoment(input)) { | |
888 | mom = moment.apply(null, args); // clone it | |
889 | transferAmbigs(input, mom); // the ambig flags weren't transfered with the clone | |
890 | } | |
891 | else if (isNativeDate(input) || input === undefined) { | |
892 | mom = moment.apply(null, args); // will be local | |
893 | } | |
894 | else { // "parsing" is required | |
895 | isAmbigTime = false; | |
896 | isAmbigZone = false; | |
897 | ||
898 | if (isSingleString) { | |
899 | if (ambigDateOfMonthRegex.test(input)) { | |
900 | // accept strings like '2014-05', but convert to the first of the month | |
901 | input += '-01'; | |
902 | args = [ input ]; // for when we pass it on to moment's constructor | |
903 | isAmbigTime = true; | |
904 | isAmbigZone = true; | |
905 | } | |
906 | else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) { | |
907 | isAmbigTime = !ambigMatch[5]; // no time part? | |
908 | isAmbigZone = true; | |
909 | } | |
910 | } | |
911 | else if ($.isArray(input)) { | |
912 | // arrays have no timezone information, so assume ambiguous zone | |
913 | isAmbigZone = true; | |
914 | } | |
915 | // otherwise, probably a string with a format | |
916 | ||
917 | if (parseAsUTC || isAmbigTime) { | |
918 | mom = moment.utc.apply(moment, args); | |
919 | } | |
920 | else { | |
921 | mom = moment.apply(null, args); | |
922 | } | |
923 | ||
924 | if (isAmbigTime) { | |
925 | mom._ambigTime = true; | |
926 | mom._ambigZone = true; // ambiguous time always means ambiguous zone | |
927 | } | |
928 | else if (parseZone) { // let's record the inputted zone somehow | |
929 | if (isAmbigZone) { | |
930 | mom._ambigZone = true; | |
931 | } | |
932 | else if (isSingleString) { | |
933 | mom.zone(input); // if not a valid zone, will assign UTC | |
934 | } | |
935 | } | |
936 | } | |
937 | ||
938 | mom._fullCalendar = true; // flag for extended functionality | |
939 | ||
940 | return mom; | |
941 | } | |
942 | ||
943 | ||
944 | // A clone method that works with the flags related to our enhanced functionality. | |
945 | // In the future, use moment.momentProperties | |
946 | newMomentProto.clone = function() { | |
947 | var mom = oldMomentProto.clone.apply(this, arguments); | |
948 | ||
949 | // these flags weren't transfered with the clone | |
950 | transferAmbigs(this, mom); | |
951 | if (this._fullCalendar) { | |
952 | mom._fullCalendar = true; | |
953 | } | |
954 | ||
955 | return mom; | |
956 | }; | |
957 | ||
958 | ||
959 | // Time-of-day | |
960 | // ------------------------------------------------------------------------------------------------- | |
961 | ||
962 | // GETTER | |
963 | // Returns a Duration with the hours/minutes/seconds/ms values of the moment. | |
964 | // If the moment has an ambiguous time, a duration of 00:00 will be returned. | |
965 | // | |
966 | // SETTER | |
967 | // You can supply a Duration, a Moment, or a Duration-like argument. | |
968 | // When setting the time, and the moment has an ambiguous time, it then becomes unambiguous. | |
969 | newMomentProto.time = function(time) { | |
970 | ||
971 | // Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar. | |
972 | // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins. | |
973 | if (!this._fullCalendar) { | |
974 | return oldMomentProto.time.apply(this, arguments); | |
975 | } | |
976 | ||
977 | if (time == null) { // getter | |
978 | return moment.duration({ | |
979 | hours: this.hours(), | |
980 | minutes: this.minutes(), | |
981 | seconds: this.seconds(), | |
982 | milliseconds: this.milliseconds() | |
983 | }); | |
984 | } | |
985 | else { // setter | |
986 | ||
987 | this._ambigTime = false; // mark that the moment now has a time | |
988 | ||
989 | if (!moment.isDuration(time) && !moment.isMoment(time)) { | |
990 | time = moment.duration(time); | |
991 | } | |
992 | ||
993 | // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day). | |
994 | // Only for Duration times, not Moment times. | |
995 | var dayHours = 0; | |
996 | if (moment.isDuration(time)) { | |
997 | dayHours = Math.floor(time.asDays()) * 24; | |
998 | } | |
999 | ||
1000 | // We need to set the individual fields. | |
1001 | // Can't use startOf('day') then add duration. In case of DST at start of day. | |
1002 | return this.hours(dayHours + time.hours()) | |
1003 | .minutes(time.minutes()) | |
1004 | .seconds(time.seconds()) | |
1005 | .milliseconds(time.milliseconds()); | |
1006 | } | |
1007 | }; | |
1008 | ||
1009 | // Converts the moment to UTC, stripping out its time-of-day and timezone offset, | |
1010 | // but preserving its YMD. A moment with a stripped time will display no time | |
1011 | // nor timezone offset when .format() is called. | |
1012 | newMomentProto.stripTime = function() { | |
1013 | var a; | |
1014 | ||
1015 | if (!this._ambigTime) { | |
1016 | ||
1017 | // get the values before any conversion happens | |
1018 | a = this.toArray(); // array of y/m/d/h/m/s/ms | |
1019 | ||
1020 | this.utc(); // set the internal UTC flag (will clear the ambig flags) | |
1021 | setUTCValues(this, a.slice(0, 3)); // set the year/month/date. time will be zero | |
1022 | ||
1023 | // Mark the time as ambiguous. This needs to happen after the .utc() call, which calls .zone(), | |
1024 | // which clears all ambig flags. Same with setUTCValues with moment-timezone. | |
1025 | this._ambigTime = true; | |
1026 | this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset | |
1027 | } | |
1028 | ||
1029 | return this; // for chaining | |
1030 | }; | |
1031 | ||
1032 | // Returns if the moment has a non-ambiguous time (boolean) | |
1033 | newMomentProto.hasTime = function() { | |
1034 | return !this._ambigTime; | |
1035 | }; | |
1036 | ||
1037 | ||
1038 | // Timezone | |
1039 | // ------------------------------------------------------------------------------------------------- | |
1040 | ||
1041 | // Converts the moment to UTC, stripping out its timezone offset, but preserving its | |
1042 | // YMD and time-of-day. A moment with a stripped timezone offset will display no | |
1043 | // timezone offset when .format() is called. | |
1044 | newMomentProto.stripZone = function() { | |
1045 | var a, wasAmbigTime; | |
1046 | ||
1047 | if (!this._ambigZone) { | |
1048 | ||
1049 | // get the values before any conversion happens | |
1050 | a = this.toArray(); // array of y/m/d/h/m/s/ms | |
1051 | wasAmbigTime = this._ambigTime; | |
1052 | ||
1053 | this.utc(); // set the internal UTC flag (will clear the ambig flags) | |
1054 | setUTCValues(this, a); // will set the year/month/date/hours/minutes/seconds/ms | |
1055 | ||
1056 | if (wasAmbigTime) { | |
1057 | // the above call to .utc()/.zone() unfortunately clears the ambig flags, so reassign | |
1058 | this._ambigTime = true; | |
1059 | } | |
1060 | ||
1061 | // Mark the zone as ambiguous. This needs to happen after the .utc() call, which calls .zone(), | |
1062 | // which clears all ambig flags. Same with setUTCValues with moment-timezone. | |
1063 | this._ambigZone = true; | |
1064 | } | |
1065 | ||
1066 | return this; // for chaining | |
1067 | }; | |
1068 | ||
1069 | // Returns of the moment has a non-ambiguous timezone offset (boolean) | |
1070 | newMomentProto.hasZone = function() { | |
1071 | return !this._ambigZone; | |
1072 | }; | |
1073 | ||
1074 | // this method implicitly marks a zone (will get called upon .utc() and .local()) | |
1075 | newMomentProto.zone = function(tzo) { | |
1076 | ||
1077 | if (tzo != null) { // setter | |
1078 | // these assignments needs to happen before the original zone method is called. | |
1079 | // I forget why, something to do with a browser crash. | |
1080 | this._ambigTime = false; | |
1081 | this._ambigZone = false; | |
1082 | } | |
1083 | ||
1084 | return oldMomentProto.zone.apply(this, arguments); | |
1085 | }; | |
1086 | ||
1087 | // this method implicitly marks a zone | |
1088 | newMomentProto.local = function() { | |
1089 | var a = this.toArray(); // year,month,date,hours,minutes,seconds,ms as an array | |
1090 | var wasAmbigZone = this._ambigZone; | |
1091 | ||
1092 | oldMomentProto.local.apply(this, arguments); // will clear ambig flags | |
1093 | ||
1094 | if (wasAmbigZone) { | |
1095 | // If the moment was ambiguously zoned, the date fields were stored as UTC. | |
1096 | // We want to preserve these, but in local time. | |
1097 | setLocalValues(this, a); | |
1098 | } | |
1099 | ||
1100 | return this; // for chaining | |
1101 | }; | |
1102 | ||
1103 | ||
1104 | // Formatting | |
1105 | // ------------------------------------------------------------------------------------------------- | |
1106 | ||
1107 | newMomentProto.format = function() { | |
1108 | if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided? | |
1109 | return formatDate(this, arguments[0]); // our extended formatting | |
1110 | } | |
1111 | if (this._ambigTime) { | |
1112 | return oldMomentFormat(this, 'YYYY-MM-DD'); | |
1113 | } | |
1114 | if (this._ambigZone) { | |
1115 | return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); | |
1116 | } | |
1117 | return oldMomentProto.format.apply(this, arguments); | |
1118 | }; | |
1119 | ||
1120 | newMomentProto.toISOString = function() { | |
1121 | if (this._ambigTime) { | |
1122 | return oldMomentFormat(this, 'YYYY-MM-DD'); | |
1123 | } | |
1124 | if (this._ambigZone) { | |
1125 | return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); | |
1126 | } | |
1127 | return oldMomentProto.toISOString.apply(this, arguments); | |
1128 | }; | |
1129 | ||
1130 | ||
1131 | // Querying | |
1132 | // ------------------------------------------------------------------------------------------------- | |
1133 | ||
1134 | // Is the moment within the specified range? `end` is exclusive. | |
1135 | // FYI, this method is not a standard Moment method, so always do our enhanced logic. | |
1136 | newMomentProto.isWithin = function(start, end) { | |
1137 | var a = commonlyAmbiguate([ this, start, end ]); | |
1138 | return a[0] >= a[1] && a[0] < a[2]; | |
1139 | }; | |
1140 | ||
1141 | // When isSame is called with units, timezone ambiguity is normalized before the comparison happens. | |
1142 | // If no units specified, the two moments must be identically the same, with matching ambig flags. | |
1143 | newMomentProto.isSame = function(input, units) { | |
1144 | var a; | |
1145 | ||
1146 | // only do custom logic if this is an enhanced moment | |
1147 | if (!this._fullCalendar) { | |
1148 | return oldMomentProto.isSame.apply(this, arguments); | |
1149 | } | |
1150 | ||
1151 | if (units) { | |
1152 | a = commonlyAmbiguate([ this, input ], true); // normalize timezones but don't erase times | |
1153 | return oldMomentProto.isSame.call(a[0], a[1], units); | |
1154 | } | |
1155 | else { | |
1156 | input = fc.moment.parseZone(input); // normalize input | |
1157 | return oldMomentProto.isSame.call(this, input) && | |
1158 | Boolean(this._ambigTime) === Boolean(input._ambigTime) && | |
1159 | Boolean(this._ambigZone) === Boolean(input._ambigZone); | |
1160 | } | |
1161 | }; | |
1162 | ||
1163 | // Make these query methods work with ambiguous moments | |
1164 | $.each([ | |
1165 | 'isBefore', | |
1166 | 'isAfter' | |
1167 | ], function(i, methodName) { | |
1168 | newMomentProto[methodName] = function(input, units) { | |
1169 | var a; | |
1170 | ||
1171 | // only do custom logic if this is an enhanced moment | |
1172 | if (!this._fullCalendar) { | |
1173 | return oldMomentProto[methodName].apply(this, arguments); | |
1174 | } | |
1175 | ||
1176 | a = commonlyAmbiguate([ this, input ]); | |
1177 | return oldMomentProto[methodName].call(a[0], a[1], units); | |
1178 | }; | |
1179 | }); | |
1180 | ||
1181 | ||
1182 | // Misc Internals | |
1183 | // ------------------------------------------------------------------------------------------------- | |
1184 | ||
1185 | // given an array of moment-like inputs, return a parallel array w/ moments similarly ambiguated. | |
1186 | // for example, of one moment has ambig time, but not others, all moments will have their time stripped. | |
1187 | // set `preserveTime` to `true` to keep times, but only normalize zone ambiguity. | |
1188 | // returns the original moments if no modifications are necessary. | |
1189 | function commonlyAmbiguate(inputs, preserveTime) { | |
1190 | var anyAmbigTime = false; | |
1191 | var anyAmbigZone = false; | |
1192 | var len = inputs.length; | |
1193 | var moms = []; | |
1194 | var i, mom; | |
1195 | ||
1196 | // parse inputs into real moments and query their ambig flags | |
1197 | for (i = 0; i < len; i++) { | |
1198 | mom = inputs[i]; | |
1199 | if (!moment.isMoment(mom)) { | |
1200 | mom = fc.moment.parseZone(mom); | |
1201 | } | |
1202 | anyAmbigTime = anyAmbigTime || mom._ambigTime; | |
1203 | anyAmbigZone = anyAmbigZone || mom._ambigZone; | |
1204 | moms.push(mom); | |
1205 | } | |
1206 | ||
1207 | // strip each moment down to lowest common ambiguity | |
1208 | // use clones to avoid modifying the original moments | |
1209 | for (i = 0; i < len; i++) { | |
1210 | mom = moms[i]; | |
1211 | if (!preserveTime && anyAmbigTime && !mom._ambigTime) { | |
1212 | moms[i] = mom.clone().stripTime(); | |
1213 | } | |
1214 | else if (anyAmbigZone && !mom._ambigZone) { | |
1215 | moms[i] = mom.clone().stripZone(); | |
1216 | } | |
1217 | } | |
1218 | ||
1219 | return moms; | |
1220 | } | |
1221 | ||
1222 | // Transfers all the flags related to ambiguous time/zone from the `src` moment to the `dest` moment | |
1223 | function transferAmbigs(src, dest) { | |
1224 | if (src._ambigTime) { | |
1225 | dest._ambigTime = true; | |
1226 | } | |
1227 | else if (dest._ambigTime) { | |
1228 | dest._ambigTime = false; | |
1229 | } | |
1230 | ||
1231 | if (src._ambigZone) { | |
1232 | dest._ambigZone = true; | |
1233 | } | |
1234 | else if (dest._ambigZone) { | |
1235 | dest._ambigZone = false; | |
1236 | } | |
1237 | } | |
1238 | ||
1239 | ||
1240 | // Sets the year/month/date/etc values of the moment from the given array. | |
1241 | // Inefficient because it calls each individual setter. | |
1242 | function setMomentValues(mom, a) { | |
1243 | mom.year(a[0] || 0) | |
1244 | .month(a[1] || 0) | |
1245 | .date(a[2] || 0) | |
1246 | .hours(a[3] || 0) | |
1247 | .minutes(a[4] || 0) | |
1248 | .seconds(a[5] || 0) | |
1249 | .milliseconds(a[6] || 0); | |
1250 | } | |
1251 | ||
1252 | // Can we set the moment's internal date directly? | |
1253 | allowValueOptimization = '_d' in moment() && 'updateOffset' in moment; | |
1254 | ||
1255 | // Utility function. Accepts a moment and an array of the UTC year/month/date/etc values to set. | |
1256 | // Assumes the given moment is already in UTC mode. | |
1257 | setUTCValues = allowValueOptimization ? function(mom, a) { | |
1258 | // simlate what moment's accessors do | |
1259 | mom._d.setTime(Date.UTC.apply(Date, a)); | |
1260 | moment.updateOffset(mom, false); // keepTime=false | |
1261 | } : setMomentValues; | |
1262 | ||
1263 | // Utility function. Accepts a moment and an array of the local year/month/date/etc values to set. | |
1264 | // Assumes the given moment is already in local mode. | |
1265 | setLocalValues = allowValueOptimization ? function(mom, a) { | |
1266 | // simlate what moment's accessors do | |
1267 | mom._d.setTime(+new Date( // FYI, there is now way to apply an array of args to a constructor | |
1268 | a[0] || 0, | |
1269 | a[1] || 0, | |
1270 | a[2] || 0, | |
1271 | a[3] || 0, | |
1272 | a[4] || 0, | |
1273 | a[5] || 0, | |
1274 | a[6] || 0 | |
1275 | )); | |
1276 | moment.updateOffset(mom, false); // keepTime=false | |
1277 | } : setMomentValues; | |
1278 | ||
1279 | // Single Date Formatting | |
1280 | // ------------------------------------------------------------------------------------------------- | |
1281 | ||
1282 | ||
1283 | // call this if you want Moment's original format method to be used | |
1284 | function oldMomentFormat(mom, formatStr) { | |
1285 | return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js | |
1286 | } | |
1287 | ||
1288 | ||
1289 | // Formats `date` with a Moment formatting string, but allow our non-zero areas and | |
1290 | // additional token. | |
1291 | function formatDate(date, formatStr) { | |
1292 | return formatDateWithChunks(date, getFormatStringChunks(formatStr)); | |
1293 | } | |
1294 | ||
1295 | ||
1296 | function formatDateWithChunks(date, chunks) { | |
1297 | var s = ''; | |
1298 | var i; | |
1299 | ||
1300 | for (i=0; i<chunks.length; i++) { | |
1301 | s += formatDateWithChunk(date, chunks[i]); | |
1302 | } | |
1303 | ||
1304 | return s; | |
1305 | } | |
1306 | ||
1307 | ||
1308 | // addition formatting tokens we want recognized | |
1309 | var tokenOverrides = { | |
1310 | t: function(date) { // "a" or "p" | |
1311 | return oldMomentFormat(date, 'a').charAt(0); | |
1312 | }, | |
1313 | T: function(date) { // "A" or "P" | |
1314 | return oldMomentFormat(date, 'A').charAt(0); | |
1315 | } | |
1316 | }; | |
1317 | ||
1318 | ||
1319 | function formatDateWithChunk(date, chunk) { | |
1320 | var token; | |
1321 | var maybeStr; | |
1322 | ||
1323 | if (typeof chunk === 'string') { // a literal string | |
1324 | return chunk; | |
1325 | } | |
1326 | else if ((token = chunk.token)) { // a token, like "YYYY" | |
1327 | if (tokenOverrides[token]) { | |
1328 | return tokenOverrides[token](date); // use our custom token | |
1329 | } | |
1330 | return oldMomentFormat(date, token); | |
1331 | } | |
1332 | else if (chunk.maybe) { // a grouping of other chunks that must be non-zero | |
1333 | maybeStr = formatDateWithChunks(date, chunk.maybe); | |
1334 | if (maybeStr.match(/[1-9]/)) { | |
1335 | return maybeStr; | |
1336 | } | |
1337 | } | |
1338 | ||
1339 | return ''; | |
1340 | } | |
1341 | ||
1342 | ||
1343 | // Date Range Formatting | |
1344 | // ------------------------------------------------------------------------------------------------- | |
1345 | // TODO: make it work with timezone offset | |
1346 | ||
1347 | // Using a formatting string meant for a single date, generate a range string, like | |
1348 | // "Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ. | |
1349 | // If the dates are the same as far as the format string is concerned, just return a single | |
1350 | // rendering of one date, without any separator. | |
1351 | function formatRange(date1, date2, formatStr, separator, isRTL) { | |
1352 | var localeData; | |
1353 | ||
1354 | date1 = fc.moment.parseZone(date1); | |
1355 | date2 = fc.moment.parseZone(date2); | |
1356 | ||
1357 | localeData = (date1.localeData || date1.lang).call(date1); // works with moment-pre-2.8 | |
1358 | ||
1359 | // Expand localized format strings, like "LL" -> "MMMM D YYYY" | |
1360 | formatStr = localeData.longDateFormat(formatStr) || formatStr; | |
1361 | // BTW, this is not important for `formatDate` because it is impossible to put custom tokens | |
1362 | // or non-zero areas in Moment's localized format strings. | |
1363 | ||
1364 | separator = separator || ' - '; | |
1365 | ||
1366 | return formatRangeWithChunks( | |
1367 | date1, | |
1368 | date2, | |
1369 | getFormatStringChunks(formatStr), | |
1370 | separator, | |
1371 | isRTL | |
1372 | ); | |
1373 | } | |
1374 | fc.formatRange = formatRange; // expose | |
1375 | ||
1376 | ||
1377 | function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { | |
1378 | var chunkStr; // the rendering of the chunk | |
1379 | var leftI; | |
1380 | var leftStr = ''; | |
1381 | var rightI; | |
1382 | var rightStr = ''; | |
1383 | var middleI; | |
1384 | var middleStr1 = ''; | |
1385 | var middleStr2 = ''; | |
1386 | var middleStr = ''; | |
1387 | ||
1388 | // Start at the leftmost side of the formatting string and continue until you hit a token | |
1389 | // that is not the same between dates. | |
1390 | for (leftI=0; leftI<chunks.length; leftI++) { | |
1391 | chunkStr = formatSimilarChunk(date1, date2, chunks[leftI]); | |
1392 | if (chunkStr === false) { | |
1393 | break; | |
1394 | } | |
1395 | leftStr += chunkStr; | |
1396 | } | |
1397 | ||
1398 | // Similarly, start at the rightmost side of the formatting string and move left | |
1399 | for (rightI=chunks.length-1; rightI>leftI; rightI--) { | |
1400 | chunkStr = formatSimilarChunk(date1, date2, chunks[rightI]); | |
1401 | if (chunkStr === false) { | |
1402 | break; | |
1403 | } | |
1404 | rightStr = chunkStr + rightStr; | |
1405 | } | |
1406 | ||
1407 | // The area in the middle is different for both of the dates. | |
1408 | // Collect them distinctly so we can jam them together later. | |
1409 | for (middleI=leftI; middleI<=rightI; middleI++) { | |
1410 | middleStr1 += formatDateWithChunk(date1, chunks[middleI]); | |
1411 | middleStr2 += formatDateWithChunk(date2, chunks[middleI]); | |
1412 | } | |
1413 | ||
1414 | if (middleStr1 || middleStr2) { | |
1415 | if (isRTL) { | |
1416 | middleStr = middleStr2 + separator + middleStr1; | |
1417 | } | |
1418 | else { | |
1419 | middleStr = middleStr1 + separator + middleStr2; | |
1420 | } | |
1421 | } | |
1422 | ||
1423 | return leftStr + middleStr + rightStr; | |
1424 | } | |
1425 | ||
1426 | ||
1427 | var similarUnitMap = { | |
1428 | Y: 'year', | |
1429 | M: 'month', | |
1430 | D: 'day', // day of month | |
1431 | d: 'day', // day of week | |
1432 | // prevents a separator between anything time-related... | |
1433 | A: 'second', // AM/PM | |
1434 | a: 'second', // am/pm | |
1435 | T: 'second', // A/P | |
1436 | t: 'second', // a/p | |
1437 | H: 'second', // hour (24) | |
1438 | h: 'second', // hour (12) | |
1439 | m: 'second', // minute | |
1440 | s: 'second' // second | |
1441 | }; | |
1442 | // TODO: week maybe? | |
1443 | ||
1444 | ||
1445 | // Given a formatting chunk, and given that both dates are similar in the regard the | |
1446 | // formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`. | |
1447 | function formatSimilarChunk(date1, date2, chunk) { | |
1448 | var token; | |
1449 | var unit; | |
1450 | ||
1451 | if (typeof chunk === 'string') { // a literal string | |
1452 | return chunk; | |
1453 | } | |
1454 | else if ((token = chunk.token)) { | |
1455 | unit = similarUnitMap[token.charAt(0)]; | |
1456 | // are the dates the same for this unit of measurement? | |
1457 | if (unit && date1.isSame(date2, unit)) { | |
1458 | return oldMomentFormat(date1, token); // would be the same if we used `date2` | |
1459 | // BTW, don't support custom tokens | |
1460 | } | |
1461 | } | |
1462 | ||
1463 | return false; // the chunk is NOT the same for the two dates | |
1464 | // BTW, don't support splitting on non-zero areas | |
1465 | } | |
1466 | ||
1467 | ||
1468 | // Chunking Utils | |
1469 | // ------------------------------------------------------------------------------------------------- | |
1470 | ||
1471 | ||
1472 | var formatStringChunkCache = {}; | |
1473 | ||
1474 | ||
1475 | function getFormatStringChunks(formatStr) { | |
1476 | if (formatStr in formatStringChunkCache) { | |
1477 | return formatStringChunkCache[formatStr]; | |
1478 | } | |
1479 | return (formatStringChunkCache[formatStr] = chunkFormatString(formatStr)); | |
1480 | } | |
1481 | ||
1482 | ||
1483 | // Break the formatting string into an array of chunks | |
1484 | function chunkFormatString(formatStr) { | |
1485 | var chunks = []; | |
1486 | var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LT|(\w)\4*o?)|([^\w\[\(]+)/g; // TODO: more descrimination | |
1487 | var match; | |
1488 | ||
1489 | while ((match = chunker.exec(formatStr))) { | |
1490 | if (match[1]) { // a literal string inside [ ... ] | |
1491 | chunks.push(match[1]); | |
1492 | } | |
1493 | else if (match[2]) { // non-zero formatting inside ( ... ) | |
1494 | chunks.push({ maybe: chunkFormatString(match[2]) }); | |
1495 | } | |
1496 | else if (match[3]) { // a formatting token | |
1497 | chunks.push({ token: match[3] }); | |
1498 | } | |
1499 | else if (match[5]) { // an unenclosed literal string | |
1500 | chunks.push(match[5]); | |
1501 | } | |
1502 | } | |
1503 | ||
1504 | return chunks; | |
1505 | } | |
1506 | ||
1507 | fc.Class = Class; // export | |
1508 | ||
1509 | // class that all other classes will inherit from | |
1510 | function Class() { } | |
1511 | ||
1512 | // called upon a class to create a subclass | |
1513 | Class.extend = function(members) { | |
1514 | var superClass = this; | |
1515 | var subClass; | |
1516 | ||
1517 | members = members || {}; | |
1518 | ||
1519 | // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist | |
1520 | if (hasOwnProp(members, 'constructor')) { | |
1521 | subClass = members.constructor; | |
1522 | } | |
1523 | if (typeof subClass !== 'function') { | |
1524 | subClass = members.constructor = function() { | |
1525 | superClass.apply(this, arguments); | |
1526 | }; | |
1527 | } | |
1528 | ||
1529 | // build the base prototype for the subclass, which is an new object chained to the superclass's prototype | |
1530 | subClass.prototype = createObject(superClass.prototype); | |
1531 | ||
1532 | // copy each member variable/method onto the the subclass's prototype | |
1533 | copyOwnProps(members, subClass.prototype); | |
1534 | ||
1535 | // copy over all class variables/methods to the subclass, such as `extend` and `mixin` | |
1536 | copyOwnProps(superClass, subClass); | |
1537 | ||
1538 | return subClass; | |
1539 | }; | |
1540 | ||
1541 | // adds new member variables/methods to the class's prototype. | |
1542 | // can be called with another class, or a plain object hash containing new members. | |
1543 | Class.mixin = function(members) { | |
1544 | copyOwnProps(members.prototype || members, this.prototype); | |
1545 | }; | |
1546 | /* A rectangular panel that is absolutely positioned over other content | |
1547 | ------------------------------------------------------------------------------------------------------------------------ | |
1548 | Options: | |
1549 | - className (string) | |
1550 | - content (HTML string or jQuery element set) | |
1551 | - parentEl | |
1552 | - top | |
1553 | - left | |
1554 | - right (the x coord of where the right edge should be. not a "CSS" right) | |
1555 | - autoHide (boolean) | |
1556 | - show (callback) | |
1557 | - hide (callback) | |
1558 | */ | |
1559 | ||
1560 | var Popover = Class.extend({ | |
1561 | ||
1562 | isHidden: true, | |
1563 | options: null, | |
1564 | el: null, // the container element for the popover. generated by this object | |
1565 | documentMousedownProxy: null, // document mousedown handler bound to `this` | |
1566 | margin: 10, // the space required between the popover and the edges of the scroll container | |
1567 | ||
1568 | ||
1569 | constructor: function(options) { | |
1570 | this.options = options || {}; | |
1571 | }, | |
1572 | ||
1573 | ||
1574 | // Shows the popover on the specified position. Renders it if not already | |
1575 | show: function() { | |
1576 | if (this.isHidden) { | |
1577 | if (!this.el) { | |
1578 | this.render(); | |
1579 | } | |
1580 | this.el.show(); | |
1581 | this.position(); | |
1582 | this.isHidden = false; | |
1583 | this.trigger('show'); | |
1584 | } | |
1585 | }, | |
1586 | ||
1587 | ||
1588 | // Hides the popover, through CSS, but does not remove it from the DOM | |
1589 | hide: function() { | |
1590 | if (!this.isHidden) { | |
1591 | this.el.hide(); | |
1592 | this.isHidden = true; | |
1593 | this.trigger('hide'); | |
1594 | } | |
1595 | }, | |
1596 | ||
1597 | ||
1598 | // Creates `this.el` and renders content inside of it | |
1599 | render: function() { | |
1600 | var _this = this; | |
1601 | var options = this.options; | |
1602 | ||
1603 | this.el = $('<div class="fc-popover"/>') | |
1604 | .addClass(options.className || '') | |
1605 | .css({ | |
1606 | // position initially to the top left to avoid creating scrollbars | |
1607 | top: 0, | |
1608 | left: 0 | |
1609 | }) | |
1610 | .append(options.content) | |
1611 | .appendTo(options.parentEl); | |
1612 | ||
1613 | // when a click happens on anything inside with a 'fc-close' className, hide the popover | |
1614 | this.el.on('click', '.fc-close', function() { | |
1615 | _this.hide(); | |
1616 | }); | |
1617 | ||
1618 | if (options.autoHide) { | |
1619 | $(document).on('mousedown', this.documentMousedownProxy = $.proxy(this, 'documentMousedown')); | |
1620 | } | |
1621 | }, | |
1622 | ||
1623 | ||
1624 | // Triggered when the user clicks *anywhere* in the document, for the autoHide feature | |
1625 | documentMousedown: function(ev) { | |
1626 | // only hide the popover if the click happened outside the popover | |
1627 | if (this.el && !$(ev.target).closest(this.el).length) { | |
1628 | this.hide(); | |
1629 | } | |
1630 | }, | |
1631 | ||
1632 | ||
1633 | // Hides and unregisters any handlers | |
1634 | destroy: function() { | |
1635 | this.hide(); | |
1636 | ||
1637 | if (this.el) { | |
1638 | this.el.remove(); | |
1639 | this.el = null; | |
1640 | } | |
1641 | ||
1642 | $(document).off('mousedown', this.documentMousedownProxy); | |
1643 | }, | |
1644 | ||
1645 | ||
1646 | // Positions the popover optimally, using the top/left/right options | |
1647 | position: function() { | |
1648 | var options = this.options; | |
1649 | var origin = this.el.offsetParent().offset(); | |
1650 | var width = this.el.outerWidth(); | |
1651 | var height = this.el.outerHeight(); | |
1652 | var windowEl = $(window); | |
1653 | var viewportEl = getScrollParent(this.el); | |
1654 | var viewportTop; | |
1655 | var viewportLeft; | |
1656 | var viewportOffset; | |
1657 | var top; // the "position" (not "offset") values for the popover | |
1658 | var left; // | |
1659 | ||
1660 | // compute top and left | |
1661 | top = options.top || 0; | |
1662 | if (options.left !== undefined) { | |
1663 | left = options.left; | |
1664 | } | |
1665 | else if (options.right !== undefined) { | |
1666 | left = options.right - width; // derive the left value from the right value | |
1667 | } | |
1668 | else { | |
1669 | left = 0; | |
1670 | } | |
1671 | ||
1672 | if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result | |
1673 | viewportEl = windowEl; | |
1674 | viewportTop = 0; // the window is always at the top left | |
1675 | viewportLeft = 0; // (and .offset() won't work if called here) | |
1676 | } | |
1677 | else { | |
1678 | viewportOffset = viewportEl.offset(); | |
1679 | viewportTop = viewportOffset.top; | |
1680 | viewportLeft = viewportOffset.left; | |
1681 | } | |
1682 | ||
1683 | // if the window is scrolled, it causes the visible area to be further down | |
1684 | viewportTop += windowEl.scrollTop(); | |
1685 | viewportLeft += windowEl.scrollLeft(); | |
1686 | ||
1687 | // constrain to the view port. if constrained by two edges, give precedence to top/left | |
1688 | if (options.viewportConstrain !== false) { | |
1689 | top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin); | |
1690 | top = Math.max(top, viewportTop + this.margin); | |
1691 | left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin); | |
1692 | left = Math.max(left, viewportLeft + this.margin); | |
1693 | } | |
1694 | ||
1695 | this.el.css({ | |
1696 | top: top - origin.top, | |
1697 | left: left - origin.left | |
1698 | }); | |
1699 | }, | |
1700 | ||
1701 | ||
1702 | // Triggers a callback. Calls a function in the option hash of the same name. | |
1703 | // Arguments beyond the first `name` are forwarded on. | |
1704 | // TODO: better code reuse for this. Repeat code | |
1705 | trigger: function(name) { | |
1706 | if (this.options[name]) { | |
1707 | this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); | |
1708 | } | |
1709 | } | |
1710 | ||
1711 | }); | |
1712 | ||
1713 | /* A "coordinate map" converts pixel coordinates into an associated cell, which has an associated date | |
1714 | ------------------------------------------------------------------------------------------------------------------------ | |
1715 | Common interface: | |
1716 | ||
1717 | CoordMap.prototype = { | |
1718 | build: function() {}, | |
1719 | getCell: function(x, y) {} | |
1720 | }; | |
1721 | ||
1722 | */ | |
1723 | ||
1724 | /* Coordinate map for a grid component | |
1725 | ----------------------------------------------------------------------------------------------------------------------*/ | |
1726 | ||
1727 | var GridCoordMap = Class.extend({ | |
1728 | ||
1729 | grid: null, // reference to the Grid | |
1730 | rowCoords: null, // array of {top,bottom} objects | |
1731 | colCoords: null, // array of {left,right} objects | |
1732 | ||
1733 | containerEl: null, // container element that all coordinates are constrained to. optionally assigned | |
1734 | minX: null, | |
1735 | maxX: null, // exclusive | |
1736 | minY: null, | |
1737 | maxY: null, // exclusive | |
1738 | ||
1739 | ||
1740 | constructor: function(grid) { | |
1741 | this.grid = grid; | |
1742 | }, | |
1743 | ||
1744 | ||
1745 | // Queries the grid for the coordinates of all the cells | |
1746 | build: function() { | |
1747 | this.rowCoords = this.grid.computeRowCoords(); | |
1748 | this.colCoords = this.grid.computeColCoords(); | |
1749 | this.computeBounds(); | |
1750 | }, | |
1751 | ||
1752 | ||
1753 | // Clears the coordinates data to free up memory | |
1754 | clear: function() { | |
1755 | this.rowCoords = null; | |
1756 | this.colCoords = null; | |
1757 | }, | |
1758 | ||
1759 | ||
1760 | // Given a coordinate of the document, gets the associated cell. If no cell is underneath, returns null | |
1761 | getCell: function(x, y) { | |
1762 | var rowCoords = this.rowCoords; | |
1763 | var colCoords = this.colCoords; | |
1764 | var hitRow = null; | |
1765 | var hitCol = null; | |
1766 | var i, coords; | |
1767 | var cell; | |
1768 | ||
1769 | if (this.inBounds(x, y)) { | |
1770 | ||
1771 | for (i = 0; i < rowCoords.length; i++) { | |
1772 | coords = rowCoords[i]; | |
1773 | if (y >= coords.top && y < coords.bottom) { | |
1774 | hitRow = i; | |
1775 | break; | |
1776 | } | |
1777 | } | |
1778 | ||
1779 | for (i = 0; i < colCoords.length; i++) { | |
1780 | coords = colCoords[i]; | |
1781 | if (x >= coords.left && x < coords.right) { | |
1782 | hitCol = i; | |
1783 | break; | |
1784 | } | |
1785 | } | |
1786 | ||
1787 | if (hitRow !== null && hitCol !== null) { | |
1788 | cell = this.grid.getCell(hitRow, hitCol); | |
1789 | cell.grid = this.grid; // for DragListener's isCellsEqual. dragging between grids | |
1790 | return cell; | |
1791 | } | |
1792 | } | |
1793 | ||
1794 | return null; | |
1795 | }, | |
1796 | ||
1797 | ||
1798 | // If there is a containerEl, compute the bounds into min/max values | |
1799 | computeBounds: function() { | |
1800 | var containerOffset; | |
1801 | ||
1802 | if (this.containerEl) { | |
1803 | containerOffset = this.containerEl.offset(); | |
1804 | this.minX = containerOffset.left; | |
1805 | this.maxX = containerOffset.left + this.containerEl.outerWidth(); | |
1806 | this.minY = containerOffset.top; | |
1807 | this.maxY = containerOffset.top + this.containerEl.outerHeight(); | |
1808 | } | |
1809 | }, | |
1810 | ||
1811 | ||
1812 | // Determines if the given coordinates are in bounds. If no `containerEl`, always true | |
1813 | inBounds: function(x, y) { | |
1814 | if (this.containerEl) { | |
1815 | return x >= this.minX && x < this.maxX && y >= this.minY && y < this.maxY; | |
1816 | } | |
1817 | return true; | |
1818 | } | |
1819 | ||
1820 | }); | |
1821 | ||
1822 | ||
1823 | /* Coordinate map that is a combination of multiple other coordinate maps | |
1824 | ----------------------------------------------------------------------------------------------------------------------*/ | |
1825 | ||
1826 | var ComboCoordMap = Class.extend({ | |
1827 | ||
1828 | coordMaps: null, // an array of CoordMaps | |
1829 | ||
1830 | ||
1831 | constructor: function(coordMaps) { | |
1832 | this.coordMaps = coordMaps; | |
1833 | }, | |
1834 | ||
1835 | ||
1836 | // Builds all coordMaps | |
1837 | build: function() { | |
1838 | var coordMaps = this.coordMaps; | |
1839 | var i; | |
1840 | ||
1841 | for (i = 0; i < coordMaps.length; i++) { | |
1842 | coordMaps[i].build(); | |
1843 | } | |
1844 | }, | |
1845 | ||
1846 | ||
1847 | // Queries all coordMaps for the cell underneath the given coordinates, returning the first result | |
1848 | getCell: function(x, y) { | |
1849 | var coordMaps = this.coordMaps; | |
1850 | var cell = null; | |
1851 | var i; | |
1852 | ||
1853 | for (i = 0; i < coordMaps.length && !cell; i++) { | |
1854 | cell = coordMaps[i].getCell(x, y); | |
1855 | } | |
1856 | ||
1857 | return cell; | |
1858 | }, | |
1859 | ||
1860 | ||
1861 | // Clears all coordMaps | |
1862 | clear: function() { | |
1863 | var coordMaps = this.coordMaps; | |
1864 | var i; | |
1865 | ||
1866 | for (i = 0; i < coordMaps.length; i++) { | |
1867 | coordMaps[i].clear(); | |
1868 | } | |
1869 | } | |
1870 | ||
1871 | }); | |
1872 | ||
1873 | /* Tracks mouse movements over a CoordMap and raises events about which cell the mouse is over. | |
1874 | ----------------------------------------------------------------------------------------------------------------------*/ | |
1875 | // TODO: very useful to have a handler that gets called upon cellOut OR when dragging stops (for cleanup) | |
1876 | ||
1877 | var DragListener = Class.extend({ | |
1878 | ||
1879 | coordMap: null, | |
1880 | options: null, | |
1881 | ||
1882 | isListening: false, | |
1883 | isDragging: false, | |
1884 | ||
1885 | // the cell the mouse was over when listening started | |
1886 | origCell: null, | |
1887 | ||
1888 | // the cell the mouse is over | |
1889 | cell: null, | |
1890 | ||
1891 | // coordinates of the initial mousedown | |
1892 | mouseX0: null, | |
1893 | mouseY0: null, | |
1894 | ||
1895 | // handler attached to the document, bound to the DragListener's `this` | |
1896 | mousemoveProxy: null, | |
1897 | mouseupProxy: null, | |
1898 | ||
1899 | scrollEl: null, | |
1900 | scrollBounds: null, // { top, bottom, left, right } | |
1901 | scrollTopVel: null, // pixels per second | |
1902 | scrollLeftVel: null, // pixels per second | |
1903 | scrollIntervalId: null, // ID of setTimeout for scrolling animation loop | |
1904 | scrollHandlerProxy: null, // this-scoped function for handling when scrollEl is scrolled | |
1905 | ||
1906 | scrollSensitivity: 30, // pixels from edge for scrolling to start | |
1907 | scrollSpeed: 200, // pixels per second, at maximum speed | |
1908 | scrollIntervalMs: 50, // millisecond wait between scroll increment | |
1909 | ||
1910 | ||
1911 | constructor: function(coordMap, options) { | |
1912 | this.coordMap = coordMap; | |
1913 | this.options = options || {}; | |
1914 | }, | |
1915 | ||
1916 | ||
1917 | // Call this when the user does a mousedown. Will probably lead to startListening | |
1918 | mousedown: function(ev) { | |
1919 | if (isPrimaryMouseButton(ev)) { | |
1920 | ||
1921 | ev.preventDefault(); // prevents native selection in most browsers | |
1922 | ||
1923 | this.startListening(ev); | |
1924 | ||
1925 | // start the drag immediately if there is no minimum distance for a drag start | |
1926 | if (!this.options.distance) { | |
1927 | this.startDrag(ev); | |
1928 | } | |
1929 | } | |
1930 | }, | |
1931 | ||
1932 | ||
1933 | // Call this to start tracking mouse movements | |
1934 | startListening: function(ev) { | |
1935 | var scrollParent; | |
1936 | var cell; | |
1937 | ||
1938 | if (!this.isListening) { | |
1939 | ||
1940 | // grab scroll container and attach handler | |
1941 | if (ev && this.options.scroll) { | |
1942 | scrollParent = getScrollParent($(ev.target)); | |
1943 | if (!scrollParent.is(window) && !scrollParent.is(document)) { | |
1944 | this.scrollEl = scrollParent; | |
1945 | ||
1946 | // scope to `this`, and use `debounce` to make sure rapid calls don't happen | |
1947 | this.scrollHandlerProxy = debounce($.proxy(this, 'scrollHandler'), 100); | |
1948 | this.scrollEl.on('scroll', this.scrollHandlerProxy); | |
1949 | } | |
1950 | } | |
1951 | ||
1952 | this.computeCoords(); // relies on `scrollEl` | |
1953 | ||
1954 | // get info on the initial cell and its coordinates | |
1955 | if (ev) { | |
1956 | cell = this.getCell(ev); | |
1957 | this.origCell = cell; | |
1958 | ||
1959 | this.mouseX0 = ev.pageX; | |
1960 | this.mouseY0 = ev.pageY; | |
1961 | } | |
1962 | ||
1963 | $(document) | |
1964 | .on('mousemove', this.mousemoveProxy = $.proxy(this, 'mousemove')) | |
1965 | .on('mouseup', this.mouseupProxy = $.proxy(this, 'mouseup')) | |
1966 | .on('selectstart', this.preventDefault); // prevents native selection in IE<=8 | |
1967 | ||
1968 | this.isListening = true; | |
1969 | this.trigger('listenStart', ev); | |
1970 | } | |
1971 | }, | |
1972 | ||
1973 | ||
1974 | // Recomputes the drag-critical positions of elements | |
1975 | computeCoords: function() { | |
1976 | this.coordMap.build(); | |
1977 | this.computeScrollBounds(); | |
1978 | }, | |
1979 | ||
1980 | ||
1981 | // Called when the user moves the mouse | |
1982 | mousemove: function(ev) { | |
1983 | var minDistance; | |
1984 | var distanceSq; // current distance from mouseX0/mouseY0, squared | |
1985 | ||
1986 | if (!this.isDragging) { // if not already dragging... | |
1987 | // then start the drag if the minimum distance criteria is met | |
1988 | minDistance = this.options.distance || 1; | |
1989 | distanceSq = Math.pow(ev.pageX - this.mouseX0, 2) + Math.pow(ev.pageY - this.mouseY0, 2); | |
1990 | if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem | |
1991 | this.startDrag(ev); | |
1992 | } | |
1993 | } | |
1994 | ||
1995 | if (this.isDragging) { | |
1996 | this.drag(ev); // report a drag, even if this mousemove initiated the drag | |
1997 | } | |
1998 | }, | |
1999 | ||
2000 | ||
2001 | // Call this to initiate a legitimate drag. | |
2002 | // This function is called internally from this class, but can also be called explicitly from outside | |
2003 | startDrag: function(ev) { | |
2004 | var cell; | |
2005 | ||
2006 | if (!this.isListening) { // startDrag must have manually initiated | |
2007 | this.startListening(); | |
2008 | } | |
2009 | ||
2010 | if (!this.isDragging) { | |
2011 | this.isDragging = true; | |
2012 | this.trigger('dragStart', ev); | |
2013 | ||
2014 | // report the initial cell the mouse is over | |
2015 | // especially important if no min-distance and drag starts immediately | |
2016 | cell = this.getCell(ev); // this might be different from this.origCell if the min-distance is large | |
2017 | if (cell) { | |
2018 | this.cellOver(cell); | |
2019 | } | |
2020 | } | |
2021 | }, | |
2022 | ||
2023 | ||
2024 | // Called while the mouse is being moved and when we know a legitimate drag is taking place | |
2025 | drag: function(ev) { | |
2026 | var cell; | |
2027 | ||
2028 | if (this.isDragging) { | |
2029 | cell = this.getCell(ev); | |
2030 | ||
2031 | if (!isCellsEqual(cell, this.cell)) { // a different cell than before? | |
2032 | if (this.cell) { | |
2033 | this.cellOut(); | |
2034 | } | |
2035 | if (cell) { | |
2036 | this.cellOver(cell); | |
2037 | } | |
2038 | } | |
2039 | ||
2040 | this.dragScroll(ev); // will possibly cause scrolling | |
2041 | } | |
2042 | }, | |
2043 | ||
2044 | ||
2045 | // Called when a the mouse has just moved over a new cell | |
2046 | cellOver: function(cell) { | |
2047 | this.cell = cell; | |
2048 | this.trigger('cellOver', cell, isCellsEqual(cell, this.origCell)); | |
2049 | }, | |
2050 | ||
2051 | ||
2052 | // Called when the mouse has just moved out of a cell | |
2053 | cellOut: function() { | |
2054 | if (this.cell) { | |
2055 | this.trigger('cellOut', this.cell); | |
2056 | this.cell = null; | |
2057 | } | |
2058 | }, | |
2059 | ||
2060 | ||
2061 | // Called when the user does a mouseup | |
2062 | mouseup: function(ev) { | |
2063 | this.stopDrag(ev); | |
2064 | this.stopListening(ev); | |
2065 | }, | |
2066 | ||
2067 | ||
2068 | // Called when the drag is over. Will not cause listening to stop however. | |
2069 | // A concluding 'cellOut' event will NOT be triggered. | |
2070 | stopDrag: function(ev) { | |
2071 | if (this.isDragging) { | |
2072 | this.stopScrolling(); | |
2073 | this.trigger('dragStop', ev); | |
2074 | this.isDragging = false; | |
2075 | } | |
2076 | }, | |
2077 | ||
2078 | ||
2079 | // Call this to stop listening to the user's mouse events | |
2080 | stopListening: function(ev) { | |
2081 | if (this.isListening) { | |
2082 | ||
2083 | // remove the scroll handler if there is a scrollEl | |
2084 | if (this.scrollEl) { | |
2085 | this.scrollEl.off('scroll', this.scrollHandlerProxy); | |
2086 | this.scrollHandlerProxy = null; | |
2087 | } | |
2088 | ||
2089 | $(document) | |
2090 | .off('mousemove', this.mousemoveProxy) | |
2091 | .off('mouseup', this.mouseupProxy) | |
2092 | .off('selectstart', this.preventDefault); | |
2093 | ||
2094 | this.mousemoveProxy = null; | |
2095 | this.mouseupProxy = null; | |
2096 | ||
2097 | this.isListening = false; | |
2098 | this.trigger('listenStop', ev); | |
2099 | ||
2100 | this.origCell = this.cell = null; | |
2101 | this.coordMap.clear(); | |
2102 | } | |
2103 | }, | |
2104 | ||
2105 | ||
2106 | // Gets the cell underneath the coordinates for the given mouse event | |
2107 | getCell: function(ev) { | |
2108 | return this.coordMap.getCell(ev.pageX, ev.pageY); | |
2109 | }, | |
2110 | ||
2111 | ||
2112 | // Triggers a callback. Calls a function in the option hash of the same name. | |
2113 | // Arguments beyond the first `name` are forwarded on. | |
2114 | trigger: function(name) { | |
2115 | if (this.options[name]) { | |
2116 | this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); | |
2117 | } | |
2118 | }, | |
2119 | ||
2120 | ||
2121 | // Stops a given mouse event from doing it's native browser action. In our case, text selection. | |
2122 | preventDefault: function(ev) { | |
2123 | ev.preventDefault(); | |
2124 | }, | |
2125 | ||
2126 | ||
2127 | /* Scrolling | |
2128 | ------------------------------------------------------------------------------------------------------------------*/ | |
2129 | ||
2130 | ||
2131 | // Computes and stores the bounding rectangle of scrollEl | |
2132 | computeScrollBounds: function() { | |
2133 | var el = this.scrollEl; | |
2134 | var offset; | |
2135 | ||
2136 | if (el) { | |
2137 | offset = el.offset(); | |
2138 | this.scrollBounds = { | |
2139 | top: offset.top, | |
2140 | left: offset.left, | |
2141 | bottom: offset.top + el.outerHeight(), | |
2142 | right: offset.left + el.outerWidth() | |
2143 | }; | |
2144 | } | |
2145 | }, | |
2146 | ||
2147 | ||
2148 | // Called when the dragging is in progress and scrolling should be updated | |
2149 | dragScroll: function(ev) { | |
2150 | var sensitivity = this.scrollSensitivity; | |
2151 | var bounds = this.scrollBounds; | |
2152 | var topCloseness, bottomCloseness; | |
2153 | var leftCloseness, rightCloseness; | |
2154 | var topVel = 0; | |
2155 | var leftVel = 0; | |
2156 | ||
2157 | if (bounds) { // only scroll if scrollEl exists | |
2158 | ||
2159 | // compute closeness to edges. valid range is from 0.0 - 1.0 | |
2160 | topCloseness = (sensitivity - (ev.pageY - bounds.top)) / sensitivity; | |
2161 | bottomCloseness = (sensitivity - (bounds.bottom - ev.pageY)) / sensitivity; | |
2162 | leftCloseness = (sensitivity - (ev.pageX - bounds.left)) / sensitivity; | |
2163 | rightCloseness = (sensitivity - (bounds.right - ev.pageX)) / sensitivity; | |
2164 | ||
2165 | // translate vertical closeness into velocity. | |
2166 | // mouse must be completely in bounds for velocity to happen. | |
2167 | if (topCloseness >= 0 && topCloseness <= 1) { | |
2168 | topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up | |
2169 | } | |
2170 | else if (bottomCloseness >= 0 && bottomCloseness <= 1) { | |
2171 | topVel = bottomCloseness * this.scrollSpeed; | |
2172 | } | |
2173 | ||
2174 | // translate horizontal closeness into velocity | |
2175 | if (leftCloseness >= 0 && leftCloseness <= 1) { | |
2176 | leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left | |
2177 | } | |
2178 | else if (rightCloseness >= 0 && rightCloseness <= 1) { | |
2179 | leftVel = rightCloseness * this.scrollSpeed; | |
2180 | } | |
2181 | } | |
2182 | ||
2183 | this.setScrollVel(topVel, leftVel); | |
2184 | }, | |
2185 | ||
2186 | ||
2187 | // Sets the speed-of-scrolling for the scrollEl | |
2188 | setScrollVel: function(topVel, leftVel) { | |
2189 | ||
2190 | this.scrollTopVel = topVel; | |
2191 | this.scrollLeftVel = leftVel; | |
2192 | ||
2193 | this.constrainScrollVel(); // massages into realistic values | |
2194 | ||
2195 | // if there is non-zero velocity, and an animation loop hasn't already started, then START | |
2196 | if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) { | |
2197 | this.scrollIntervalId = setInterval( | |
2198 | $.proxy(this, 'scrollIntervalFunc'), // scope to `this` | |
2199 | this.scrollIntervalMs | |
2200 | ); | |
2201 | } | |
2202 | }, | |
2203 | ||
2204 | ||
2205 | // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way | |
2206 | constrainScrollVel: function() { | |
2207 | var el = this.scrollEl; | |
2208 | ||
2209 | if (this.scrollTopVel < 0) { // scrolling up? | |
2210 | if (el.scrollTop() <= 0) { // already scrolled all the way up? | |
2211 | this.scrollTopVel = 0; | |
2212 | } | |
2213 | } | |
2214 | else if (this.scrollTopVel > 0) { // scrolling down? | |
2215 | if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down? | |
2216 | this.scrollTopVel = 0; | |
2217 | } | |
2218 | } | |
2219 | ||
2220 | if (this.scrollLeftVel < 0) { // scrolling left? | |
2221 | if (el.scrollLeft() <= 0) { // already scrolled all the left? | |
2222 | this.scrollLeftVel = 0; | |
2223 | } | |
2224 | } | |
2225 | else if (this.scrollLeftVel > 0) { // scrolling right? | |
2226 | if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right? | |
2227 | this.scrollLeftVel = 0; | |
2228 | } | |
2229 | } | |
2230 | }, | |
2231 | ||
2232 | ||
2233 | // This function gets called during every iteration of the scrolling animation loop | |
2234 | scrollIntervalFunc: function() { | |
2235 | var el = this.scrollEl; | |
2236 | var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by | |
2237 | ||
2238 | // change the value of scrollEl's scroll | |
2239 | if (this.scrollTopVel) { | |
2240 | el.scrollTop(el.scrollTop() + this.scrollTopVel * frac); | |
2241 | } | |
2242 | if (this.scrollLeftVel) { | |
2243 | el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac); | |
2244 | } | |
2245 | ||
2246 | this.constrainScrollVel(); // since the scroll values changed, recompute the velocities | |
2247 | ||
2248 | // if scrolled all the way, which causes the vels to be zero, stop the animation loop | |
2249 | if (!this.scrollTopVel && !this.scrollLeftVel) { | |
2250 | this.stopScrolling(); | |
2251 | } | |
2252 | }, | |
2253 | ||
2254 | ||
2255 | // Kills any existing scrolling animation loop | |
2256 | stopScrolling: function() { | |
2257 | if (this.scrollIntervalId) { | |
2258 | clearInterval(this.scrollIntervalId); | |
2259 | this.scrollIntervalId = null; | |
2260 | ||
2261 | // when all done with scrolling, recompute positions since they probably changed | |
2262 | this.computeCoords(); | |
2263 | } | |
2264 | }, | |
2265 | ||
2266 | ||
2267 | // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce) | |
2268 | scrollHandler: function() { | |
2269 | // recompute all coordinates, but *only* if this is *not* part of our scrolling animation | |
2270 | if (!this.scrollIntervalId) { | |
2271 | this.computeCoords(); | |
2272 | } | |
2273 | } | |
2274 | ||
2275 | }); | |
2276 | ||
2277 | ||
2278 | // Returns `true` if the cells are identically equal. `false` otherwise. | |
2279 | // They must have the same row, col, and be from the same grid. | |
2280 | // Two null values will be considered equal, as two "out of the grid" states are the same. | |
2281 | function isCellsEqual(cell1, cell2) { | |
2282 | ||
2283 | if (!cell1 && !cell2) { | |
2284 | return true; | |
2285 | } | |
2286 | ||
2287 | if (cell1 && cell2) { | |
2288 | return cell1.grid === cell2.grid && | |
2289 | cell1.row === cell2.row && | |
2290 | cell1.col === cell2.col; | |
2291 | } | |
2292 | ||
2293 | return false; | |
2294 | } | |
2295 | ||
2296 | /* Creates a clone of an element and lets it track the mouse as it moves | |
2297 | ----------------------------------------------------------------------------------------------------------------------*/ | |
2298 | ||
2299 | var MouseFollower = Class.extend({ | |
2300 | ||
2301 | options: null, | |
2302 | ||
2303 | sourceEl: null, // the element that will be cloned and made to look like it is dragging | |
2304 | el: null, // the clone of `sourceEl` that will track the mouse | |
2305 | parentEl: null, // the element that `el` (the clone) will be attached to | |
2306 | ||
2307 | // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl | |
2308 | top0: null, | |
2309 | left0: null, | |
2310 | ||
2311 | // the initial position of the mouse | |
2312 | mouseY0: null, | |
2313 | mouseX0: null, | |
2314 | ||
2315 | // the number of pixels the mouse has moved from its initial position | |
2316 | topDelta: null, | |
2317 | leftDelta: null, | |
2318 | ||
2319 | mousemoveProxy: null, // document mousemove handler, bound to the MouseFollower's `this` | |
2320 | ||
2321 | isFollowing: false, | |
2322 | isHidden: false, | |
2323 | isAnimating: false, // doing the revert animation? | |
2324 | ||
2325 | constructor: function(sourceEl, options) { | |
2326 | this.options = options = options || {}; | |
2327 | this.sourceEl = sourceEl; | |
2328 | this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent | |
2329 | }, | |
2330 | ||
2331 | ||
2332 | // Causes the element to start following the mouse | |
2333 | start: function(ev) { | |
2334 | if (!this.isFollowing) { | |
2335 | this.isFollowing = true; | |
2336 | ||
2337 | this.mouseY0 = ev.pageY; | |
2338 | this.mouseX0 = ev.pageX; | |
2339 | this.topDelta = 0; | |
2340 | this.leftDelta = 0; | |
2341 | ||
2342 | if (!this.isHidden) { | |
2343 | this.updatePosition(); | |
2344 | } | |
2345 | ||
2346 | $(document).on('mousemove', this.mousemoveProxy = $.proxy(this, 'mousemove')); | |
2347 | } | |
2348 | }, | |
2349 | ||
2350 | ||
2351 | // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position. | |
2352 | // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately. | |
2353 | stop: function(shouldRevert, callback) { | |
2354 | var _this = this; | |
2355 | var revertDuration = this.options.revertDuration; | |
2356 | ||
2357 | function complete() { | |
2358 | this.isAnimating = false; | |
2359 | _this.destroyEl(); | |
2360 | ||
2361 | this.top0 = this.left0 = null; // reset state for future updatePosition calls | |
2362 | ||
2363 | if (callback) { | |
2364 | callback(); | |
2365 | } | |
2366 | } | |
2367 | ||
2368 | if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time | |
2369 | this.isFollowing = false; | |
2370 | ||
2371 | $(document).off('mousemove', this.mousemoveProxy); | |
2372 | ||
2373 | if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation? | |
2374 | this.isAnimating = true; | |
2375 | this.el.animate({ | |
2376 | top: this.top0, | |
2377 | left: this.left0 | |
2378 | }, { | |
2379 | duration: revertDuration, | |
2380 | complete: complete | |
2381 | }); | |
2382 | } | |
2383 | else { | |
2384 | complete(); | |
2385 | } | |
2386 | } | |
2387 | }, | |
2388 | ||
2389 | ||
2390 | // Gets the tracking element. Create it if necessary | |
2391 | getEl: function() { | |
2392 | var el = this.el; | |
2393 | ||
2394 | if (!el) { | |
2395 | this.sourceEl.width(); // hack to force IE8 to compute correct bounding box | |
2396 | el = this.el = this.sourceEl.clone() | |
2397 | .css({ | |
2398 | position: 'absolute', | |
2399 | visibility: '', // in case original element was hidden (commonly through hideEvents()) | |
2400 | display: this.isHidden ? 'none' : '', // for when initially hidden | |
2401 | margin: 0, | |
2402 | right: 'auto', // erase and set width instead | |
2403 | bottom: 'auto', // erase and set height instead | |
2404 | width: this.sourceEl.width(), // explicit height in case there was a 'right' value | |
2405 | height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value | |
2406 | opacity: this.options.opacity || '', | |
2407 | zIndex: this.options.zIndex | |
2408 | }) | |
2409 | .appendTo(this.parentEl); | |
2410 | } | |
2411 | ||
2412 | return el; | |
2413 | }, | |
2414 | ||
2415 | ||
2416 | // Removes the tracking element if it has already been created | |
2417 | destroyEl: function() { | |
2418 | if (this.el) { | |
2419 | this.el.remove(); | |
2420 | this.el = null; | |
2421 | } | |
2422 | }, | |
2423 | ||
2424 | ||
2425 | // Update the CSS position of the tracking element | |
2426 | updatePosition: function() { | |
2427 | var sourceOffset; | |
2428 | var origin; | |
2429 | ||
2430 | this.getEl(); // ensure this.el | |
2431 | ||
2432 | // make sure origin info was computed | |
2433 | if (this.top0 === null) { | |
2434 | this.sourceEl.width(); // hack to force IE8 to compute correct bounding box | |
2435 | sourceOffset = this.sourceEl.offset(); | |
2436 | origin = this.el.offsetParent().offset(); | |
2437 | this.top0 = sourceOffset.top - origin.top; | |
2438 | this.left0 = sourceOffset.left - origin.left; | |
2439 | } | |
2440 | ||
2441 | this.el.css({ | |
2442 | top: this.top0 + this.topDelta, | |
2443 | left: this.left0 + this.leftDelta | |
2444 | }); | |
2445 | }, | |
2446 | ||
2447 | ||
2448 | // Gets called when the user moves the mouse | |
2449 | mousemove: function(ev) { | |
2450 | this.topDelta = ev.pageY - this.mouseY0; | |
2451 | this.leftDelta = ev.pageX - this.mouseX0; | |
2452 | ||
2453 | if (!this.isHidden) { | |
2454 | this.updatePosition(); | |
2455 | } | |
2456 | }, | |
2457 | ||
2458 | ||
2459 | // Temporarily makes the tracking element invisible. Can be called before following starts | |
2460 | hide: function() { | |
2461 | if (!this.isHidden) { | |
2462 | this.isHidden = true; | |
2463 | if (this.el) { | |
2464 | this.el.hide(); | |
2465 | } | |
2466 | } | |
2467 | }, | |
2468 | ||
2469 | ||
2470 | // Show the tracking element after it has been temporarily hidden | |
2471 | show: function() { | |
2472 | if (this.isHidden) { | |
2473 | this.isHidden = false; | |
2474 | this.updatePosition(); | |
2475 | this.getEl().show(); | |
2476 | } | |
2477 | } | |
2478 | ||
2479 | }); | |
2480 | ||
2481 | /* A utility class for rendering <tr> rows. | |
2482 | ----------------------------------------------------------------------------------------------------------------------*/ | |
2483 | // It leverages methods of the subclass and the View to determine custom rendering behavior for each row "type" | |
2484 | // (such as highlight rows, day rows, helper rows, etc). | |
2485 | ||
2486 | var RowRenderer = Class.extend({ | |
2487 | ||
2488 | view: null, // a View object | |
2489 | isRTL: null, // shortcut to the view's isRTL option | |
2490 | cellHtml: '<td/>', // plain default HTML used for a cell when no other is available | |
2491 | ||
2492 | ||
2493 | constructor: function(view) { | |
2494 | this.view = view; | |
2495 | this.isRTL = view.opt('isRTL'); | |
2496 | }, | |
2497 | ||
2498 | ||
2499 | // Renders the HTML for a row, leveraging custom cell-HTML-renderers based on the `rowType`. | |
2500 | // Also applies the "intro" and "outro" cells, which are specified by the subclass and views. | |
2501 | // `row` is an optional row number. | |
2502 | rowHtml: function(rowType, row) { | |
2503 | var renderCell = this.getHtmlRenderer('cell', rowType); | |
2504 | var rowCellHtml = ''; | |
2505 | var col; | |
2506 | var cell; | |
2507 | ||
2508 | row = row || 0; | |
2509 | ||
2510 | for (col = 0; col < this.colCnt; col++) { | |
2511 | cell = this.getCell(row, col); | |
2512 | rowCellHtml += renderCell(cell); | |
2513 | } | |
2514 | ||
2515 | rowCellHtml = this.bookendCells(rowCellHtml, rowType, row); // apply intro and outro | |
2516 | ||
2517 | return '<tr>' + rowCellHtml + '</tr>'; | |
2518 | }, | |
2519 | ||
2520 | ||
2521 | // Applies the "intro" and "outro" HTML to the given cells. | |
2522 | // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro. | |
2523 | // `cells` can be an HTML string of <td>'s or a jQuery <tr> element | |
2524 | // `row` is an optional row number. | |
2525 | bookendCells: function(cells, rowType, row) { | |
2526 | var intro = this.getHtmlRenderer('intro', rowType)(row || 0); | |
2527 | var outro = this.getHtmlRenderer('outro', rowType)(row || 0); | |
2528 | var prependHtml = this.isRTL ? outro : intro; | |
2529 | var appendHtml = this.isRTL ? intro : outro; | |
2530 | ||
2531 | if (typeof cells === 'string') { | |
2532 | return prependHtml + cells + appendHtml; | |
2533 | } | |
2534 | else { // a jQuery <tr> element | |
2535 | return cells.prepend(prependHtml).append(appendHtml); | |
2536 | } | |
2537 | }, | |
2538 | ||
2539 | ||
2540 | // Returns an HTML-rendering function given a specific `rendererName` (like cell, intro, or outro) and a specific | |
2541 | // `rowType` (like day, eventSkeleton, helperSkeleton), which is optional. | |
2542 | // If a renderer for the specific rowType doesn't exist, it will fall back to a generic renderer. | |
2543 | // We will query the View object first for any custom rendering functions, then the methods of the subclass. | |
2544 | getHtmlRenderer: function(rendererName, rowType) { | |
2545 | var view = this.view; | |
2546 | var generalName; // like "cellHtml" | |
2547 | var specificName; // like "dayCellHtml". based on rowType | |
2548 | var provider; // either the View or the RowRenderer subclass, whichever provided the method | |
2549 | var renderer; | |
2550 | ||
2551 | generalName = rendererName + 'Html'; | |
2552 | if (rowType) { | |
2553 | specificName = rowType + capitaliseFirstLetter(rendererName) + 'Html'; | |
2554 | } | |
2555 | ||
2556 | if (specificName && (renderer = view[specificName])) { | |
2557 | provider = view; | |
2558 | } | |
2559 | else if (specificName && (renderer = this[specificName])) { | |
2560 | provider = this; | |
2561 | } | |
2562 | else if ((renderer = view[generalName])) { | |
2563 | provider = view; | |
2564 | } | |
2565 | else if ((renderer = this[generalName])) { | |
2566 | provider = this; | |
2567 | } | |
2568 | ||
2569 | if (typeof renderer === 'function') { | |
2570 | return function() { | |
2571 | return renderer.apply(provider, arguments) || ''; // use correct `this` and always return a string | |
2572 | }; | |
2573 | } | |
2574 | ||
2575 | // the rendered can be a plain string as well. if not specified, always an empty string. | |
2576 | return function() { | |
2577 | return renderer || ''; | |
2578 | }; | |
2579 | } | |
2580 | ||
2581 | }); | |
2582 | ||
2583 | /* An abstract class comprised of a "grid" of cells that each represent a specific datetime | |
2584 | ----------------------------------------------------------------------------------------------------------------------*/ | |
2585 | ||
2586 | var Grid = fc.Grid = RowRenderer.extend({ | |
2587 | ||
2588 | start: null, // the date of the first cell | |
2589 | end: null, // the date after the last cell | |
2590 | ||
2591 | rowCnt: 0, // number of rows | |
2592 | colCnt: 0, // number of cols | |
2593 | rowData: null, // array of objects, holding misc data for each row | |
2594 | colData: null, // array of objects, holding misc data for each column | |
2595 | ||
2596 | el: null, // the containing element | |
2597 | coordMap: null, // a GridCoordMap that converts pixel values to datetimes | |
2598 | elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name. | |
2599 | ||
2600 | documentDragStartProxy: null, // binds the Grid's scope to documentDragStart (in DayGrid.events) | |
2601 | ||
2602 | // derived from options | |
2603 | colHeadFormat: null, // TODO: move to another class. not applicable to all Grids | |
2604 | eventTimeFormat: null, | |
2605 | displayEventEnd: null, | |
2606 | ||
2607 | ||
2608 | constructor: function() { | |
2609 | RowRenderer.apply(this, arguments); // call the super-constructor | |
2610 | ||
2611 | this.coordMap = new GridCoordMap(this); | |
2612 | this.elsByFill = {}; | |
2613 | this.documentDragStartProxy = $.proxy(this, 'documentDragStart'); | |
2614 | }, | |
2615 | ||
2616 | ||
2617 | // Renders the grid into the `el` element. | |
2618 | // Subclasses should override and call this super-method when done. | |
2619 | render: function() { | |
2620 | this.bindHandlers(); | |
2621 | }, | |
2622 | ||
2623 | ||
2624 | // Called when the grid's resources need to be cleaned up | |
2625 | destroy: function() { | |
2626 | this.unbindHandlers(); | |
2627 | }, | |
2628 | ||
2629 | ||
2630 | /* Options | |
2631 | ------------------------------------------------------------------------------------------------------------------*/ | |
2632 | ||
2633 | ||
2634 | // Generates the format string used for the text in column headers, if not explicitly defined by 'columnFormat' | |
2635 | // TODO: move to another class. not applicable to all Grids | |
2636 | computeColHeadFormat: function() { | |
2637 | // subclasses must implement if they want to use headHtml() | |
2638 | }, | |
2639 | ||
2640 | ||
2641 | // Generates the format string used for event time text, if not explicitly defined by 'timeFormat' | |
2642 | computeEventTimeFormat: function() { | |
2643 | return this.view.opt('smallTimeFormat'); | |
2644 | }, | |
2645 | ||
2646 | ||
2647 | // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd' | |
2648 | computeDisplayEventEnd: function() { | |
2649 | return false; | |
2650 | }, | |
2651 | ||
2652 | ||
2653 | /* Dates | |
2654 | ------------------------------------------------------------------------------------------------------------------*/ | |
2655 | ||
2656 | ||
2657 | // Tells the grid about what period of time to display. Grid will subsequently compute dates for cell system. | |
2658 | setRange: function(range) { | |
2659 | var view = this.view; | |
2660 | ||
2661 | this.start = range.start.clone(); | |
2662 | this.end = range.end.clone(); | |
2663 | ||
2664 | this.rowData = []; | |
2665 | this.colData = []; | |
2666 | this.updateCells(); | |
2667 | ||
2668 | // Populate option-derived settings. Look for override first, then compute if necessary. | |
2669 | this.colHeadFormat = view.opt('columnFormat') || this.computeColHeadFormat(); | |
2670 | this.eventTimeFormat = view.opt('timeFormat') || this.computeEventTimeFormat(); | |
2671 | this.displayEventEnd = view.opt('displayEventEnd'); | |
2672 | if (this.displayEventEnd == null) { | |
2673 | this.displayEventEnd = this.computeDisplayEventEnd(); | |
2674 | } | |
2675 | }, | |
2676 | ||
2677 | ||
2678 | // Responsible for setting rowCnt/colCnt and any other row/col data | |
2679 | updateCells: function() { | |
2680 | // subclasses must implement | |
2681 | }, | |
2682 | ||
2683 | ||
2684 | // Converts a range with an inclusive `start` and an exclusive `end` into an array of segment objects | |
2685 | rangeToSegs: function(range) { | |
2686 | // subclasses must implement | |
2687 | }, | |
2688 | ||
2689 | ||
2690 | /* Cells | |
2691 | ------------------------------------------------------------------------------------------------------------------*/ | |
2692 | // NOTE: columns are ordered left-to-right | |
2693 | ||
2694 | ||
2695 | // Gets an object containing row/col number, misc data, and range information about the cell. | |
2696 | // Accepts row/col values, an object with row/col properties, or a single-number offset from the first cell. | |
2697 | getCell: function(row, col) { | |
2698 | var cell; | |
2699 | ||
2700 | if (col == null) { | |
2701 | if (typeof row === 'number') { // a single-number offset | |
2702 | col = row % this.colCnt; | |
2703 | row = Math.floor(row / this.colCnt); | |
2704 | } | |
2705 | else { // an object with row/col properties | |
2706 | col = row.col; | |
2707 | row = row.row; | |
2708 | } | |
2709 | } | |
2710 | ||
2711 | cell = { row: row, col: col }; | |
2712 | ||
2713 | $.extend(cell, this.getRowData(row), this.getColData(col)); | |
2714 | $.extend(cell, this.computeCellRange(cell)); | |
2715 | ||
2716 | return cell; | |
2717 | }, | |
2718 | ||
2719 | ||
2720 | // Given a cell object with index and misc data, generates a range object | |
2721 | computeCellRange: function(cell) { | |
2722 | // subclasses must implement | |
2723 | }, | |
2724 | ||
2725 | ||
2726 | // Retrieves misc data about the given row | |
2727 | getRowData: function(row) { | |
2728 | return this.rowData[row] || {}; | |
2729 | }, | |
2730 | ||
2731 | ||
2732 | // Retrieves misc data baout the given column | |
2733 | getColData: function(col) { | |
2734 | return this.colData[col] || {}; | |
2735 | }, | |
2736 | ||
2737 | ||
2738 | // Retrieves the element representing the given row | |
2739 | getRowEl: function(row) { | |
2740 | // subclasses should implement if leveraging the default getCellDayEl() or computeRowCoords() | |
2741 | }, | |
2742 | ||
2743 | ||
2744 | // Retrieves the element representing the given column | |
2745 | getColEl: function(col) { | |
2746 | // subclasses should implement if leveraging the default getCellDayEl() or computeColCoords() | |
2747 | }, | |
2748 | ||
2749 | ||
2750 | // Given a cell object, returns the element that represents the cell's whole-day | |
2751 | getCellDayEl: function(cell) { | |
2752 | return this.getColEl(cell.col) || this.getRowEl(cell.row); | |
2753 | }, | |
2754 | ||
2755 | ||
2756 | /* Cell Coordinates | |
2757 | ------------------------------------------------------------------------------------------------------------------*/ | |
2758 | ||
2759 | ||
2760 | // Computes the top/bottom coordinates of all rows. | |
2761 | // By default, queries the dimensions of the element provided by getRowEl(). | |
2762 | computeRowCoords: function() { | |
2763 | var items = []; | |
2764 | var i, el; | |
2765 | var item; | |
2766 | ||
2767 | for (i = 0; i < this.rowCnt; i++) { | |
2768 | el = this.getRowEl(i); | |
2769 | item = { | |
2770 | top: el.offset().top | |
2771 | }; | |
2772 | if (i > 0) { | |
2773 | items[i - 1].bottom = item.top; | |
2774 | } | |
2775 | items.push(item); | |
2776 | } | |
2777 | item.bottom = item.top + el.outerHeight(); | |
2778 | ||
2779 | return items; | |
2780 | }, | |
2781 | ||
2782 | ||
2783 | // Computes the left/right coordinates of all rows. | |
2784 | // By default, queries the dimensions of the element provided by getColEl(). | |
2785 | computeColCoords: function() { | |
2786 | var items = []; | |
2787 | var i, el; | |
2788 | var item; | |
2789 | ||
2790 | for (i = 0; i < this.colCnt; i++) { | |
2791 | el = this.getColEl(i); | |
2792 | item = { | |
2793 | left: el.offset().left | |
2794 | }; | |
2795 | if (i > 0) { | |
2796 | items[i - 1].right = item.left; | |
2797 | } | |
2798 | items.push(item); | |
2799 | } | |
2800 | item.right = item.left + el.outerWidth(); | |
2801 | ||
2802 | return items; | |
2803 | }, | |
2804 | ||
2805 | ||
2806 | /* Handlers | |
2807 | ------------------------------------------------------------------------------------------------------------------*/ | |
2808 | ||
2809 | ||
2810 | // Attaches handlers to DOM | |
2811 | bindHandlers: function() { | |
2812 | var _this = this; | |
2813 | ||
2814 | // attach a handler to the grid's root element. | |
2815 | // we don't need to clean up in unbindHandlers or destroy, because when jQuery removes the element from the | |
2816 | // DOM it automatically unregisters the handlers. | |
2817 | this.el.on('mousedown', function(ev) { | |
2818 | if ( | |
2819 | !$(ev.target).is('.fc-event-container *, .fc-more') && // not an an event element, or "more.." link | |
2820 | !$(ev.target).closest('.fc-popover').length // not on a popover (like the "more.." events one) | |
2821 | ) { | |
2822 | _this.dayMousedown(ev); | |
2823 | } | |
2824 | }); | |
2825 | ||
2826 | // attach event-element-related handlers. in Grid.events | |
2827 | // same garbage collection note as above. | |
2828 | this.bindSegHandlers(); | |
2829 | ||
2830 | $(document).on('dragstart', this.documentDragStartProxy); // jqui drag | |
2831 | }, | |
2832 | ||
2833 | ||
2834 | // Unattaches handlers from the DOM | |
2835 | unbindHandlers: function() { | |
2836 | $(document).off('dragstart', this.documentDragStartProxy); // jqui drag | |
2837 | }, | |
2838 | ||
2839 | ||
2840 | // Process a mousedown on an element that represents a day. For day clicking and selecting. | |
2841 | dayMousedown: function(ev) { | |
2842 | var _this = this; | |
2843 | var view = this.view; | |
2844 | var isSelectable = view.opt('selectable'); | |
2845 | var dayClickCell; // null if invalid dayClick | |
2846 | var selectionRange; // null if invalid selection | |
2847 | ||
2848 | // this listener tracks a mousedown on a day element, and a subsequent drag. | |
2849 | // if the drag ends on the same day, it is a 'dayClick'. | |
2850 | // if 'selectable' is enabled, this listener also detects selections. | |
2851 | var dragListener = new DragListener(this.coordMap, { | |
2852 | //distance: 5, // needs more work if we want dayClick to fire correctly | |
2853 | scroll: view.opt('dragScroll'), | |
2854 | dragStart: function() { | |
2855 | view.unselect(); // since we could be rendering a new selection, we want to clear any old one | |
2856 | }, | |
2857 | cellOver: function(cell, isOrig) { | |
2858 | var origCell = dragListener.origCell; | |
2859 | if (origCell) { // click needs to have started on a cell | |
2860 | dayClickCell = isOrig ? cell : null; // single-cell selection is a day click | |
2861 | if (isSelectable) { | |
2862 | selectionRange = _this.computeSelection(origCell, cell); | |
2863 | if (selectionRange) { | |
2864 | _this.renderSelection(selectionRange); | |
2865 | } | |
2866 | else { | |
2867 | disableCursor(); | |
2868 | } | |
2869 | } | |
2870 | } | |
2871 | }, | |
2872 | cellOut: function(cell) { | |
2873 | dayClickCell = null; | |
2874 | selectionRange = null; | |
2875 | _this.destroySelection(); | |
2876 | enableCursor(); | |
2877 | }, | |
2878 | listenStop: function(ev) { | |
2879 | if (dayClickCell) { | |
2880 | view.trigger('dayClick', _this.getCellDayEl(dayClickCell), dayClickCell.start, ev); | |
2881 | } | |
2882 | if (selectionRange) { | |
2883 | // the selection will already have been rendered. just report it | |
2884 | view.reportSelection(selectionRange, ev); | |
2885 | } | |
2886 | enableCursor(); | |
2887 | } | |
2888 | }); | |
2889 | ||
2890 | dragListener.mousedown(ev); // start listening, which will eventually initiate a dragStart | |
2891 | }, | |
2892 | ||
2893 | ||
2894 | /* Event Helper | |
2895 | ------------------------------------------------------------------------------------------------------------------*/ | |
2896 | // TODO: should probably move this to Grid.events, like we did event dragging / resizing | |
2897 | ||
2898 | ||
2899 | // Renders a mock event over the given range. | |
2900 | // The range's end can be null, in which case the mock event that is rendered will have a null end time. | |
2901 | // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging. | |
2902 | renderRangeHelper: function(range, sourceSeg) { | |
2903 | var fakeEvent; | |
2904 | ||
2905 | fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible | |
2906 | fakeEvent.start = range.start.clone(); | |
2907 | fakeEvent.end = range.end ? range.end.clone() : null; | |
2908 | fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventDateProps | |
2909 | this.view.calendar.normalizeEventDateProps(fakeEvent); | |
2910 | ||
2911 | // this extra className will be useful for differentiating real events from mock events in CSS | |
2912 | fakeEvent.className = (fakeEvent.className || []).concat('fc-helper'); | |
2913 | ||
2914 | // if something external is being dragged in, don't render a resizer | |
2915 | if (!sourceSeg) { | |
2916 | fakeEvent.editable = false; | |
2917 | } | |
2918 | ||
2919 | this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering | |
2920 | }, | |
2921 | ||
2922 | ||
2923 | // Renders a mock event | |
2924 | renderHelper: function(event, sourceSeg) { | |
2925 | // subclasses must implement | |
2926 | }, | |
2927 | ||
2928 | ||
2929 | // Unrenders a mock event | |
2930 | destroyHelper: function() { | |
2931 | // subclasses must implement | |
2932 | }, | |
2933 | ||
2934 | ||
2935 | /* Selection | |
2936 | ------------------------------------------------------------------------------------------------------------------*/ | |
2937 | ||
2938 | ||
2939 | // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses. | |
2940 | renderSelection: function(range) { | |
2941 | this.renderHighlight(range); | |
2942 | }, | |
2943 | ||
2944 | ||
2945 | // Unrenders any visual indications of a selection. Will unrender a highlight by default. | |
2946 | destroySelection: function() { | |
2947 | this.destroyHighlight(); | |
2948 | }, | |
2949 | ||
2950 | ||
2951 | // Given the first and last cells of a selection, returns a range object. | |
2952 | // Will return something falsy if the selection is invalid (when outside of selectionConstraint for example). | |
2953 | // Subclasses can override and provide additional data in the range object. Will be passed to renderSelection(). | |
2954 | computeSelection: function(firstCell, lastCell) { | |
2955 | var dates = [ | |
2956 | firstCell.start, | |
2957 | firstCell.end, | |
2958 | lastCell.start, | |
2959 | lastCell.end | |
2960 | ]; | |
2961 | var range; | |
2962 | ||
2963 | dates.sort(compareNumbers); // sorts chronologically. works with Moments | |
2964 | ||
2965 | range = { | |
2966 | start: dates[0].clone(), | |
2967 | end: dates[3].clone() | |
2968 | }; | |
2969 | ||
2970 | if (!this.view.calendar.isSelectionRangeAllowed(range)) { | |
2971 | return null; | |
2972 | } | |
2973 | ||
2974 | return range; | |
2975 | }, | |
2976 | ||
2977 | ||
2978 | /* Highlight | |
2979 | ------------------------------------------------------------------------------------------------------------------*/ | |
2980 | ||
2981 | ||
2982 | // Renders an emphasis on the given date range. `start` is inclusive. `end` is exclusive. | |
2983 | renderHighlight: function(range) { | |
2984 | this.renderFill('highlight', this.rangeToSegs(range)); | |
2985 | }, | |
2986 | ||
2987 | ||
2988 | // Unrenders the emphasis on a date range | |
2989 | destroyHighlight: function() { | |
2990 | this.destroyFill('highlight'); | |
2991 | }, | |
2992 | ||
2993 | ||
2994 | // Generates an array of classNames for rendering the highlight. Used by the fill system. | |
2995 | highlightSegClasses: function() { | |
2996 | return [ 'fc-highlight' ]; | |
2997 | }, | |
2998 | ||
2999 | ||
3000 | /* Fill System (highlight, background events, business hours) | |
3001 | ------------------------------------------------------------------------------------------------------------------*/ | |
3002 | ||
3003 | ||
3004 | // Renders a set of rectangles over the given segments of time. | |
3005 | // Returns a subset of segs, the segs that were actually rendered. | |
3006 | // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement | |
3007 | renderFill: function(type, segs) { | |
3008 | // subclasses must implement | |
3009 | }, | |
3010 | ||
3011 | ||
3012 | // Unrenders a specific type of fill that is currently rendered on the grid | |
3013 | destroyFill: function(type) { | |
3014 | var el = this.elsByFill[type]; | |
3015 | ||
3016 | if (el) { | |
3017 | el.remove(); | |
3018 | delete this.elsByFill[type]; | |
3019 | } | |
3020 | }, | |
3021 | ||
3022 | ||
3023 | // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types. | |
3024 | // Only returns segments that successfully rendered. | |
3025 | // To be harnessed by renderFill (implemented by subclasses). | |
3026 | // Analagous to renderFgSegEls. | |
3027 | renderFillSegEls: function(type, segs) { | |
3028 | var _this = this; | |
3029 | var segElMethod = this[type + 'SegEl']; | |
3030 | var html = ''; | |
3031 | var renderedSegs = []; | |
3032 | var i; | |
3033 | ||
3034 | if (segs.length) { | |
3035 | ||
3036 | // build a large concatenation of segment HTML | |
3037 | for (i = 0; i < segs.length; i++) { | |
3038 | html += this.fillSegHtml(type, segs[i]); | |
3039 | } | |
3040 | ||
3041 | // Grab individual elements from the combined HTML string. Use each as the default rendering. | |
3042 | // Then, compute the 'el' for each segment. | |
3043 | $(html).each(function(i, node) { | |
3044 | var seg = segs[i]; | |
3045 | var el = $(node); | |
3046 | ||
3047 | // allow custom filter methods per-type | |
3048 | if (segElMethod) { | |
3049 | el = segElMethod.call(_this, seg, el); | |
3050 | } | |
3051 | ||
3052 | if (el) { // custom filters did not cancel the render | |
3053 | el = $(el); // allow custom filter to return raw DOM node | |
3054 | ||
3055 | // correct element type? (would be bad if a non-TD were inserted into a table for example) | |
3056 | if (el.is(_this.fillSegTag)) { | |
3057 | seg.el = el; | |
3058 | renderedSegs.push(seg); | |
3059 | } | |
3060 | } | |
3061 | }); | |
3062 | } | |
3063 | ||
3064 | return renderedSegs; | |
3065 | }, | |
3066 | ||
3067 | ||
3068 | fillSegTag: 'div', // subclasses can override | |
3069 | ||
3070 | ||
3071 | // Builds the HTML needed for one fill segment. Generic enought o work with different types. | |
3072 | fillSegHtml: function(type, seg) { | |
3073 | var classesMethod = this[type + 'SegClasses']; // custom hooks per-type | |
3074 | var stylesMethod = this[type + 'SegStyles']; // | |
3075 | var classes = classesMethod ? classesMethod.call(this, seg) : []; | |
3076 | var styles = stylesMethod ? stylesMethod.call(this, seg) : ''; // a semi-colon separated CSS property string | |
3077 | ||
3078 | return '<' + this.fillSegTag + | |
3079 | (classes.length ? ' class="' + classes.join(' ') + '"' : '') + | |
3080 | (styles ? ' style="' + styles + '"' : '') + | |
3081 | ' />'; | |
3082 | }, | |
3083 | ||
3084 | ||
3085 | /* Generic rendering utilities for subclasses | |
3086 | ------------------------------------------------------------------------------------------------------------------*/ | |
3087 | ||
3088 | ||
3089 | // Renders a day-of-week header row. | |
3090 | // TODO: move to another class. not applicable to all Grids | |
3091 | headHtml: function() { | |
3092 | return '' + | |
3093 | '<div class="fc-row ' + this.view.widgetHeaderClass + '">' + | |
3094 | '<table>' + | |
3095 | '<thead>' + | |
3096 | this.rowHtml('head') + // leverages RowRenderer | |
3097 | '</thead>' + | |
3098 | '</table>' + | |
3099 | '</div>'; | |
3100 | }, | |
3101 | ||
3102 | ||
3103 | // Used by the `headHtml` method, via RowRenderer, for rendering the HTML of a day-of-week header cell | |
3104 | // TODO: move to another class. not applicable to all Grids | |
3105 | headCellHtml: function(cell) { | |
3106 | var view = this.view; | |
3107 | var date = cell.start; | |
3108 | ||
3109 | return '' + | |
3110 | '<th class="fc-day-header ' + view.widgetHeaderClass + ' fc-' + dayIDs[date.day()] + '">' + | |
3111 | htmlEscape(date.format(this.colHeadFormat)) + | |
3112 | '</th>'; | |
3113 | }, | |
3114 | ||
3115 | ||
3116 | // Renders the HTML for a single-day background cell | |
3117 | bgCellHtml: function(cell) { | |
3118 | var view = this.view; | |
3119 | var date = cell.start; | |
3120 | var classes = this.getDayClasses(date); | |
3121 | ||
3122 | classes.unshift('fc-day', view.widgetContentClass); | |
3123 | ||
3124 | return '<td class="' + classes.join(' ') + '"' + | |
3125 | ' data-date="' + date.format('YYYY-MM-DD') + '"' + // if date has a time, won't format it | |
3126 | '></td>'; | |
3127 | }, | |
3128 | ||
3129 | ||
3130 | // Computes HTML classNames for a single-day cell | |
3131 | getDayClasses: function(date) { | |
3132 | var view = this.view; | |
3133 | var today = view.calendar.getNow().stripTime(); | |
3134 | var classes = [ 'fc-' + dayIDs[date.day()] ]; | |
3135 | ||
3136 | if ( | |
3137 | view.name === 'month' && | |
3138 | date.month() != view.intervalStart.month() | |
3139 | ) { | |
3140 | classes.push('fc-other-month'); | |
3141 | } | |
3142 | ||
3143 | if (date.isSame(today, 'day')) { | |
3144 | classes.push( | |
3145 | 'fc-today', | |
3146 | view.highlightStateClass | |
3147 | ); | |
3148 | } | |
3149 | else if (date < today) { | |
3150 | classes.push('fc-past'); | |
3151 | } | |
3152 | else { | |
3153 | classes.push('fc-future'); | |
3154 | } | |
3155 | ||
3156 | return classes; | |
3157 | } | |
3158 | ||
3159 | }); | |
3160 | ||
3161 | /* Event-rendering and event-interaction methods for the abstract Grid class | |
3162 | ----------------------------------------------------------------------------------------------------------------------*/ | |
3163 | ||
3164 | Grid.mixin({ | |
3165 | ||
3166 | mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing | |
3167 | isDraggingSeg: false, // is a segment being dragged? boolean | |
3168 | isResizingSeg: false, // is a segment being resized? boolean | |
3169 | segs: null, // the event segments currently rendered in the grid | |
3170 | ||
3171 | ||
3172 | // Renders the given events onto the grid | |
3173 | renderEvents: function(events) { | |
3174 | var segs = this.eventsToSegs(events); | |
3175 | var bgSegs = []; | |
3176 | var fgSegs = []; | |
3177 | var i, seg; | |
3178 | ||
3179 | for (i = 0; i < segs.length; i++) { | |
3180 | seg = segs[i]; | |
3181 | ||
3182 | if (isBgEvent(seg.event)) { | |
3183 | bgSegs.push(seg); | |
3184 | } | |
3185 | else { | |
3186 | fgSegs.push(seg); | |
3187 | } | |
3188 | } | |
3189 | ||
3190 | // Render each different type of segment. | |
3191 | // Each function may return a subset of the segs, segs that were actually rendered. | |
3192 | bgSegs = this.renderBgSegs(bgSegs) || bgSegs; | |
3193 | fgSegs = this.renderFgSegs(fgSegs) || fgSegs; | |
3194 | ||
3195 | this.segs = bgSegs.concat(fgSegs); | |
3196 | }, | |
3197 | ||
3198 | ||
3199 | // Unrenders all events currently rendered on the grid | |
3200 | destroyEvents: function() { | |
3201 | this.triggerSegMouseout(); // trigger an eventMouseout if user's mouse is over an event | |
3202 | ||
3203 | this.destroyFgSegs(); | |
3204 | this.destroyBgSegs(); | |
3205 | ||
3206 | this.segs = null; | |
3207 | }, | |
3208 | ||
3209 | ||
3210 | // Retrieves all rendered segment objects currently rendered on the grid | |
3211 | getEventSegs: function() { | |
3212 | return this.segs || []; | |
3213 | }, | |
3214 | ||
3215 | ||
3216 | /* Foreground Segment Rendering | |
3217 | ------------------------------------------------------------------------------------------------------------------*/ | |
3218 | ||
3219 | ||
3220 | // Renders foreground event segments onto the grid. May return a subset of segs that were rendered. | |
3221 | renderFgSegs: function(segs) { | |
3222 | // subclasses must implement | |
3223 | }, | |
3224 | ||
3225 | ||
3226 | // Unrenders all currently rendered foreground segments | |
3227 | destroyFgSegs: function() { | |
3228 | // subclasses must implement | |
3229 | }, | |
3230 | ||
3231 | ||
3232 | // Renders and assigns an `el` property for each foreground event segment. | |
3233 | // Only returns segments that successfully rendered. | |
3234 | // A utility that subclasses may use. | |
3235 | renderFgSegEls: function(segs, disableResizing) { | |
3236 | var view = this.view; | |
3237 | var html = ''; | |
3238 | var renderedSegs = []; | |
3239 | var i; | |
3240 | ||
3241 | if (segs.length) { // don't build an empty html string | |
3242 | ||
3243 | // build a large concatenation of event segment HTML | |
3244 | for (i = 0; i < segs.length; i++) { | |
3245 | html += this.fgSegHtml(segs[i], disableResizing); | |
3246 | } | |
3247 | ||
3248 | // Grab individual elements from the combined HTML string. Use each as the default rendering. | |
3249 | // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false. | |
3250 | $(html).each(function(i, node) { | |
3251 | var seg = segs[i]; | |
3252 | var el = view.resolveEventEl(seg.event, $(node)); | |
3253 | ||
3254 | if (el) { | |
3255 | el.data('fc-seg', seg); // used by handlers | |
3256 | seg.el = el; | |
3257 | renderedSegs.push(seg); | |
3258 | } | |
3259 | }); | |
3260 | } | |
3261 | ||
3262 | return renderedSegs; | |
3263 | }, | |
3264 | ||
3265 | ||
3266 | // Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls() | |
3267 | fgSegHtml: function(seg, disableResizing) { | |
3268 | // subclasses should implement | |
3269 | }, | |
3270 | ||
3271 | ||
3272 | /* Background Segment Rendering | |
3273 | ------------------------------------------------------------------------------------------------------------------*/ | |
3274 | ||
3275 | ||
3276 | // Renders the given background event segments onto the grid. | |
3277 | // Returns a subset of the segs that were actually rendered. | |
3278 | renderBgSegs: function(segs) { | |
3279 | return this.renderFill('bgEvent', segs); | |
3280 | }, | |
3281 | ||
3282 | ||
3283 | // Unrenders all the currently rendered background event segments | |
3284 | destroyBgSegs: function() { | |
3285 | this.destroyFill('bgEvent'); | |
3286 | }, | |
3287 | ||
3288 | ||
3289 | // Renders a background event element, given the default rendering. Called by the fill system. | |
3290 | bgEventSegEl: function(seg, el) { | |
3291 | return this.view.resolveEventEl(seg.event, el); // will filter through eventRender | |
3292 | }, | |
3293 | ||
3294 | ||
3295 | // Generates an array of classNames to be used for the default rendering of a background event. | |
3296 | // Called by the fill system. | |
3297 | bgEventSegClasses: function(seg) { | |
3298 | var event = seg.event; | |
3299 | var source = event.source || {}; | |
3300 | ||
3301 | return [ 'fc-bgevent' ].concat( | |
3302 | event.className, | |
3303 | source.className || [] | |
3304 | ); | |
3305 | }, | |
3306 | ||
3307 | ||
3308 | // Generates a semicolon-separated CSS string to be used for the default rendering of a background event. | |
3309 | // Called by the fill system. | |
3310 | // TODO: consolidate with getEventSkinCss? | |
3311 | bgEventSegStyles: function(seg) { | |
3312 | var view = this.view; | |
3313 | var event = seg.event; | |
3314 | var source = event.source || {}; | |
3315 | var eventColor = event.color; | |
3316 | var sourceColor = source.color; | |
3317 | var optionColor = view.opt('eventColor'); | |
3318 | var backgroundColor = | |
3319 | event.backgroundColor || | |
3320 | eventColor || | |
3321 | source.backgroundColor || | |
3322 | sourceColor || | |
3323 | view.opt('eventBackgroundColor') || | |
3324 | optionColor; | |
3325 | ||
3326 | if (backgroundColor) { | |
3327 | return 'background-color:' + backgroundColor; | |
3328 | } | |
3329 | ||
3330 | return ''; | |
3331 | }, | |
3332 | ||
3333 | ||
3334 | // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system. | |
3335 | businessHoursSegClasses: function(seg) { | |
3336 | return [ 'fc-nonbusiness', 'fc-bgevent' ]; | |
3337 | }, | |
3338 | ||
3339 | ||
3340 | /* Handlers | |
3341 | ------------------------------------------------------------------------------------------------------------------*/ | |
3342 | ||
3343 | ||
3344 | // Attaches event-element-related handlers to the container element and leverage bubbling | |
3345 | bindSegHandlers: function() { | |
3346 | var _this = this; | |
3347 | var view = this.view; | |
3348 | ||
3349 | $.each( | |
3350 | { | |
3351 | mouseenter: function(seg, ev) { | |
3352 | _this.triggerSegMouseover(seg, ev); | |
3353 | }, | |
3354 | mouseleave: function(seg, ev) { | |
3355 | _this.triggerSegMouseout(seg, ev); | |
3356 | }, | |
3357 | click: function(seg, ev) { | |
3358 | return view.trigger('eventClick', this, seg.event, ev); // can return `false` to cancel | |
3359 | }, | |
3360 | mousedown: function(seg, ev) { | |
3361 | if ($(ev.target).is('.fc-resizer') && view.isEventResizable(seg.event)) { | |
3362 | _this.segResizeMousedown(seg, ev); | |
3363 | } | |
3364 | else if (view.isEventDraggable(seg.event)) { | |
3365 | _this.segDragMousedown(seg, ev); | |
3366 | } | |
3367 | } | |
3368 | }, | |
3369 | function(name, func) { | |
3370 | // attach the handler to the container element and only listen for real event elements via bubbling | |
3371 | _this.el.on(name, '.fc-event-container > *', function(ev) { | |
3372 | var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents | |
3373 | ||
3374 | // only call the handlers if there is not a drag/resize in progress | |
3375 | if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) { | |
3376 | return func.call(this, seg, ev); // `this` will be the event element | |
3377 | } | |
3378 | }); | |
3379 | } | |
3380 | ); | |
3381 | }, | |
3382 | ||
3383 | ||
3384 | // Updates internal state and triggers handlers for when an event element is moused over | |
3385 | triggerSegMouseover: function(seg, ev) { | |
3386 | if (!this.mousedOverSeg) { | |
3387 | this.mousedOverSeg = seg; | |
3388 | this.view.trigger('eventMouseover', seg.el[0], seg.event, ev); | |
3389 | } | |
3390 | }, | |
3391 | ||
3392 | ||
3393 | // Updates internal state and triggers handlers for when an event element is moused out. | |
3394 | // Can be given no arguments, in which case it will mouseout the segment that was previously moused over. | |
3395 | triggerSegMouseout: function(seg, ev) { | |
3396 | ev = ev || {}; // if given no args, make a mock mouse event | |
3397 | ||
3398 | if (this.mousedOverSeg) { | |
3399 | seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment | |
3400 | this.mousedOverSeg = null; | |
3401 | this.view.trigger('eventMouseout', seg.el[0], seg.event, ev); | |
3402 | } | |
3403 | }, | |
3404 | ||
3405 | ||
3406 | /* Event Dragging | |
3407 | ------------------------------------------------------------------------------------------------------------------*/ | |
3408 | ||
3409 | ||
3410 | // Called when the user does a mousedown on an event, which might lead to dragging. | |
3411 | // Generic enough to work with any type of Grid. | |
3412 | segDragMousedown: function(seg, ev) { | |
3413 | var _this = this; | |
3414 | var view = this.view; | |
3415 | var el = seg.el; | |
3416 | var event = seg.event; | |
3417 | var dropLocation; | |
3418 | ||
3419 | // A clone of the original element that will move with the mouse | |
3420 | var mouseFollower = new MouseFollower(seg.el, { | |
3421 | parentEl: view.el, | |
3422 | opacity: view.opt('dragOpacity'), | |
3423 | revertDuration: view.opt('dragRevertDuration'), | |
3424 | zIndex: 2 // one above the .fc-view | |
3425 | }); | |
3426 | ||
3427 | // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents | |
3428 | // of the view. | |
3429 | var dragListener = new DragListener(view.coordMap, { | |
3430 | distance: 5, | |
3431 | scroll: view.opt('dragScroll'), | |
3432 | listenStart: function(ev) { | |
3433 | mouseFollower.hide(); // don't show until we know this is a real drag | |
3434 | mouseFollower.start(ev); | |
3435 | }, | |
3436 | dragStart: function(ev) { | |
3437 | _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported | |
3438 | _this.isDraggingSeg = true; | |
3439 | view.hideEvent(event); // hide all event segments. our mouseFollower will take over | |
3440 | view.trigger('eventDragStart', el[0], event, ev, {}); // last argument is jqui dummy | |
3441 | }, | |
3442 | cellOver: function(cell, isOrig) { | |
3443 | var origCell = seg.cell || dragListener.origCell; // starting cell could be forced (DayGrid.limit) | |
3444 | ||
3445 | dropLocation = _this.computeEventDrop(origCell, cell, event); | |
3446 | if (dropLocation) { | |
3447 | if (view.renderDrag(dropLocation, seg)) { // have the subclass render a visual indication | |
3448 | mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own | |
3449 | } | |
3450 | else { | |
3451 | mouseFollower.show(); | |
3452 | } | |
3453 | if (isOrig) { | |
3454 | dropLocation = null; // needs to have moved cells to be a valid drop | |
3455 | } | |
3456 | } | |
3457 | else { | |
3458 | // have the helper follow the mouse (no snapping) with a warning-style cursor | |
3459 | mouseFollower.show(); | |
3460 | disableCursor(); | |
3461 | } | |
3462 | }, | |
3463 | cellOut: function() { // called before mouse moves to a different cell OR moved out of all cells | |
3464 | dropLocation = null; | |
3465 | view.destroyDrag(); // unrender whatever was done in renderDrag | |
3466 | mouseFollower.show(); // show in case we are moving out of all cells | |
3467 | enableCursor(); | |
3468 | }, | |
3469 | dragStop: function(ev) { | |
3470 | // do revert animation if hasn't changed. calls a callback when finished (whether animation or not) | |
3471 | mouseFollower.stop(!dropLocation, function() { | |
3472 | _this.isDraggingSeg = false; | |
3473 | view.destroyDrag(); | |
3474 | view.showEvent(event); | |
3475 | view.trigger('eventDragStop', el[0], event, ev, {}); // last argument is jqui dummy | |
3476 | ||
3477 | if (dropLocation) { | |
3478 | view.reportEventDrop(event, dropLocation, el, ev); | |
3479 | } | |
3480 | }); | |
3481 | enableCursor(); | |
3482 | }, | |
3483 | listenStop: function() { | |
3484 | mouseFollower.stop(); // put in listenStop in case there was a mousedown but the drag never started | |
3485 | } | |
3486 | }); | |
3487 | ||
3488 | dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart | |
3489 | }, | |
3490 | ||
3491 | ||
3492 | // Given the cell an event drag began, and the cell event was dropped, calculates the new start/end/allDay | |
3493 | // values for the event. Subclasses may override and set additional properties to be used by renderDrag. | |
3494 | // A falsy returned value indicates an invalid drop. | |
3495 | computeEventDrop: function(startCell, endCell, event) { | |
3496 | var dragStart = startCell.start; | |
3497 | var dragEnd = endCell.start; | |
3498 | var delta; | |
3499 | var newStart; | |
3500 | var newEnd; | |
3501 | var newAllDay; | |
3502 | var dropLocation; | |
3503 | ||
3504 | if (dragStart.hasTime() === dragEnd.hasTime()) { | |
3505 | delta = diffDayTime(dragEnd, dragStart); | |
3506 | newStart = event.start.clone().add(delta); | |
3507 | if (event.end === null) { // do we need to compute an end? | |
3508 | newEnd = null; | |
3509 | } | |
3510 | else { | |
3511 | newEnd = event.end.clone().add(delta); | |
3512 | } | |
3513 | newAllDay = event.allDay; // keep it the same | |
3514 | } | |
3515 | else { | |
3516 | // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared | |
3517 | newStart = dragEnd.clone(); | |
3518 | newEnd = null; // end should be cleared | |
3519 | newAllDay = !dragEnd.hasTime(); | |
3520 | } | |
3521 | ||
3522 | dropLocation = { | |
3523 | start: newStart, | |
3524 | end: newEnd, | |
3525 | allDay: newAllDay | |
3526 | }; | |
3527 | ||
3528 | if (!this.view.calendar.isEventRangeAllowed(dropLocation, event)) { | |
3529 | return null; | |
3530 | } | |
3531 | ||
3532 | return dropLocation; | |
3533 | }, | |
3534 | ||
3535 | ||
3536 | /* External Element Dragging | |
3537 | ------------------------------------------------------------------------------------------------------------------*/ | |
3538 | ||
3539 | ||
3540 | // Called when a jQuery UI drag is initiated anywhere in the DOM | |
3541 | documentDragStart: function(ev, ui) { | |
3542 | var view = this.view; | |
3543 | var el; | |
3544 | var accept; | |
3545 | ||
3546 | if (view.opt('droppable')) { // only listen if this setting is on | |
3547 | el = $(ev.target); | |
3548 | ||
3549 | // Test that the dragged element passes the dropAccept selector or filter function. | |
3550 | // FYI, the default is "*" (matches all) | |
3551 | accept = view.opt('dropAccept'); | |
3552 | if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) { | |
3553 | ||
3554 | this.startExternalDrag(el, ev, ui); | |
3555 | } | |
3556 | } | |
3557 | }, | |
3558 | ||
3559 | ||
3560 | // Called when a jQuery UI drag starts and it needs to be monitored for cell dropping | |
3561 | startExternalDrag: function(el, ev, ui) { | |
3562 | var _this = this; | |
3563 | var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create | |
3564 | var dragListener; | |
3565 | var dropLocation; // a null value signals an unsuccessful drag | |
3566 | ||
3567 | // listener that tracks mouse movement over date-associated pixel regions | |
3568 | dragListener = new DragListener(this.coordMap, { | |
3569 | cellOver: function(cell) { | |
3570 | dropLocation = _this.computeExternalDrop(cell, meta); | |
3571 | if (dropLocation) { | |
3572 | _this.renderDrag(dropLocation); // called without a seg parameter | |
3573 | } | |
3574 | else { // invalid drop cell | |
3575 | disableCursor(); | |
3576 | } | |
3577 | }, | |
3578 | cellOut: function() { | |
3579 | dropLocation = null; // signal unsuccessful | |
3580 | _this.destroyDrag(); | |
3581 | enableCursor(); | |
3582 | } | |
3583 | }); | |
3584 | ||
3585 | // gets called, only once, when jqui drag is finished | |
3586 | $(document).one('dragstop', function(ev, ui) { | |
3587 | _this.destroyDrag(); | |
3588 | enableCursor(); | |
3589 | ||
3590 | if (dropLocation) { // element was dropped on a valid date/time cell | |
3591 | _this.view.reportExternalDrop(meta, dropLocation, el, ev, ui); | |
3592 | } | |
3593 | }); | |
3594 | ||
3595 | dragListener.startDrag(ev); // start listening immediately | |
3596 | }, | |
3597 | ||
3598 | ||
3599 | // Given a cell to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object), | |
3600 | // returns start/end dates for the event that would result from the hypothetical drop. end might be null. | |
3601 | // Returning a null value signals an invalid drop cell. | |
3602 | computeExternalDrop: function(cell, meta) { | |
3603 | var dropLocation = { | |
3604 | start: cell.start.clone(), | |
3605 | end: null | |
3606 | }; | |
3607 | ||
3608 | // if dropped on an all-day cell, and element's metadata specified a time, set it | |
3609 | if (meta.startTime && !dropLocation.start.hasTime()) { | |
3610 | dropLocation.start.time(meta.startTime); | |
3611 | } | |
3612 | ||
3613 | if (meta.duration) { | |
3614 | dropLocation.end = dropLocation.start.clone().add(meta.duration); | |
3615 | } | |
3616 | ||
3617 | if (!this.view.calendar.isExternalDropRangeAllowed(dropLocation, meta.eventProps)) { | |
3618 | return null; | |
3619 | } | |
3620 | ||
3621 | return dropLocation; | |
3622 | }, | |
3623 | ||
3624 | ||
3625 | ||
3626 | /* Drag Rendering (for both events and an external elements) | |
3627 | ------------------------------------------------------------------------------------------------------------------*/ | |
3628 | ||
3629 | ||
3630 | // Renders a visual indication of an event or external element being dragged. | |
3631 | // `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null. | |
3632 | // `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null. | |
3633 | // A truthy returned value indicates this method has rendered a helper element. | |
3634 | renderDrag: function(dropLocation, seg) { | |
3635 | // subclasses must implement | |
3636 | }, | |
3637 | ||
3638 | ||
3639 | // Unrenders a visual indication of an event or external element being dragged | |
3640 | destroyDrag: function() { | |
3641 | // subclasses must implement | |
3642 | }, | |
3643 | ||
3644 | ||
3645 | /* Resizing | |
3646 | ------------------------------------------------------------------------------------------------------------------*/ | |
3647 | ||
3648 | ||
3649 | // Called when the user does a mousedown on an event's resizer, which might lead to resizing. | |
3650 | // Generic enough to work with any type of Grid. | |
3651 | segResizeMousedown: function(seg, ev) { | |
3652 | var _this = this; | |
3653 | var view = this.view; | |
3654 | var calendar = view.calendar; | |
3655 | var el = seg.el; | |
3656 | var event = seg.event; | |
3657 | var start = event.start; | |
3658 | var oldEnd = calendar.getEventEnd(event); | |
3659 | var newEnd; // falsy if invalid resize | |
3660 | var dragListener; | |
3661 | ||
3662 | function destroy() { // resets the rendering to show the original event | |
3663 | _this.destroyEventResize(); | |
3664 | view.showEvent(event); | |
3665 | enableCursor(); | |
3666 | } | |
3667 | ||
3668 | // Tracks mouse movement over the *grid's* coordinate map | |
3669 | dragListener = new DragListener(this.coordMap, { | |
3670 | distance: 5, | |
3671 | scroll: view.opt('dragScroll'), | |
3672 | dragStart: function(ev) { | |
3673 | _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported | |
3674 | _this.isResizingSeg = true; | |
3675 | view.trigger('eventResizeStart', el[0], event, ev, {}); // last argument is jqui dummy | |
3676 | }, | |
3677 | cellOver: function(cell) { | |
3678 | newEnd = cell.end; | |
3679 | ||
3680 | if (!newEnd.isAfter(start)) { // was end moved before start? | |
3681 | newEnd = start.clone().add( // make the event span a single slot | |
3682 | diffDayTime(cell.end, cell.start) // assumes all slot durations are the same | |
3683 | ); | |
3684 | } | |
3685 | ||
3686 | if (newEnd.isSame(oldEnd)) { | |
3687 | newEnd = null; | |
3688 | } | |
3689 | else if (!calendar.isEventRangeAllowed({ start: start, end: newEnd }, event)) { | |
3690 | newEnd = null; | |
3691 | disableCursor(); | |
3692 | } | |
3693 | else { | |
3694 | _this.renderEventResize({ start: start, end: newEnd }, seg); | |
3695 | view.hideEvent(event); | |
3696 | } | |
3697 | }, | |
3698 | cellOut: function() { // called before mouse moves to a different cell OR moved out of all cells | |
3699 | newEnd = null; | |
3700 | destroy(); | |
3701 | }, | |
3702 | dragStop: function(ev) { | |
3703 | _this.isResizingSeg = false; | |
3704 | destroy(); | |
3705 | view.trigger('eventResizeStop', el[0], event, ev, {}); // last argument is jqui dummy | |
3706 | ||
3707 | if (newEnd) { // valid date to resize to? | |
3708 | view.reportEventResize(event, newEnd, el, ev); | |
3709 | } | |
3710 | } | |
3711 | }); | |
3712 | ||
3713 | dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart | |
3714 | }, | |
3715 | ||
3716 | ||
3717 | // Renders a visual indication of an event being resized. | |
3718 | // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag. | |
3719 | renderEventResize: function(range, seg) { | |
3720 | // subclasses must implement | |
3721 | }, | |
3722 | ||
3723 | ||
3724 | // Unrenders a visual indication of an event being resized. | |
3725 | destroyEventResize: function() { | |
3726 | // subclasses must implement | |
3727 | }, | |
3728 | ||
3729 | ||
3730 | /* Rendering Utils | |
3731 | ------------------------------------------------------------------------------------------------------------------*/ | |
3732 | ||
3733 | ||
3734 | // Compute the text that should be displayed on an event's element. | |
3735 | // `range` can be the Event object itself, or something range-like, with at least a `start`. | |
3736 | // The `timeFormat` options and the grid's default format is used, but `formatStr` can override. | |
3737 | getEventTimeText: function(range, formatStr) { | |
3738 | ||
3739 | formatStr = formatStr || this.eventTimeFormat; | |
3740 | ||
3741 | if (range.end && this.displayEventEnd) { | |
3742 | return this.view.formatRange(range, formatStr); | |
3743 | } | |
3744 | else { | |
3745 | return range.start.format(formatStr); | |
3746 | } | |
3747 | }, | |
3748 | ||
3749 | ||
3750 | // Generic utility for generating the HTML classNames for an event segment's element | |
3751 | getSegClasses: function(seg, isDraggable, isResizable) { | |
3752 | var event = seg.event; | |
3753 | var classes = [ | |
3754 | 'fc-event', | |
3755 | seg.isStart ? 'fc-start' : 'fc-not-start', | |
3756 | seg.isEnd ? 'fc-end' : 'fc-not-end' | |
3757 | ].concat( | |
3758 | event.className, | |
3759 | event.source ? event.source.className : [] | |
3760 | ); | |
3761 | ||
3762 | if (isDraggable) { | |
3763 | classes.push('fc-draggable'); | |
3764 | } | |
3765 | if (isResizable) { | |
3766 | classes.push('fc-resizable'); | |
3767 | } | |
3768 | ||
3769 | return classes; | |
3770 | }, | |
3771 | ||
3772 | ||
3773 | // Utility for generating a CSS string with all the event skin-related properties | |
3774 | getEventSkinCss: function(event) { | |
3775 | var view = this.view; | |
3776 | var source = event.source || {}; | |
3777 | var eventColor = event.color; | |
3778 | var sourceColor = source.color; | |
3779 | var optionColor = view.opt('eventColor'); | |
3780 | var backgroundColor = | |
3781 | event.backgroundColor || | |
3782 | eventColor || | |
3783 | source.backgroundColor || | |
3784 | sourceColor || | |
3785 | view.opt('eventBackgroundColor') || | |
3786 | optionColor; | |
3787 | var borderColor = | |
3788 | event.borderColor || | |
3789 | eventColor || | |
3790 | source.borderColor || | |
3791 | sourceColor || | |
3792 | view.opt('eventBorderColor') || | |
3793 | optionColor; | |
3794 | var textColor = | |
3795 | event.textColor || | |
3796 | source.textColor || | |
3797 | view.opt('eventTextColor'); | |
3798 | var statements = []; | |
3799 | if (backgroundColor) { | |
3800 | statements.push('background-color:' + backgroundColor); | |
3801 | } | |
3802 | if (borderColor) { | |
3803 | statements.push('border-color:' + borderColor); | |
3804 | } | |
3805 | if (textColor) { | |
3806 | statements.push('color:' + textColor); | |
3807 | } | |
3808 | return statements.join(';'); | |
3809 | }, | |
3810 | ||
3811 | ||
3812 | /* Converting events -> ranges -> segs | |
3813 | ------------------------------------------------------------------------------------------------------------------*/ | |
3814 | ||
3815 | ||
3816 | // Converts an array of event objects into an array of event segment objects. | |
3817 | // A custom `rangeToSegsFunc` may be given for arbitrarily slicing up events. | |
3818 | eventsToSegs: function(events, rangeToSegsFunc) { | |
3819 | var eventRanges = this.eventsToRanges(events); | |
3820 | var segs = []; | |
3821 | var i; | |
3822 | ||
3823 | for (i = 0; i < eventRanges.length; i++) { | |
3824 | segs.push.apply( | |
3825 | segs, | |
3826 | this.eventRangeToSegs(eventRanges[i], rangeToSegsFunc) | |
3827 | ); | |
3828 | } | |
3829 | ||
3830 | return segs; | |
3831 | }, | |
3832 | ||
3833 | ||
3834 | // Converts an array of events into an array of "range" objects. | |
3835 | // A "range" object is a plain object with start/end properties denoting the time it covers. Also an event property. | |
3836 | // For "normal" events, this will be identical to the event's start/end, but for "inverse-background" events, | |
3837 | // will create an array of ranges that span the time *not* covered by the given event. | |
3838 | eventsToRanges: function(events) { | |
3839 | var _this = this; | |
3840 | var eventsById = groupEventsById(events); | |
3841 | var ranges = []; | |
3842 | ||
3843 | // group by ID so that related inverse-background events can be rendered together | |
3844 | $.each(eventsById, function(id, eventGroup) { | |
3845 | if (eventGroup.length) { | |
3846 | ranges.push.apply( | |
3847 | ranges, | |
3848 | isInverseBgEvent(eventGroup[0]) ? | |
3849 | _this.eventsToInverseRanges(eventGroup) : | |
3850 | _this.eventsToNormalRanges(eventGroup) | |
3851 | ); | |
3852 | } | |
3853 | }); | |
3854 | ||
3855 | return ranges; | |
3856 | }, | |
3857 | ||
3858 | ||
3859 | // Converts an array of "normal" events (not inverted rendering) into a parallel array of ranges | |
3860 | eventsToNormalRanges: function(events) { | |
3861 | var calendar = this.view.calendar; | |
3862 | var ranges = []; | |
3863 | var i, event; | |
3864 | var eventStart, eventEnd; | |
3865 | ||
3866 | for (i = 0; i < events.length; i++) { | |
3867 | event = events[i]; | |
3868 | ||
3869 | // make copies and normalize by stripping timezone | |
3870 | eventStart = event.start.clone().stripZone(); | |
3871 | eventEnd = calendar.getEventEnd(event).stripZone(); | |
3872 | ||
3873 | ranges.push({ | |
3874 | event: event, | |
3875 | start: eventStart, | |
3876 | end: eventEnd, | |
3877 | eventStartMS: +eventStart, | |
3878 | eventDurationMS: eventEnd - eventStart | |
3879 | }); | |
3880 | } | |
3881 | ||
3882 | return ranges; | |
3883 | }, | |
3884 | ||
3885 | ||
3886 | // Converts an array of events, with inverse-background rendering, into an array of range objects. | |
3887 | // The range objects will cover all the time NOT covered by the events. | |
3888 | eventsToInverseRanges: function(events) { | |
3889 | var view = this.view; | |
3890 | var viewStart = view.start.clone().stripZone(); // normalize timezone | |
3891 | var viewEnd = view.end.clone().stripZone(); // normalize timezone | |
3892 | var normalRanges = this.eventsToNormalRanges(events); // will give us normalized dates we can use w/o copies | |
3893 | var inverseRanges = []; | |
3894 | var event0 = events[0]; // assign this to each range's `.event` | |
3895 | var start = viewStart; // the end of the previous range. the start of the new range | |
3896 | var i, normalRange; | |
3897 | ||
3898 | // ranges need to be in order. required for our date-walking algorithm | |
3899 | normalRanges.sort(compareNormalRanges); | |
3900 | ||
3901 | for (i = 0; i < normalRanges.length; i++) { | |
3902 | normalRange = normalRanges[i]; | |
3903 | ||
3904 | // add the span of time before the event (if there is any) | |
3905 | if (normalRange.start > start) { // compare millisecond time (skip any ambig logic) | |
3906 | inverseRanges.push({ | |
3907 | event: event0, | |
3908 | start: start, | |
3909 | end: normalRange.start | |
3910 | }); | |
3911 | } | |
3912 | ||
3913 | start = normalRange.end; | |
3914 | } | |
3915 | ||
3916 | // add the span of time after the last event (if there is any) | |
3917 | if (start < viewEnd) { // compare millisecond time (skip any ambig logic) | |
3918 | inverseRanges.push({ | |
3919 | event: event0, | |
3920 | start: start, | |
3921 | end: viewEnd | |
3922 | }); | |
3923 | } | |
3924 | ||
3925 | return inverseRanges; | |
3926 | }, | |
3927 | ||
3928 | ||
3929 | // Slices the given event range into one or more segment objects. | |
3930 | // A `rangeToSegsFunc` custom slicing function can be given. | |
3931 | eventRangeToSegs: function(eventRange, rangeToSegsFunc) { | |
3932 | var segs; | |
3933 | var i, seg; | |
3934 | ||
3935 | if (rangeToSegsFunc) { | |
3936 | segs = rangeToSegsFunc(eventRange); | |
3937 | } | |
3938 | else { | |
3939 | segs = this.rangeToSegs(eventRange); // defined by the subclass | |
3940 | } | |
3941 | ||
3942 | for (i = 0; i < segs.length; i++) { | |
3943 | seg = segs[i]; | |
3944 | seg.event = eventRange.event; | |
3945 | seg.eventStartMS = eventRange.eventStartMS; | |
3946 | seg.eventDurationMS = eventRange.eventDurationMS; | |
3947 | } | |
3948 | ||
3949 | return segs; | |
3950 | } | |
3951 | ||
3952 | }); | |
3953 | ||
3954 | ||
3955 | /* Utilities | |
3956 | ----------------------------------------------------------------------------------------------------------------------*/ | |
3957 | ||
3958 | ||
3959 | function isBgEvent(event) { // returns true if background OR inverse-background | |
3960 | var rendering = getEventRendering(event); | |
3961 | return rendering === 'background' || rendering === 'inverse-background'; | |
3962 | } | |
3963 | ||
3964 | ||
3965 | function isInverseBgEvent(event) { | |
3966 | return getEventRendering(event) === 'inverse-background'; | |
3967 | } | |
3968 | ||
3969 | ||
3970 | function getEventRendering(event) { | |
3971 | return firstDefined((event.source || {}).rendering, event.rendering); | |
3972 | } | |
3973 | ||
3974 | ||
3975 | function groupEventsById(events) { | |
3976 | var eventsById = {}; | |
3977 | var i, event; | |
3978 | ||
3979 | for (i = 0; i < events.length; i++) { | |
3980 | event = events[i]; | |
3981 | (eventsById[event._id] || (eventsById[event._id] = [])).push(event); | |
3982 | } | |
3983 | ||
3984 | return eventsById; | |
3985 | } | |
3986 | ||
3987 | ||
3988 | // A cmp function for determining which non-inverted "ranges" (see above) happen earlier | |
3989 | function compareNormalRanges(range1, range2) { | |
3990 | return range1.eventStartMS - range2.eventStartMS; // earlier ranges go first | |
3991 | } | |
3992 | ||
3993 | ||
3994 | // A cmp function for determining which segments should take visual priority | |
3995 | // DOES NOT WORK ON INVERTED BACKGROUND EVENTS because they have no eventStartMS/eventDurationMS | |
3996 | function compareSegs(seg1, seg2) { | |
3997 | return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first | |
3998 | seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first | |
3999 | seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1) | |
4000 | (seg1.event.title || '').localeCompare(seg2.event.title); // tie? alphabetically by title | |
4001 | } | |
4002 | ||
4003 | fc.compareSegs = compareSegs; // export | |
4004 | ||
4005 | ||
4006 | /* External-Dragging-Element Data | |
4007 | ----------------------------------------------------------------------------------------------------------------------*/ | |
4008 | ||
4009 | // Require all HTML5 data-* attributes used by FullCalendar to have this prefix. | |
4010 | // A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event. | |
4011 | fc.dataAttrPrefix = ''; | |
4012 | ||
4013 | // Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure | |
4014 | // to be used for Event Object creation. | |
4015 | // A defined `.eventProps`, even when empty, indicates that an event should be created. | |
4016 | function getDraggedElMeta(el) { | |
4017 | var prefix = fc.dataAttrPrefix; | |
4018 | var eventProps; // properties for creating the event, not related to date/time | |
4019 | var startTime; // a Duration | |
4020 | var duration; | |
4021 | var stick; | |
4022 | ||
4023 | if (prefix) { prefix += '-'; } | |
4024 | eventProps = el.data(prefix + 'event') || null; | |
4025 | ||
4026 | if (eventProps) { | |
4027 | if (typeof eventProps === 'object') { | |
4028 | eventProps = $.extend({}, eventProps); // make a copy | |
4029 | } | |
4030 | else { // something like 1 or true. still signal event creation | |
4031 | eventProps = {}; | |
4032 | } | |
4033 | ||
4034 | // pluck special-cased date/time properties | |
4035 | startTime = eventProps.start; | |
4036 | if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well | |
4037 | duration = eventProps.duration; | |
4038 | stick = eventProps.stick; | |
4039 | delete eventProps.start; | |
4040 | delete eventProps.time; | |
4041 | delete eventProps.duration; | |
4042 | delete eventProps.stick; | |
4043 | } | |
4044 | ||
4045 | // fallback to standalone attribute values for each of the date/time properties | |
4046 | if (startTime == null) { startTime = el.data(prefix + 'start'); } | |
4047 | if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well | |
4048 | if (duration == null) { duration = el.data(prefix + 'duration'); } | |
4049 | if (stick == null) { stick = el.data(prefix + 'stick'); } | |
4050 | ||
4051 | // massage into correct data types | |
4052 | startTime = startTime != null ? moment.duration(startTime) : null; | |
4053 | duration = duration != null ? moment.duration(duration) : null; | |
4054 | stick = Boolean(stick); | |
4055 | ||
4056 | return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick }; | |
4057 | } | |
4058 | ||
4059 | ||
4060 | /* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week. | |
4061 | ----------------------------------------------------------------------------------------------------------------------*/ | |
4062 | ||
4063 | var DayGrid = Grid.extend({ | |
4064 | ||
4065 | numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal | |
4066 | bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid | |
4067 | breakOnWeeks: null, // should create a new row for each week? set by outside view | |
4068 | ||
4069 | cellDates: null, // flat chronological array of each cell's dates | |
4070 | dayToCellOffsets: null, // maps days offsets from grid's start date, to cell offsets | |
4071 | ||
4072 | rowEls: null, // set of fake row elements | |
4073 | dayEls: null, // set of whole-day elements comprising the row's background | |
4074 | helperEls: null, // set of cell skeleton elements for rendering the mock event "helper" | |
4075 | ||
4076 | ||
4077 | // Renders the rows and columns into the component's `this.el`, which should already be assigned. | |
4078 | // isRigid determins whether the individual rows should ignore the contents and be a constant height. | |
4079 | // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient. | |
4080 | render: function(isRigid) { | |
4081 | var view = this.view; | |
4082 | var rowCnt = this.rowCnt; | |
4083 | var colCnt = this.colCnt; | |
4084 | var cellCnt = rowCnt * colCnt; | |
4085 | var html = ''; | |
4086 | var row; | |
4087 | var i, cell; | |
4088 | ||
4089 | for (row = 0; row < rowCnt; row++) { | |
4090 | html += this.dayRowHtml(row, isRigid); | |
4091 | } | |
4092 | this.el.html(html); | |
4093 | ||
4094 | this.rowEls = this.el.find('.fc-row'); | |
4095 | this.dayEls = this.el.find('.fc-day'); | |
4096 | ||
4097 | // trigger dayRender with each cell's element | |
4098 | for (i = 0; i < cellCnt; i++) { | |
4099 | cell = this.getCell(i); | |
4100 | view.trigger('dayRender', null, cell.start, this.dayEls.eq(i)); | |
4101 | } | |
4102 | ||
4103 | Grid.prototype.render.call(this); // call the super-method | |
4104 | }, | |
4105 | ||
4106 | ||
4107 | destroy: function() { | |
4108 | this.destroySegPopover(); | |
4109 | Grid.prototype.destroy.call(this); // call the super-method | |
4110 | }, | |
4111 | ||
4112 | ||
4113 | // Generates the HTML for a single row. `row` is the row number. | |
4114 | dayRowHtml: function(row, isRigid) { | |
4115 | var view = this.view; | |
4116 | var classes = [ 'fc-row', 'fc-week', view.widgetContentClass ]; | |
4117 | ||
4118 | if (isRigid) { | |
4119 | classes.push('fc-rigid'); | |
4120 | } | |
4121 | ||
4122 | return '' + | |
4123 | '<div class="' + classes.join(' ') + '">' + | |
4124 | '<div class="fc-bg">' + | |
4125 | '<table>' + | |
4126 | this.rowHtml('day', row) + // leverages RowRenderer. calls dayCellHtml() | |
4127 | '</table>' + | |
4128 | '</div>' + | |
4129 | '<div class="fc-content-skeleton">' + | |
4130 | '<table>' + | |
4131 | (this.numbersVisible ? | |
4132 | '<thead>' + | |
4133 | this.rowHtml('number', row) + // leverages RowRenderer. View will define render method | |
4134 | '</thead>' : | |
4135 | '' | |
4136 | ) + | |
4137 | '</table>' + | |
4138 | '</div>' + | |
4139 | '</div>'; | |
4140 | }, | |
4141 | ||
4142 | ||
4143 | // Renders the HTML for a whole-day cell. Will eventually end up in the day-row's background. | |
4144 | // We go through a 'day' row type instead of just doing a 'bg' row type so that the View can do custom rendering | |
4145 | // specifically for whole-day rows, whereas a 'bg' might also be used for other purposes (TimeGrid bg for example). | |
4146 | dayCellHtml: function(cell) { | |
4147 | return this.bgCellHtml(cell); | |
4148 | }, | |
4149 | ||
4150 | ||
4151 | /* Options | |
4152 | ------------------------------------------------------------------------------------------------------------------*/ | |
4153 | ||
4154 | ||
4155 | // Computes a default column header formatting string if `colFormat` is not explicitly defined | |
4156 | computeColHeadFormat: function() { | |
4157 | if (this.rowCnt > 1) { // more than one week row. day numbers will be in each cell | |
4158 | return 'ddd'; // "Sat" | |
4159 | } | |
4160 | else if (this.colCnt > 1) { // multiple days, so full single date string WON'T be in title text | |
4161 | return this.view.opt('dayOfMonthFormat'); // "Sat 12/10" | |
4162 | } | |
4163 | else { // single day, so full single date string will probably be in title text | |
4164 | return 'dddd'; // "Saturday" | |
4165 | } | |
4166 | }, | |
4167 | ||
4168 | ||
4169 | // Computes a default event time formatting string if `timeFormat` is not explicitly defined | |
4170 | computeEventTimeFormat: function() { | |
4171 | return this.view.opt('extraSmallTimeFormat'); // like "6p" or "6:30p" | |
4172 | }, | |
4173 | ||
4174 | ||
4175 | // Computes a default `displayEventEnd` value if one is not expliclty defined | |
4176 | computeDisplayEventEnd: function() { | |
4177 | return this.colCnt == 1; // we'll likely have space if there's only one day | |
4178 | }, | |
4179 | ||
4180 | ||
4181 | /* Cell System | |
4182 | ------------------------------------------------------------------------------------------------------------------*/ | |
4183 | ||
4184 | ||
4185 | // Initializes row/col information | |
4186 | updateCells: function() { | |
4187 | var cellDates; | |
4188 | var firstDay; | |
4189 | var rowCnt; | |
4190 | var colCnt; | |
4191 | ||
4192 | this.updateCellDates(); // populates cellDates and dayToCellOffsets | |
4193 | cellDates = this.cellDates; | |
4194 | ||
4195 | if (this.breakOnWeeks) { | |
4196 | // count columns until the day-of-week repeats | |
4197 | firstDay = cellDates[0].day(); | |
4198 | for (colCnt = 1; colCnt < cellDates.length; colCnt++) { | |
4199 | if (cellDates[colCnt].day() == firstDay) { | |
4200 | break; | |
4201 | } | |
4202 | } | |
4203 | rowCnt = Math.ceil(cellDates.length / colCnt); | |
4204 | } | |
4205 | else { | |
4206 | rowCnt = 1; | |
4207 | colCnt = cellDates.length; | |
4208 | } | |
4209 | ||
4210 | this.rowCnt = rowCnt; | |
4211 | this.colCnt = colCnt; | |
4212 | }, | |
4213 | ||
4214 | ||
4215 | // Populates cellDates and dayToCellOffsets | |
4216 | updateCellDates: function() { | |
4217 | var view = this.view; | |
4218 | var date = this.start.clone(); | |
4219 | var dates = []; | |
4220 | var offset = -1; | |
4221 | var offsets = []; | |
4222 | ||
4223 | while (date.isBefore(this.end)) { // loop each day from start to end | |
4224 | if (view.isHiddenDay(date)) { | |
4225 | offsets.push(offset + 0.5); // mark that it's between offsets | |
4226 | } | |
4227 | else { | |
4228 | offset++; | |
4229 | offsets.push(offset); | |
4230 | dates.push(date.clone()); | |
4231 | } | |
4232 | date.add(1, 'days'); | |
4233 | } | |
4234 | ||
4235 | this.cellDates = dates; | |
4236 | this.dayToCellOffsets = offsets; | |
4237 | }, | |
4238 | ||
4239 | ||
4240 | // Given a cell object, generates a range object | |
4241 | computeCellRange: function(cell) { | |
4242 | var colCnt = this.colCnt; | |
4243 | var index = cell.row * colCnt + (this.isRTL ? colCnt - cell.col - 1 : cell.col); | |
4244 | var start = this.cellDates[index].clone(); | |
4245 | var end = start.clone().add(1, 'day'); | |
4246 | ||
4247 | return { start: start, end: end }; | |
4248 | }, | |
4249 | ||
4250 | ||
4251 | // Retrieves the element representing the given row | |
4252 | getRowEl: function(row) { | |
4253 | return this.rowEls.eq(row); | |
4254 | }, | |
4255 | ||
4256 | ||
4257 | // Retrieves the element representing the given column | |
4258 | getColEl: function(col) { | |
4259 | return this.dayEls.eq(col); | |
4260 | }, | |
4261 | ||
4262 | ||
4263 | // Gets the whole-day element associated with the cell | |
4264 | getCellDayEl: function(cell) { | |
4265 | return this.dayEls.eq(cell.row * this.colCnt + cell.col); | |
4266 | }, | |
4267 | ||
4268 | ||
4269 | // Overrides Grid's method for when row coordinates are computed | |
4270 | computeRowCoords: function() { | |
4271 | var rowCoords = Grid.prototype.computeRowCoords.call(this); // call the super-method | |
4272 | ||
4273 | // hack for extending last row (used by AgendaView) | |
4274 | rowCoords[rowCoords.length - 1].bottom += this.bottomCoordPadding; | |
4275 | ||
4276 | return rowCoords; | |
4277 | }, | |
4278 | ||
4279 | ||
4280 | /* Dates | |
4281 | ------------------------------------------------------------------------------------------------------------------*/ | |
4282 | ||
4283 | ||
4284 | // Slices up a date range by row into an array of segments | |
4285 | rangeToSegs: function(range) { | |
4286 | var isRTL = this.isRTL; | |
4287 | var rowCnt = this.rowCnt; | |
4288 | var colCnt = this.colCnt; | |
4289 | var segs = []; | |
4290 | var first, last; // inclusive cell-offset range for given range | |
4291 | var row; | |
4292 | var rowFirst, rowLast; // inclusive cell-offset range for current row | |
4293 | var isStart, isEnd; | |
4294 | var segFirst, segLast; // inclusive cell-offset range for segment | |
4295 | var seg; | |
4296 | ||
4297 | range = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold | |
4298 | first = this.dateToCellOffset(range.start); | |
4299 | last = this.dateToCellOffset(range.end.subtract(1, 'days')); // offset of inclusive end date | |
4300 | ||
4301 | for (row = 0; row < rowCnt; row++) { | |
4302 | rowFirst = row * colCnt; | |
4303 | rowLast = rowFirst + colCnt - 1; | |
4304 | ||
4305 | // intersect segment's offset range with the row's | |
4306 | segFirst = Math.max(rowFirst, first); | |
4307 | segLast = Math.min(rowLast, last); | |
4308 | ||
4309 | // deal with in-between indices | |
4310 | segFirst = Math.ceil(segFirst); // in-between starts round to next cell | |
4311 | segLast = Math.floor(segLast); // in-between ends round to prev cell | |
4312 | ||
4313 | if (segFirst <= segLast) { // was there any intersection with the current row? | |
4314 | ||
4315 | // must be matching integers to be the segment's start/end | |
4316 | isStart = segFirst === first; | |
4317 | isEnd = segLast === last; | |
4318 | ||
4319 | // translate offsets to be relative to start-of-row | |
4320 | segFirst -= rowFirst; | |
4321 | segLast -= rowFirst; | |
4322 | ||
4323 | seg = { row: row, isStart: isStart, isEnd: isEnd }; | |
4324 | if (isRTL) { | |
4325 | seg.leftCol = colCnt - segLast - 1; | |
4326 | seg.rightCol = colCnt - segFirst - 1; | |
4327 | } | |
4328 | else { | |
4329 | seg.leftCol = segFirst; | |
4330 | seg.rightCol = segLast; | |
4331 | } | |
4332 | segs.push(seg); | |
4333 | } | |
4334 | } | |
4335 | ||
4336 | return segs; | |
4337 | }, | |
4338 | ||
4339 | ||
4340 | // Given a date, returns its chronolocial cell-offset from the first cell of the grid. | |
4341 | // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets. | |
4342 | // If before the first offset, returns a negative number. | |
4343 | // If after the last offset, returns an offset past the last cell offset. | |
4344 | // Only works for *start* dates of cells. Will not work for exclusive end dates for cells. | |
4345 | dateToCellOffset: function(date) { | |
4346 | var offsets = this.dayToCellOffsets; | |
4347 | var day = date.diff(this.start, 'days'); | |
4348 | ||
4349 | if (day < 0) { | |
4350 | return offsets[0] - 1; | |
4351 | } | |
4352 | else if (day >= offsets.length) { | |
4353 | return offsets[offsets.length - 1] + 1; | |
4354 | } | |
4355 | else { | |
4356 | return offsets[day]; | |
4357 | } | |
4358 | }, | |
4359 | ||
4360 | ||
4361 | /* Event Drag Visualization | |
4362 | ------------------------------------------------------------------------------------------------------------------*/ | |
4363 | // TODO: move to DayGrid.event, similar to what we did with Grid's drag methods | |
4364 | ||
4365 | ||
4366 | // Renders a visual indication of an event or external element being dragged. | |
4367 | // The dropLocation's end can be null. seg can be null. See Grid::renderDrag for more info. | |
4368 | renderDrag: function(dropLocation, seg) { | |
4369 | var opacity; | |
4370 | ||
4371 | // always render a highlight underneath | |
4372 | this.renderHighlight( | |
4373 | this.view.calendar.ensureVisibleEventRange(dropLocation) // needs to be a proper range | |
4374 | ); | |
4375 | ||
4376 | // if a segment from the same calendar but another component is being dragged, render a helper event | |
4377 | if (seg && !seg.el.closest(this.el).length) { | |
4378 | ||
4379 | this.renderRangeHelper(dropLocation, seg); | |
4380 | ||
4381 | opacity = this.view.opt('dragOpacity'); | |
4382 | if (opacity !== undefined) { | |
4383 | this.helperEls.css('opacity', opacity); | |
4384 | } | |
4385 | ||
4386 | return true; // a helper has been rendered | |
4387 | } | |
4388 | }, | |
4389 | ||
4390 | ||
4391 | // Unrenders any visual indication of a hovering event | |
4392 | destroyDrag: function() { | |
4393 | this.destroyHighlight(); | |
4394 | this.destroyHelper(); | |
4395 | }, | |
4396 | ||
4397 | ||
4398 | /* Event Resize Visualization | |
4399 | ------------------------------------------------------------------------------------------------------------------*/ | |
4400 | ||
4401 | ||
4402 | // Renders a visual indication of an event being resized | |
4403 | renderEventResize: function(range, seg) { | |
4404 | this.renderHighlight(range); | |
4405 | this.renderRangeHelper(range, seg); | |
4406 | }, | |
4407 | ||
4408 | ||
4409 | // Unrenders a visual indication of an event being resized | |
4410 | destroyEventResize: function() { | |
4411 | this.destroyHighlight(); | |
4412 | this.destroyHelper(); | |
4413 | }, | |
4414 | ||
4415 | ||
4416 | /* Event Helper | |
4417 | ------------------------------------------------------------------------------------------------------------------*/ | |
4418 | ||
4419 | ||
4420 | // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null. | |
4421 | renderHelper: function(event, sourceSeg) { | |
4422 | var helperNodes = []; | |
4423 | var segs = this.eventsToSegs([ event ]); | |
4424 | var rowStructs; | |
4425 | ||
4426 | segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered | |
4427 | rowStructs = this.renderSegRows(segs); | |
4428 | ||
4429 | // inject each new event skeleton into each associated row | |
4430 | this.rowEls.each(function(row, rowNode) { | |
4431 | var rowEl = $(rowNode); // the .fc-row | |
4432 | var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned | |
4433 | var skeletonTop; | |
4434 | ||
4435 | // If there is an original segment, match the top position. Otherwise, put it at the row's top level | |
4436 | if (sourceSeg && sourceSeg.row === row) { | |
4437 | skeletonTop = sourceSeg.el.position().top; | |
4438 | } | |
4439 | else { | |
4440 | skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top; | |
4441 | } | |
4442 | ||
4443 | skeletonEl.css('top', skeletonTop) | |
4444 | .find('table') | |
4445 | .append(rowStructs[row].tbodyEl); | |
4446 | ||
4447 | rowEl.append(skeletonEl); | |
4448 | helperNodes.push(skeletonEl[0]); | |
4449 | }); | |
4450 | ||
4451 | this.helperEls = $(helperNodes); // array -> jQuery set | |
4452 | }, | |
4453 | ||
4454 | ||
4455 | // Unrenders any visual indication of a mock helper event | |
4456 | destroyHelper: function() { | |
4457 | if (this.helperEls) { | |
4458 | this.helperEls.remove(); | |
4459 | this.helperEls = null; | |
4460 | } | |
4461 | }, | |
4462 | ||
4463 | ||
4464 | /* Fill System (highlight, background events, business hours) | |
4465 | ------------------------------------------------------------------------------------------------------------------*/ | |
4466 | ||
4467 | ||
4468 | fillSegTag: 'td', // override the default tag name | |
4469 | ||
4470 | ||
4471 | // Renders a set of rectangles over the given segments of days. | |
4472 | // Only returns segments that successfully rendered. | |
4473 | renderFill: function(type, segs) { | |
4474 | var nodes = []; | |
4475 | var i, seg; | |
4476 | var skeletonEl; | |
4477 | ||
4478 | segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs | |
4479 | ||
4480 | for (i = 0; i < segs.length; i++) { | |
4481 | seg = segs[i]; | |
4482 | skeletonEl = this.renderFillRow(type, seg); | |
4483 | this.rowEls.eq(seg.row).append(skeletonEl); | |
4484 | nodes.push(skeletonEl[0]); | |
4485 | } | |
4486 | ||
4487 | this.elsByFill[type] = $(nodes); | |
4488 | ||
4489 | return segs; | |
4490 | }, | |
4491 | ||
4492 | ||
4493 | // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered. | |
4494 | renderFillRow: function(type, seg) { | |
4495 | var colCnt = this.colCnt; | |
4496 | var startCol = seg.leftCol; | |
4497 | var endCol = seg.rightCol + 1; | |
4498 | var skeletonEl; | |
4499 | var trEl; | |
4500 | ||
4501 | skeletonEl = $( | |
4502 | '<div class="fc-' + type.toLowerCase() + '-skeleton">' + | |
4503 | '<table><tr/></table>' + | |
4504 | '</div>' | |
4505 | ); | |
4506 | trEl = skeletonEl.find('tr'); | |
4507 | ||
4508 | if (startCol > 0) { | |
4509 | trEl.append('<td colspan="' + startCol + '"/>'); | |
4510 | } | |
4511 | ||
4512 | trEl.append( | |
4513 | seg.el.attr('colspan', endCol - startCol) | |
4514 | ); | |
4515 | ||
4516 | if (endCol < colCnt) { | |
4517 | trEl.append('<td colspan="' + (colCnt - endCol) + '"/>'); | |
4518 | } | |
4519 | ||
4520 | this.bookendCells(trEl, type); | |
4521 | ||
4522 | return skeletonEl; | |
4523 | } | |
4524 | ||
4525 | }); | |
4526 | ||
4527 | /* Event-rendering methods for the DayGrid class | |
4528 | ----------------------------------------------------------------------------------------------------------------------*/ | |
4529 | ||
4530 | DayGrid.mixin({ | |
4531 | ||
4532 | rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering | |
4533 | ||
4534 | ||
4535 | // Unrenders all events currently rendered on the grid | |
4536 | destroyEvents: function() { | |
4537 | this.destroySegPopover(); // removes the "more.." events popover | |
4538 | Grid.prototype.destroyEvents.apply(this, arguments); // calls the super-method | |
4539 | }, | |
4540 | ||
4541 | ||
4542 | // Retrieves all rendered segment objects currently rendered on the grid | |
4543 | getEventSegs: function() { | |
4544 | return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method | |
4545 | .concat(this.popoverSegs || []); // append the segments from the "more..." popover | |
4546 | }, | |
4547 | ||
4548 | ||
4549 | // Renders the given background event segments onto the grid | |
4550 | renderBgSegs: function(segs) { | |
4551 | ||
4552 | // don't render timed background events | |
4553 | var allDaySegs = $.grep(segs, function(seg) { | |
4554 | return seg.event.allDay; | |
4555 | }); | |
4556 | ||
4557 | return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method | |
4558 | }, | |
4559 | ||
4560 | ||
4561 | // Renders the given foreground event segments onto the grid | |
4562 | renderFgSegs: function(segs) { | |
4563 | var rowStructs; | |
4564 | ||
4565 | // render an `.el` on each seg | |
4566 | // returns a subset of the segs. segs that were actually rendered | |
4567 | segs = this.renderFgSegEls(segs); | |
4568 | ||
4569 | rowStructs = this.rowStructs = this.renderSegRows(segs); | |
4570 | ||
4571 | // append to each row's content skeleton | |
4572 | this.rowEls.each(function(i, rowNode) { | |
4573 | $(rowNode).find('.fc-content-skeleton > table').append( | |
4574 | rowStructs[i].tbodyEl | |
4575 | ); | |
4576 | }); | |
4577 | ||
4578 | return segs; // return only the segs that were actually rendered | |
4579 | }, | |
4580 | ||
4581 | ||
4582 | // Unrenders all currently rendered foreground event segments | |
4583 | destroyFgSegs: function() { | |
4584 | var rowStructs = this.rowStructs || []; | |
4585 | var rowStruct; | |
4586 | ||
4587 | while ((rowStruct = rowStructs.pop())) { | |
4588 | rowStruct.tbodyEl.remove(); | |
4589 | } | |
4590 | ||
4591 | this.rowStructs = null; | |
4592 | }, | |
4593 | ||
4594 | ||
4595 | // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton. | |
4596 | // Returns an array of rowStruct objects (see the bottom of `renderSegRow`). | |
4597 | // PRECONDITION: each segment shoud already have a rendered and assigned `.el` | |
4598 | renderSegRows: function(segs) { | |
4599 | var rowStructs = []; | |
4600 | var segRows; | |
4601 | var row; | |
4602 | ||
4603 | segRows = this.groupSegRows(segs); // group into nested arrays | |
4604 | ||
4605 | // iterate each row of segment groupings | |
4606 | for (row = 0; row < segRows.length; row++) { | |
4607 | rowStructs.push( | |
4608 | this.renderSegRow(row, segRows[row]) | |
4609 | ); | |
4610 | } | |
4611 | ||
4612 | return rowStructs; | |
4613 | }, | |
4614 | ||
4615 | ||
4616 | // Builds the HTML to be used for the default element for an individual segment | |
4617 | fgSegHtml: function(seg, disableResizing) { | |
4618 | var view = this.view; | |
4619 | var event = seg.event; | |
4620 | var isDraggable = view.isEventDraggable(event); | |
4621 | var isResizable = !disableResizing && event.allDay && seg.isEnd && view.isEventResizable(event); | |
4622 | var classes = this.getSegClasses(seg, isDraggable, isResizable); | |
4623 | var skinCss = this.getEventSkinCss(event); | |
4624 | var timeHtml = ''; | |
4625 | var titleHtml; | |
4626 | ||
4627 | classes.unshift('fc-day-grid-event'); | |
4628 | ||
4629 | // Only display a timed events time if it is the starting segment | |
4630 | if (!event.allDay && seg.isStart) { | |
4631 | timeHtml = '<span class="fc-time">' + htmlEscape(this.getEventTimeText(event)) + '</span>'; | |
4632 | } | |
4633 | ||
4634 | titleHtml = | |
4635 | '<span class="fc-title">' + | |
4636 | (htmlEscape(event.title || '') || ' ') + // we always want one line of height | |
4637 | '</span>'; | |
4638 | ||
4639 | return '<a class="' + classes.join(' ') + '"' + | |
4640 | (event.url ? | |
4641 | ' href="' + htmlEscape(event.url) + '"' : | |
4642 | '' | |
4643 | ) + | |
4644 | (skinCss ? | |
4645 | ' style="' + skinCss + '"' : | |
4646 | '' | |
4647 | ) + | |
4648 | '>' + | |
4649 | '<div class="fc-content">' + | |
4650 | (this.isRTL ? | |
4651 | titleHtml + ' ' + timeHtml : // put a natural space in between | |
4652 | timeHtml + ' ' + titleHtml // | |
4653 | ) + | |
4654 | '</div>' + | |
4655 | (isResizable ? | |
4656 | '<div class="fc-resizer"/>' : | |
4657 | '' | |
4658 | ) + | |
4659 | '</a>'; | |
4660 | }, | |
4661 | ||
4662 | ||
4663 | // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains | |
4664 | // the segments. Returns object with a bunch of internal data about how the render was calculated. | |
4665 | renderSegRow: function(row, rowSegs) { | |
4666 | var colCnt = this.colCnt; | |
4667 | var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels | |
4668 | var levelCnt = Math.max(1, segLevels.length); // ensure at least one level | |
4669 | var tbody = $('<tbody/>'); | |
4670 | var segMatrix = []; // lookup for which segments are rendered into which level+col cells | |
4671 | var cellMatrix = []; // lookup for all <td> elements of the level+col matrix | |
4672 | var loneCellMatrix = []; // lookup for <td> elements that only take up a single column | |
4673 | var i, levelSegs; | |
4674 | var col; | |
4675 | var tr; | |
4676 | var j, seg; | |
4677 | var td; | |
4678 | ||
4679 | // populates empty cells from the current column (`col`) to `endCol` | |
4680 | function emptyCellsUntil(endCol) { | |
4681 | while (col < endCol) { | |
4682 | // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell | |
4683 | td = (loneCellMatrix[i - 1] || [])[col]; | |
4684 | if (td) { | |
4685 | td.attr( | |
4686 | 'rowspan', | |
4687 | parseInt(td.attr('rowspan') || 1, 10) + 1 | |
4688 | ); | |
4689 | } | |
4690 | else { | |
4691 | td = $('<td/>'); | |
4692 | tr.append(td); | |
4693 | } | |
4694 | cellMatrix[i][col] = td; | |
4695 | loneCellMatrix[i][col] = td; | |
4696 | col++; | |
4697 | } | |
4698 | } | |
4699 | ||
4700 | for (i = 0; i < levelCnt; i++) { // iterate through all levels | |
4701 | levelSegs = segLevels[i]; | |
4702 | col = 0; | |
4703 | tr = $('<tr/>'); | |
4704 | ||
4705 | segMatrix.push([]); | |
4706 | cellMatrix.push([]); | |
4707 | loneCellMatrix.push([]); | |
4708 | ||
4709 | // levelCnt might be 1 even though there are no actual levels. protect against this. | |
4710 | // this single empty row is useful for styling. | |
4711 | if (levelSegs) { | |
4712 | for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level | |
4713 | seg = levelSegs[j]; | |
4714 | ||
4715 | emptyCellsUntil(seg.leftCol); | |
4716 | ||
4717 | // create a container that occupies or more columns. append the event element. | |
4718 | td = $('<td class="fc-event-container"/>').append(seg.el); | |
4719 | if (seg.leftCol != seg.rightCol) { | |
4720 | td.attr('colspan', seg.rightCol - seg.leftCol + 1); | |
4721 | } | |
4722 | else { // a single-column segment | |
4723 | loneCellMatrix[i][col] = td; | |
4724 | } | |
4725 | ||
4726 | while (col <= seg.rightCol) { | |
4727 | cellMatrix[i][col] = td; | |
4728 | segMatrix[i][col] = seg; | |
4729 | col++; | |
4730 | } | |
4731 | ||
4732 | tr.append(td); | |
4733 | } | |
4734 | } | |
4735 | ||
4736 | emptyCellsUntil(colCnt); // finish off the row | |
4737 | this.bookendCells(tr, 'eventSkeleton'); | |
4738 | tbody.append(tr); | |
4739 | } | |
4740 | ||
4741 | return { // a "rowStruct" | |
4742 | row: row, // the row number | |
4743 | tbodyEl: tbody, | |
4744 | cellMatrix: cellMatrix, | |
4745 | segMatrix: segMatrix, | |
4746 | segLevels: segLevels, | |
4747 | segs: rowSegs | |
4748 | }; | |
4749 | }, | |
4750 | ||
4751 | ||
4752 | // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels. | |
4753 | buildSegLevels: function(segs) { | |
4754 | var levels = []; | |
4755 | var i, seg; | |
4756 | var j; | |
4757 | ||
4758 | // Give preference to elements with certain criteria, so they have | |
4759 | // a chance to be closer to the top. | |
4760 | segs.sort(compareSegs); | |
4761 | ||
4762 | for (i = 0; i < segs.length; i++) { | |
4763 | seg = segs[i]; | |
4764 | ||
4765 | // loop through levels, starting with the topmost, until the segment doesn't collide with other segments | |
4766 | for (j = 0; j < levels.length; j++) { | |
4767 | if (!isDaySegCollision(seg, levels[j])) { | |
4768 | break; | |
4769 | } | |
4770 | } | |
4771 | // `j` now holds the desired subrow index | |
4772 | seg.level = j; | |
4773 | ||
4774 | // create new level array if needed and append segment | |
4775 | (levels[j] || (levels[j] = [])).push(seg); | |
4776 | } | |
4777 | ||
4778 | // order segments left-to-right. very important if calendar is RTL | |
4779 | for (j = 0; j < levels.length; j++) { | |
4780 | levels[j].sort(compareDaySegCols); | |
4781 | } | |
4782 | ||
4783 | return levels; | |
4784 | }, | |
4785 | ||
4786 | ||
4787 | // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row | |
4788 | groupSegRows: function(segs) { | |
4789 | var segRows = []; | |
4790 | var i; | |
4791 | ||
4792 | for (i = 0; i < this.rowCnt; i++) { | |
4793 | segRows.push([]); | |
4794 | } | |
4795 | ||
4796 | for (i = 0; i < segs.length; i++) { | |
4797 | segRows[segs[i].row].push(segs[i]); | |
4798 | } | |
4799 | ||
4800 | return segRows; | |
4801 | } | |
4802 | ||
4803 | }); | |
4804 | ||
4805 | ||
4806 | // Computes whether two segments' columns collide. They are assumed to be in the same row. | |
4807 | function isDaySegCollision(seg, otherSegs) { | |
4808 | var i, otherSeg; | |
4809 | ||
4810 | for (i = 0; i < otherSegs.length; i++) { | |
4811 | otherSeg = otherSegs[i]; | |
4812 | ||
4813 | if ( | |
4814 | otherSeg.leftCol <= seg.rightCol && | |
4815 | otherSeg.rightCol >= seg.leftCol | |
4816 | ) { | |
4817 | return true; | |
4818 | } | |
4819 | } | |
4820 | ||
4821 | return false; | |
4822 | } | |
4823 | ||
4824 | ||
4825 | // A cmp function for determining the leftmost event | |
4826 | function compareDaySegCols(a, b) { | |
4827 | return a.leftCol - b.leftCol; | |
4828 | } | |
4829 | ||
4830 | /* Methods relate to limiting the number events for a given day on a DayGrid | |
4831 | ----------------------------------------------------------------------------------------------------------------------*/ | |
4832 | // NOTE: all the segs being passed around in here are foreground segs | |
4833 | ||
4834 | DayGrid.mixin({ | |
4835 | ||
4836 | segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible | |
4837 | popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible | |
4838 | ||
4839 | ||
4840 | destroySegPopover: function() { | |
4841 | if (this.segPopover) { | |
4842 | this.segPopover.hide(); // will trigger destruction of `segPopover` and `popoverSegs` | |
4843 | } | |
4844 | }, | |
4845 | ||
4846 | ||
4847 | // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid. | |
4848 | // `levelLimit` can be false (don't limit), a number, or true (should be computed). | |
4849 | limitRows: function(levelLimit) { | |
4850 | var rowStructs = this.rowStructs || []; | |
4851 | var row; // row # | |
4852 | var rowLevelLimit; | |
4853 | ||
4854 | for (row = 0; row < rowStructs.length; row++) { | |
4855 | this.unlimitRow(row); | |
4856 | ||
4857 | if (!levelLimit) { | |
4858 | rowLevelLimit = false; | |
4859 | } | |
4860 | else if (typeof levelLimit === 'number') { | |
4861 | rowLevelLimit = levelLimit; | |
4862 | } | |
4863 | else { | |
4864 | rowLevelLimit = this.computeRowLevelLimit(row); | |
4865 | } | |
4866 | ||
4867 | if (rowLevelLimit !== false) { | |
4868 | this.limitRow(row, rowLevelLimit); | |
4869 | } | |
4870 | } | |
4871 | }, | |
4872 | ||
4873 | ||
4874 | // Computes the number of levels a row will accomodate without going outside its bounds. | |
4875 | // Assumes the row is "rigid" (maintains a constant height regardless of what is inside). | |
4876 | // `row` is the row number. | |
4877 | computeRowLevelLimit: function(row) { | |
4878 | var rowEl = this.rowEls.eq(row); // the containing "fake" row div | |
4879 | var rowHeight = rowEl.height(); // TODO: cache somehow? | |
4880 | var trEls = this.rowStructs[row].tbodyEl.children(); | |
4881 | var i, trEl; | |
4882 | ||
4883 | // Reveal one level <tr> at a time and stop when we find one out of bounds | |
4884 | for (i = 0; i < trEls.length; i++) { | |
4885 | trEl = trEls.eq(i).removeClass('fc-limited'); // get and reveal | |
4886 | if (trEl.position().top + trEl.outerHeight() > rowHeight) { | |
4887 | return i; | |
4888 | } | |
4889 | } | |
4890 | ||
4891 | return false; // should not limit at all | |
4892 | }, | |
4893 | ||
4894 | ||
4895 | // Limits the given grid row to the maximum number of levels and injects "more" links if necessary. | |
4896 | // `row` is the row number. | |
4897 | // `levelLimit` is a number for the maximum (inclusive) number of levels allowed. | |
4898 | limitRow: function(row, levelLimit) { | |
4899 | var _this = this; | |
4900 | var rowStruct = this.rowStructs[row]; | |
4901 | var moreNodes = []; // array of "more" <a> links and <td> DOM nodes | |
4902 | var col = 0; // col #, left-to-right (not chronologically) | |
4903 | var cell; | |
4904 | var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right | |
4905 | var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row | |
4906 | var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes | |
4907 | var i, seg; | |
4908 | var segsBelow; // array of segment objects below `seg` in the current `col` | |
4909 | var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies | |
4910 | var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column) | |
4911 | var td, rowspan; | |
4912 | var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell | |
4913 | var j; | |
4914 | var moreTd, moreWrap, moreLink; | |
4915 | ||
4916 | // Iterates through empty level cells and places "more" links inside if need be | |
4917 | function emptyCellsUntil(endCol) { // goes from current `col` to `endCol` | |
4918 | while (col < endCol) { | |
4919 | cell = _this.getCell(row, col); | |
4920 | segsBelow = _this.getCellSegs(cell, levelLimit); | |
4921 | if (segsBelow.length) { | |
4922 | td = cellMatrix[levelLimit - 1][col]; | |
4923 | moreLink = _this.renderMoreLink(cell, segsBelow); | |
4924 | moreWrap = $('<div/>').append(moreLink); | |
4925 | td.append(moreWrap); | |
4926 | moreNodes.push(moreWrap[0]); | |
4927 | } | |
4928 | col++; | |
4929 | } | |
4930 | } | |
4931 | ||
4932 | if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit? | |
4933 | levelSegs = rowStruct.segLevels[levelLimit - 1]; | |
4934 | cellMatrix = rowStruct.cellMatrix; | |
4935 | ||
4936 | limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit | |
4937 | .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array | |
4938 | ||
4939 | // iterate though segments in the last allowable level | |
4940 | for (i = 0; i < levelSegs.length; i++) { | |
4941 | seg = levelSegs[i]; | |
4942 | emptyCellsUntil(seg.leftCol); // process empty cells before the segment | |
4943 | ||
4944 | // determine *all* segments below `seg` that occupy the same columns | |
4945 | colSegsBelow = []; | |
4946 | totalSegsBelow = 0; | |
4947 | while (col <= seg.rightCol) { | |
4948 | cell = this.getCell(row, col); | |
4949 | segsBelow = this.getCellSegs(cell, levelLimit); | |
4950 | colSegsBelow.push(segsBelow); | |
4951 | totalSegsBelow += segsBelow.length; | |
4952 | col++; | |
4953 | } | |
4954 | ||
4955 | if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links? | |
4956 | td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell | |
4957 | rowspan = td.attr('rowspan') || 1; | |
4958 | segMoreNodes = []; | |
4959 | ||
4960 | // make a replacement <td> for each column the segment occupies. will be one for each colspan | |
4961 | for (j = 0; j < colSegsBelow.length; j++) { | |
4962 | moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan); | |
4963 | segsBelow = colSegsBelow[j]; | |
4964 | cell = this.getCell(row, seg.leftCol + j); | |
4965 | moreLink = this.renderMoreLink(cell, [ seg ].concat(segsBelow)); // count seg as hidden too | |
4966 | moreWrap = $('<div/>').append(moreLink); | |
4967 | moreTd.append(moreWrap); | |
4968 | segMoreNodes.push(moreTd[0]); | |
4969 | moreNodes.push(moreTd[0]); | |
4970 | } | |
4971 | ||
4972 | td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements | |
4973 | limitedNodes.push(td[0]); | |
4974 | } | |
4975 | } | |
4976 | ||
4977 | emptyCellsUntil(this.colCnt); // finish off the level | |
4978 | rowStruct.moreEls = $(moreNodes); // for easy undoing later | |
4979 | rowStruct.limitedEls = $(limitedNodes); // for easy undoing later | |
4980 | } | |
4981 | }, | |
4982 | ||
4983 | ||
4984 | // Reveals all levels and removes all "more"-related elements for a grid's row. | |
4985 | // `row` is a row number. | |
4986 | unlimitRow: function(row) { | |
4987 | var rowStruct = this.rowStructs[row]; | |
4988 | ||
4989 | if (rowStruct.moreEls) { | |
4990 | rowStruct.moreEls.remove(); | |
4991 | rowStruct.moreEls = null; | |
4992 | } | |
4993 | ||
4994 | if (rowStruct.limitedEls) { | |
4995 | rowStruct.limitedEls.removeClass('fc-limited'); | |
4996 | rowStruct.limitedEls = null; | |
4997 | } | |
4998 | }, | |
4999 | ||
5000 | ||
5001 | // Renders an <a> element that represents hidden event element for a cell. | |
5002 | // Responsible for attaching click handler as well. | |
5003 | renderMoreLink: function(cell, hiddenSegs) { | |
5004 | var _this = this; | |
5005 | var view = this.view; | |
5006 | ||
5007 | return $('<a class="fc-more"/>') | |
5008 | .text( | |
5009 | this.getMoreLinkText(hiddenSegs.length) | |
5010 | ) | |
5011 | .on('click', function(ev) { | |
5012 | var clickOption = view.opt('eventLimitClick'); | |
5013 | var date = cell.start; | |
5014 | var moreEl = $(this); | |
5015 | var dayEl = _this.getCellDayEl(cell); | |
5016 | var allSegs = _this.getCellSegs(cell); | |
5017 | ||
5018 | // rescope the segments to be within the cell's date | |
5019 | var reslicedAllSegs = _this.resliceDaySegs(allSegs, date); | |
5020 | var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date); | |
5021 | ||
5022 | if (typeof clickOption === 'function') { | |
5023 | // the returned value can be an atomic option | |
5024 | clickOption = view.trigger('eventLimitClick', null, { | |
5025 | date: date, | |
5026 | dayEl: dayEl, | |
5027 | moreEl: moreEl, | |
5028 | segs: reslicedAllSegs, | |
5029 | hiddenSegs: reslicedHiddenSegs | |
5030 | }, ev); | |
5031 | } | |
5032 | ||
5033 | if (clickOption === 'popover') { | |
5034 | _this.showSegPopover(cell, moreEl, reslicedAllSegs); | |
5035 | } | |
5036 | else if (typeof clickOption === 'string') { // a view name | |
5037 | view.calendar.zoomTo(date, clickOption); | |
5038 | } | |
5039 | }); | |
5040 | }, | |
5041 | ||
5042 | ||
5043 | // Reveals the popover that displays all events within a cell | |
5044 | showSegPopover: function(cell, moreLink, segs) { | |
5045 | var _this = this; | |
5046 | var view = this.view; | |
5047 | var moreWrap = moreLink.parent(); // the <div> wrapper around the <a> | |
5048 | var topEl; // the element we want to match the top coordinate of | |
5049 | var options; | |
5050 | ||
5051 | if (this.rowCnt == 1) { | |
5052 | topEl = view.el; // will cause the popover to cover any sort of header | |
5053 | } | |
5054 | else { | |
5055 | topEl = this.rowEls.eq(cell.row); // will align with top of row | |
5056 | } | |
5057 | ||
5058 | options = { | |
5059 | className: 'fc-more-popover', | |
5060 | content: this.renderSegPopoverContent(cell, segs), | |
5061 | parentEl: this.el, | |
5062 | top: topEl.offset().top, | |
5063 | autoHide: true, // when the user clicks elsewhere, hide the popover | |
5064 | viewportConstrain: view.opt('popoverViewportConstrain'), | |
5065 | hide: function() { | |
5066 | // destroy everything when the popover is hidden | |
5067 | _this.segPopover.destroy(); | |
5068 | _this.segPopover = null; | |
5069 | _this.popoverSegs = null; | |
5070 | } | |
5071 | }; | |
5072 | ||
5073 | // Determine horizontal coordinate. | |
5074 | // We use the moreWrap instead of the <td> to avoid border confusion. | |
5075 | if (this.isRTL) { | |
5076 | options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border | |
5077 | } | |
5078 | else { | |
5079 | options.left = moreWrap.offset().left - 1; // -1 to be over cell border | |
5080 | } | |
5081 | ||
5082 | this.segPopover = new Popover(options); | |
5083 | this.segPopover.show(); | |
5084 | }, | |
5085 | ||
5086 | ||
5087 | // Builds the inner DOM contents of the segment popover | |
5088 | renderSegPopoverContent: function(cell, segs) { | |
5089 | var view = this.view; | |
5090 | var isTheme = view.opt('theme'); | |
5091 | var title = cell.start.format(view.opt('dayPopoverFormat')); | |
5092 | var content = $( | |
5093 | '<div class="fc-header ' + view.widgetHeaderClass + '">' + | |
5094 | '<span class="fc-close ' + | |
5095 | (isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') + | |
5096 | '"></span>' + | |
5097 | '<span class="fc-title">' + | |
5098 | htmlEscape(title) + | |
5099 | '</span>' + | |
5100 | '<div class="fc-clear"/>' + | |
5101 | '</div>' + | |
5102 | '<div class="fc-body ' + view.widgetContentClass + '">' + | |
5103 | '<div class="fc-event-container"></div>' + | |
5104 | '</div>' | |
5105 | ); | |
5106 | var segContainer = content.find('.fc-event-container'); | |
5107 | var i; | |
5108 | ||
5109 | // render each seg's `el` and only return the visible segs | |
5110 | segs = this.renderFgSegEls(segs, true); // disableResizing=true | |
5111 | this.popoverSegs = segs; | |
5112 | ||
5113 | for (i = 0; i < segs.length; i++) { | |
5114 | ||
5115 | // because segments in the popover are not part of a grid coordinate system, provide a hint to any | |
5116 | // grids that want to do drag-n-drop about which cell it came from | |
5117 | segs[i].cell = cell; | |
5118 | ||
5119 | segContainer.append(segs[i].el); | |
5120 | } | |
5121 | ||
5122 | return content; | |
5123 | }, | |
5124 | ||
5125 | ||
5126 | // Given the events within an array of segment objects, reslice them to be in a single day | |
5127 | resliceDaySegs: function(segs, dayDate) { | |
5128 | ||
5129 | // build an array of the original events | |
5130 | var events = $.map(segs, function(seg) { | |
5131 | return seg.event; | |
5132 | }); | |
5133 | ||
5134 | var dayStart = dayDate.clone().stripTime(); | |
5135 | var dayEnd = dayStart.clone().add(1, 'days'); | |
5136 | var dayRange = { start: dayStart, end: dayEnd }; | |
5137 | ||
5138 | // slice the events with a custom slicing function | |
5139 | return this.eventsToSegs( | |
5140 | events, | |
5141 | function(range) { | |
5142 | var seg = intersectionToSeg(range, dayRange); // undefind if no intersection | |
5143 | return seg ? [ seg ] : []; // must return an array of segments | |
5144 | } | |
5145 | ); | |
5146 | }, | |
5147 | ||
5148 | ||
5149 | // Generates the text that should be inside a "more" link, given the number of events it represents | |
5150 | getMoreLinkText: function(num) { | |
5151 | var opt = this.view.opt('eventLimitText'); | |
5152 | ||
5153 | if (typeof opt === 'function') { | |
5154 | return opt(num); | |
5155 | } | |
5156 | else { | |
5157 | return '+' + num + ' ' + opt; | |
5158 | } | |
5159 | }, | |
5160 | ||
5161 | ||
5162 | // Returns segments within a given cell. | |
5163 | // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs. | |
5164 | getCellSegs: function(cell, startLevel) { | |
5165 | var segMatrix = this.rowStructs[cell.row].segMatrix; | |
5166 | var level = startLevel || 0; | |
5167 | var segs = []; | |
5168 | var seg; | |
5169 | ||
5170 | while (level < segMatrix.length) { | |
5171 | seg = segMatrix[level][cell.col]; | |
5172 | if (seg) { | |
5173 | segs.push(seg); | |
5174 | } | |
5175 | level++; | |
5176 | } | |
5177 | ||
5178 | return segs; | |
5179 | } | |
5180 | ||
5181 | }); | |
5182 | ||
5183 | /* A component that renders one or more columns of vertical time slots | |
5184 | ----------------------------------------------------------------------------------------------------------------------*/ | |
5185 | ||
5186 | var TimeGrid = Grid.extend({ | |
5187 | ||
5188 | slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines | |
5189 | snapDuration: null, // granularity of time for dragging and selecting | |
5190 | ||
5191 | minTime: null, // Duration object that denotes the first visible time of any given day | |
5192 | maxTime: null, // Duration object that denotes the exclusive visible end time of any given day | |
5193 | ||
5194 | axisFormat: null, // formatting string for times running along vertical axis | |
5195 | ||
5196 | dayEls: null, // cells elements in the day-row background | |
5197 | slatEls: null, // elements running horizontally across all columns | |
5198 | ||
5199 | slatTops: null, // an array of top positions, relative to the container. last item holds bottom of last slot | |
5200 | ||
5201 | helperEl: null, // cell skeleton element for rendering the mock event "helper" | |
5202 | ||
5203 | businessHourSegs: null, | |
5204 | ||
5205 | ||
5206 | constructor: function() { | |
5207 | Grid.apply(this, arguments); // call the super-constructor | |
5208 | this.processOptions(); | |
5209 | }, | |
5210 | ||
5211 | ||
5212 | // Renders the time grid into `this.el`, which should already be assigned. | |
5213 | // Relies on the view's colCnt. In the future, this component should probably be self-sufficient. | |
5214 | render: function() { | |
5215 | this.el.html(this.renderHtml()); | |
5216 | this.dayEls = this.el.find('.fc-day'); | |
5217 | this.slatEls = this.el.find('.fc-slats tr'); | |
5218 | ||
5219 | this.computeSlatTops(); | |
5220 | this.renderBusinessHours(); | |
5221 | Grid.prototype.render.call(this); // call the super-method | |
5222 | }, | |
5223 | ||
5224 | ||
5225 | renderBusinessHours: function() { | |
5226 | var events = this.view.calendar.getBusinessHoursEvents(); | |
5227 | this.businessHourSegs = this.renderFill('businessHours', this.eventsToSegs(events), 'bgevent'); | |
5228 | }, | |
5229 | ||
5230 | ||
5231 | // Renders the basic HTML skeleton for the grid | |
5232 | renderHtml: function() { | |
5233 | return '' + | |
5234 | '<div class="fc-bg">' + | |
5235 | '<table>' + | |
5236 | this.rowHtml('slotBg') + // leverages RowRenderer, which will call slotBgCellHtml | |
5237 | '</table>' + | |
5238 | '</div>' + | |
5239 | '<div class="fc-slats">' + | |
5240 | '<table>' + | |
5241 | this.slatRowHtml() + | |
5242 | '</table>' + | |
5243 | '</div>'; | |
5244 | }, | |
5245 | ||
5246 | ||
5247 | // Renders the HTML for a vertical background cell behind the slots. | |
5248 | // This method is distinct from 'bg' because we wanted a new `rowType` so the View could customize the rendering. | |
5249 | slotBgCellHtml: function(cell) { | |
5250 | return this.bgCellHtml(cell); | |
5251 | }, | |
5252 | ||
5253 | ||
5254 | // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL. | |
5255 | slatRowHtml: function() { | |
5256 | var view = this.view; | |
5257 | var isRTL = this.isRTL; | |
5258 | var html = ''; | |
5259 | var slotNormal = this.slotDuration.asMinutes() % 15 === 0; | |
5260 | var slotTime = moment.duration(+this.minTime); // wish there was .clone() for durations | |
5261 | var slotDate; // will be on the view's first day, but we only care about its time | |
5262 | var minutes; | |
5263 | var axisHtml; | |
5264 | ||
5265 | // Calculate the time for each slot | |
5266 | while (slotTime < this.maxTime) { | |
5267 | slotDate = this.start.clone().time(slotTime); // will be in UTC but that's good. to avoid DST issues | |
5268 | minutes = slotDate.minutes(); | |
5269 | ||
5270 | axisHtml = | |
5271 | '<td class="fc-axis fc-time ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' + | |
5272 | ((!slotNormal || !minutes) ? // if irregular slot duration, or on the hour, then display the time | |
5273 | '<span>' + // for matchCellWidths | |
5274 | htmlEscape(slotDate.format(this.axisFormat)) + | |
5275 | '</span>' : | |
5276 | '' | |
5277 | ) + | |
5278 | '</td>'; | |
5279 | ||
5280 | html += | |
5281 | '<tr ' + (!minutes ? '' : 'class="fc-minor"') + '>' + | |
5282 | (!isRTL ? axisHtml : '') + | |
5283 | '<td class="' + view.widgetContentClass + '"/>' + | |
5284 | (isRTL ? axisHtml : '') + | |
5285 | "</tr>"; | |
5286 | ||
5287 | slotTime.add(this.slotDuration); | |
5288 | } | |
5289 | ||
5290 | return html; | |
5291 | }, | |
5292 | ||
5293 | ||
5294 | /* Options | |
5295 | ------------------------------------------------------------------------------------------------------------------*/ | |
5296 | ||
5297 | ||
5298 | // Parses various options into properties of this object | |
5299 | processOptions: function() { | |
5300 | var view = this.view; | |
5301 | var slotDuration = view.opt('slotDuration'); | |
5302 | var snapDuration = view.opt('snapDuration'); | |
5303 | ||
5304 | slotDuration = moment.duration(slotDuration); | |
5305 | snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration; | |
5306 | ||
5307 | this.slotDuration = slotDuration; | |
5308 | this.snapDuration = snapDuration; | |
5309 | ||
5310 | this.minTime = moment.duration(view.opt('minTime')); | |
5311 | this.maxTime = moment.duration(view.opt('maxTime')); | |
5312 | ||
5313 | this.axisFormat = view.opt('axisFormat') || view.opt('smallTimeFormat'); | |
5314 | }, | |
5315 | ||
5316 | ||
5317 | // Computes a default column header formatting string if `colFormat` is not explicitly defined | |
5318 | computeColHeadFormat: function() { | |
5319 | if (this.colCnt > 1) { // multiple days, so full single date string WON'T be in title text | |
5320 | return this.view.opt('dayOfMonthFormat'); // "Sat 12/10" | |
5321 | } | |
5322 | else { // single day, so full single date string will probably be in title text | |
5323 | return 'dddd'; // "Saturday" | |
5324 | } | |
5325 | }, | |
5326 | ||
5327 | ||
5328 | // Computes a default event time formatting string if `timeFormat` is not explicitly defined | |
5329 | computeEventTimeFormat: function() { | |
5330 | return this.view.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM) | |
5331 | }, | |
5332 | ||
5333 | ||
5334 | // Computes a default `displayEventEnd` value if one is not expliclty defined | |
5335 | computeDisplayEventEnd: function() { | |
5336 | return true; | |
5337 | }, | |
5338 | ||
5339 | ||
5340 | /* Cell System | |
5341 | ------------------------------------------------------------------------------------------------------------------*/ | |
5342 | ||
5343 | ||
5344 | // Initializes row/col information | |
5345 | updateCells: function() { | |
5346 | var view = this.view; | |
5347 | var colData = []; | |
5348 | var date; | |
5349 | ||
5350 | date = this.start.clone(); | |
5351 | while (date.isBefore(this.end)) { | |
5352 | colData.push({ | |
5353 | day: date.clone() | |
5354 | }); | |
5355 | date.add(1, 'day'); | |
5356 | date = view.skipHiddenDays(date); | |
5357 | } | |
5358 | ||
5359 | if (this.isRTL) { | |
5360 | colData.reverse(); | |
5361 | } | |
5362 | ||
5363 | this.colData = colData; | |
5364 | this.colCnt = colData.length; | |
5365 | this.rowCnt = Math.ceil((this.maxTime - this.minTime) / this.snapDuration); // # of vertical snaps | |
5366 | }, | |
5367 | ||
5368 | ||
5369 | // Given a cell object, generates a range object | |
5370 | computeCellRange: function(cell) { | |
5371 | var time = this.computeSnapTime(cell.row); | |
5372 | var start = this.view.calendar.rezoneDate(cell.day).time(time); | |
5373 | var end = start.clone().add(this.snapDuration); | |
5374 | ||
5375 | return { start: start, end: end }; | |
5376 | }, | |
5377 | ||
5378 | ||
5379 | // Retrieves the element representing the given column | |
5380 | getColEl: function(col) { | |
5381 | return this.dayEls.eq(col); | |
5382 | }, | |
5383 | ||
5384 | ||
5385 | /* Dates | |
5386 | ------------------------------------------------------------------------------------------------------------------*/ | |
5387 | ||
5388 | ||
5389 | // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day | |
5390 | computeSnapTime: function(row) { | |
5391 | return moment.duration(this.minTime + this.snapDuration * row); | |
5392 | }, | |
5393 | ||
5394 | ||
5395 | // Slices up a date range by column into an array of segments | |
5396 | rangeToSegs: function(range) { | |
5397 | var colCnt = this.colCnt; | |
5398 | var segs = []; | |
5399 | var seg; | |
5400 | var col; | |
5401 | var colDate; | |
5402 | var colRange; | |
5403 | ||
5404 | // normalize :( | |
5405 | range = { | |
5406 | start: range.start.clone().stripZone(), | |
5407 | end: range.end.clone().stripZone() | |
5408 | }; | |
5409 | ||
5410 | for (col = 0; col < colCnt; col++) { | |
5411 | colDate = this.colData[col].day; // will be ambig time/timezone | |
5412 | colRange = { | |
5413 | start: colDate.clone().time(this.minTime), | |
5414 | end: colDate.clone().time(this.maxTime) | |
5415 | }; | |
5416 | seg = intersectionToSeg(range, colRange); // both will be ambig timezone | |
5417 | if (seg) { | |
5418 | seg.col = col; | |
5419 | segs.push(seg); | |
5420 | } | |
5421 | } | |
5422 | ||
5423 | return segs; | |
5424 | }, | |
5425 | ||
5426 | ||
5427 | /* Coordinates | |
5428 | ------------------------------------------------------------------------------------------------------------------*/ | |
5429 | ||
5430 | ||
5431 | // Called when there is a window resize/zoom and we need to recalculate coordinates for the grid | |
5432 | resize: function() { | |
5433 | this.computeSlatTops(); | |
5434 | this.updateSegVerticals(); | |
5435 | }, | |
5436 | ||
5437 | ||
5438 | // Computes the top/bottom coordinates of each "snap" rows | |
5439 | computeRowCoords: function() { | |
5440 | var originTop = this.el.offset().top; | |
5441 | var items = []; | |
5442 | var i; | |
5443 | var item; | |
5444 | ||
5445 | for (i = 0; i < this.rowCnt; i++) { | |
5446 | item = { | |
5447 | top: originTop + this.computeTimeTop(this.computeSnapTime(i)) | |
5448 | }; | |
5449 | if (i > 0) { | |
5450 | items[i - 1].bottom = item.top; | |
5451 | } | |
5452 | items.push(item); | |
5453 | } | |
5454 | item.bottom = item.top + this.computeTimeTop(this.computeSnapTime(i)); | |
5455 | ||
5456 | return items; | |
5457 | }, | |
5458 | ||
5459 | ||
5460 | // Computes the top coordinate, relative to the bounds of the grid, of the given date. | |
5461 | // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight. | |
5462 | computeDateTop: function(date, startOfDayDate) { | |
5463 | return this.computeTimeTop( | |
5464 | moment.duration( | |
5465 | date.clone().stripZone() - startOfDayDate.clone().stripTime() | |
5466 | ) | |
5467 | ); | |
5468 | }, | |
5469 | ||
5470 | ||
5471 | // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration). | |
5472 | computeTimeTop: function(time) { | |
5473 | var slatCoverage = (time - this.minTime) / this.slotDuration; // floating-point value of # of slots covered | |
5474 | var slatIndex; | |
5475 | var slatRemainder; | |
5476 | var slatTop; | |
5477 | var slatBottom; | |
5478 | ||
5479 | // constrain. because minTime/maxTime might be customized | |
5480 | slatCoverage = Math.max(0, slatCoverage); | |
5481 | slatCoverage = Math.min(this.slatEls.length, slatCoverage); | |
5482 | ||
5483 | slatIndex = Math.floor(slatCoverage); // an integer index of the furthest whole slot | |
5484 | slatRemainder = slatCoverage - slatIndex; | |
5485 | slatTop = this.slatTops[slatIndex]; // the top position of the furthest whole slot | |
5486 | ||
5487 | if (slatRemainder) { // time spans part-way into the slot | |
5488 | slatBottom = this.slatTops[slatIndex + 1]; | |
5489 | return slatTop + (slatBottom - slatTop) * slatRemainder; // part-way between slots | |
5490 | } | |
5491 | else { | |
5492 | return slatTop; | |
5493 | } | |
5494 | }, | |
5495 | ||
5496 | ||
5497 | // Queries each `slatEl` for its position relative to the grid's container and stores it in `slatTops`. | |
5498 | // Includes the the bottom of the last slat as the last item in the array. | |
5499 | computeSlatTops: function() { | |
5500 | var tops = []; | |
5501 | var top; | |
5502 | ||
5503 | this.slatEls.each(function(i, node) { | |
5504 | top = $(node).position().top; | |
5505 | tops.push(top); | |
5506 | }); | |
5507 | ||
5508 | tops.push(top + this.slatEls.last().outerHeight()); // bottom of the last slat | |
5509 | ||
5510 | this.slatTops = tops; | |
5511 | }, | |
5512 | ||
5513 | ||
5514 | /* Event Drag Visualization | |
5515 | ------------------------------------------------------------------------------------------------------------------*/ | |
5516 | ||
5517 | ||
5518 | // Renders a visual indication of an event being dragged over the specified date(s). | |
5519 | // dropLocation's end might be null, as well as `seg`. See Grid::renderDrag for more info. | |
5520 | // A returned value of `true` signals that a mock "helper" event has been rendered. | |
5521 | renderDrag: function(dropLocation, seg) { | |
5522 | var opacity; | |
5523 | ||
5524 | if (seg) { // if there is event information for this drag, render a helper event | |
5525 | this.renderRangeHelper(dropLocation, seg); | |
5526 | ||
5527 | opacity = this.view.opt('dragOpacity'); | |
5528 | if (opacity !== undefined) { | |
5529 | this.helperEl.css('opacity', opacity); | |
5530 | } | |
5531 | ||
5532 | return true; // signal that a helper has been rendered | |
5533 | } | |
5534 | else { | |
5535 | // otherwise, just render a highlight | |
5536 | this.renderHighlight( | |
5537 | this.view.calendar.ensureVisibleEventRange(dropLocation) // needs to be a proper range | |
5538 | ); | |
5539 | } | |
5540 | }, | |
5541 | ||
5542 | ||
5543 | // Unrenders any visual indication of an event being dragged | |
5544 | destroyDrag: function() { | |
5545 | this.destroyHelper(); | |
5546 | this.destroyHighlight(); | |
5547 | }, | |
5548 | ||
5549 | ||
5550 | /* Event Resize Visualization | |
5551 | ------------------------------------------------------------------------------------------------------------------*/ | |
5552 | ||
5553 | ||
5554 | // Renders a visual indication of an event being resized | |
5555 | renderEventResize: function(range, seg) { | |
5556 | this.renderRangeHelper(range, seg); | |
5557 | }, | |
5558 | ||
5559 | ||
5560 | // Unrenders any visual indication of an event being resized | |
5561 | destroyEventResize: function() { | |
5562 | this.destroyHelper(); | |
5563 | }, | |
5564 | ||
5565 | ||
5566 | /* Event Helper | |
5567 | ------------------------------------------------------------------------------------------------------------------*/ | |
5568 | ||
5569 | ||
5570 | // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag) | |
5571 | renderHelper: function(event, sourceSeg) { | |
5572 | var segs = this.eventsToSegs([ event ]); | |
5573 | var tableEl; | |
5574 | var i, seg; | |
5575 | var sourceEl; | |
5576 | ||
5577 | segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered | |
5578 | tableEl = this.renderSegTable(segs); | |
5579 | ||
5580 | // Try to make the segment that is in the same row as sourceSeg look the same | |
5581 | for (i = 0; i < segs.length; i++) { | |
5582 | seg = segs[i]; | |
5583 | if (sourceSeg && sourceSeg.col === seg.col) { | |
5584 | sourceEl = sourceSeg.el; | |
5585 | seg.el.css({ | |
5586 | left: sourceEl.css('left'), | |
5587 | right: sourceEl.css('right'), | |
5588 | 'margin-left': sourceEl.css('margin-left'), | |
5589 | 'margin-right': sourceEl.css('margin-right') | |
5590 | }); | |
5591 | } | |
5592 | } | |
5593 | ||
5594 | this.helperEl = $('<div class="fc-helper-skeleton"/>') | |
5595 | .append(tableEl) | |
5596 | .appendTo(this.el); | |
5597 | }, | |
5598 | ||
5599 | ||
5600 | // Unrenders any mock helper event | |
5601 | destroyHelper: function() { | |
5602 | if (this.helperEl) { | |
5603 | this.helperEl.remove(); | |
5604 | this.helperEl = null; | |
5605 | } | |
5606 | }, | |
5607 | ||
5608 | ||
5609 | /* Selection | |
5610 | ------------------------------------------------------------------------------------------------------------------*/ | |
5611 | ||
5612 | ||
5613 | // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight. | |
5614 | renderSelection: function(range) { | |
5615 | if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered | |
5616 | this.renderRangeHelper(range); | |
5617 | } | |
5618 | else { | |
5619 | this.renderHighlight(range); | |
5620 | } | |
5621 | }, | |
5622 | ||
5623 | ||
5624 | // Unrenders any visual indication of a selection | |
5625 | destroySelection: function() { | |
5626 | this.destroyHelper(); | |
5627 | this.destroyHighlight(); | |
5628 | }, | |
5629 | ||
5630 | ||
5631 | /* Fill System (highlight, background events, business hours) | |
5632 | ------------------------------------------------------------------------------------------------------------------*/ | |
5633 | ||
5634 | ||
5635 | // Renders a set of rectangles over the given time segments. | |
5636 | // Only returns segments that successfully rendered. | |
5637 | renderFill: function(type, segs, className) { | |
5638 | var segCols; | |
5639 | var skeletonEl; | |
5640 | var trEl; | |
5641 | var col, colSegs; | |
5642 | var tdEl; | |
5643 | var containerEl; | |
5644 | var dayDate; | |
5645 | var i, seg; | |
5646 | ||
5647 | if (segs.length) { | |
5648 | ||
5649 | segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs | |
5650 | segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg | |
5651 | ||
5652 | className = className || type.toLowerCase(); | |
5653 | skeletonEl = $( | |
5654 | '<div class="fc-' + className + '-skeleton">' + | |
5655 | '<table><tr/></table>' + | |
5656 | '</div>' | |
5657 | ); | |
5658 | trEl = skeletonEl.find('tr'); | |
5659 | ||
5660 | for (col = 0; col < segCols.length; col++) { | |
5661 | colSegs = segCols[col]; | |
5662 | tdEl = $('<td/>').appendTo(trEl); | |
5663 | ||
5664 | if (colSegs.length) { | |
5665 | containerEl = $('<div class="fc-' + className + '-container"/>').appendTo(tdEl); | |
5666 | dayDate = this.colData[col].day; | |
5667 | ||
5668 | for (i = 0; i < colSegs.length; i++) { | |
5669 | seg = colSegs[i]; | |
5670 | containerEl.append( | |
5671 | seg.el.css({ | |
5672 | top: this.computeDateTop(seg.start, dayDate), | |
5673 | bottom: -this.computeDateTop(seg.end, dayDate) // the y position of the bottom edge | |
5674 | }) | |
5675 | ); | |
5676 | } | |
5677 | } | |
5678 | } | |
5679 | ||
5680 | this.bookendCells(trEl, type); | |
5681 | ||
5682 | this.el.append(skeletonEl); | |
5683 | this.elsByFill[type] = skeletonEl; | |
5684 | } | |
5685 | ||
5686 | return segs; | |
5687 | } | |
5688 | ||
5689 | }); | |
5690 | ||
5691 | /* Event-rendering methods for the TimeGrid class | |
5692 | ----------------------------------------------------------------------------------------------------------------------*/ | |
5693 | ||
5694 | TimeGrid.mixin({ | |
5695 | ||
5696 | eventSkeletonEl: null, // has cells with event-containers, which contain absolutely positioned event elements | |
5697 | ||
5698 | ||
5699 | // Renders the given foreground event segments onto the grid | |
5700 | renderFgSegs: function(segs) { | |
5701 | segs = this.renderFgSegEls(segs); // returns a subset of the segs. segs that were actually rendered | |
5702 | ||
5703 | this.el.append( | |
5704 | this.eventSkeletonEl = $('<div class="fc-content-skeleton"/>') | |
5705 | .append(this.renderSegTable(segs)) | |
5706 | ); | |
5707 | ||
5708 | return segs; // return only the segs that were actually rendered | |
5709 | }, | |
5710 | ||
5711 | ||
5712 | // Unrenders all currently rendered foreground event segments | |
5713 | destroyFgSegs: function(segs) { | |
5714 | if (this.eventSkeletonEl) { | |
5715 | this.eventSkeletonEl.remove(); | |
5716 | this.eventSkeletonEl = null; | |
5717 | } | |
5718 | }, | |
5719 | ||
5720 | ||
5721 | // Renders and returns the <table> portion of the event-skeleton. | |
5722 | // Returns an object with properties 'tbodyEl' and 'segs'. | |
5723 | renderSegTable: function(segs) { | |
5724 | var tableEl = $('<table><tr/></table>'); | |
5725 | var trEl = tableEl.find('tr'); | |
5726 | var segCols; | |
5727 | var i, seg; | |
5728 | var col, colSegs; | |
5729 | var containerEl; | |
5730 | ||
5731 | segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg | |
5732 | ||
5733 | this.computeSegVerticals(segs); // compute and assign top/bottom | |
5734 | ||
5735 | for (col = 0; col < segCols.length; col++) { // iterate each column grouping | |
5736 | colSegs = segCols[col]; | |
5737 | placeSlotSegs(colSegs); // compute horizontal coordinates, z-index's, and reorder the array | |
5738 | ||
5739 | containerEl = $('<div class="fc-event-container"/>'); | |
5740 | ||
5741 | // assign positioning CSS and insert into container | |
5742 | for (i = 0; i < colSegs.length; i++) { | |
5743 | seg = colSegs[i]; | |
5744 | seg.el.css(this.generateSegPositionCss(seg)); | |
5745 | ||
5746 | // if the height is short, add a className for alternate styling | |
5747 | if (seg.bottom - seg.top < 30) { | |
5748 | seg.el.addClass('fc-short'); | |
5749 | } | |
5750 | ||
5751 | containerEl.append(seg.el); | |
5752 | } | |
5753 | ||
5754 | trEl.append($('<td/>').append(containerEl)); | |
5755 | } | |
5756 | ||
5757 | this.bookendCells(trEl, 'eventSkeleton'); | |
5758 | ||
5759 | return tableEl; | |
5760 | }, | |
5761 | ||
5762 | ||
5763 | // Refreshes the CSS top/bottom coordinates for each segment element. Probably after a window resize/zoom. | |
5764 | // Repositions business hours segs too, so not just for events. Maybe shouldn't be here. | |
5765 | updateSegVerticals: function() { | |
5766 | var allSegs = (this.segs || []).concat(this.businessHourSegs || []); | |
5767 | var i; | |
5768 | ||
5769 | this.computeSegVerticals(allSegs); | |
5770 | ||
5771 | for (i = 0; i < allSegs.length; i++) { | |
5772 | allSegs[i].el.css( | |
5773 | this.generateSegVerticalCss(allSegs[i]) | |
5774 | ); | |
5775 | } | |
5776 | }, | |
5777 | ||
5778 | ||
5779 | // For each segment in an array, computes and assigns its top and bottom properties | |
5780 | computeSegVerticals: function(segs) { | |
5781 | var i, seg; | |
5782 | ||
5783 | for (i = 0; i < segs.length; i++) { | |
5784 | seg = segs[i]; | |
5785 | seg.top = this.computeDateTop(seg.start, seg.start); | |
5786 | seg.bottom = this.computeDateTop(seg.end, seg.start); | |
5787 | } | |
5788 | }, | |
5789 | ||
5790 | ||
5791 | // Renders the HTML for a single event segment's default rendering | |
5792 | fgSegHtml: function(seg, disableResizing) { | |
5793 | var view = this.view; | |
5794 | var event = seg.event; | |
5795 | var isDraggable = view.isEventDraggable(event); | |
5796 | var isResizable = !disableResizing && seg.isEnd && view.isEventResizable(event); | |
5797 | var classes = this.getSegClasses(seg, isDraggable, isResizable); | |
5798 | var skinCss = this.getEventSkinCss(event); | |
5799 | var timeText; | |
5800 | var fullTimeText; // more verbose time text. for the print stylesheet | |
5801 | var startTimeText; // just the start time text | |
5802 | ||
5803 | classes.unshift('fc-time-grid-event'); | |
5804 | ||
5805 | if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day... | |
5806 | // Don't display time text on segments that run entirely through a day. | |
5807 | // That would appear as midnight-midnight and would look dumb. | |
5808 | // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am) | |
5809 | if (seg.isStart || seg.isEnd) { | |
5810 | timeText = this.getEventTimeText(seg); | |
5811 | fullTimeText = this.getEventTimeText(seg, 'LT'); | |
5812 | startTimeText = this.getEventTimeText({ start: seg.start }); | |
5813 | } | |
5814 | } else { | |
5815 | // Display the normal time text for the *event's* times | |
5816 | timeText = this.getEventTimeText(event); | |
5817 | fullTimeText = this.getEventTimeText(event, 'LT'); | |
5818 | startTimeText = this.getEventTimeText({ start: event.start }); | |
5819 | } | |
5820 | ||
5821 | return '<a class="' + classes.join(' ') + '"' + | |
5822 | (event.url ? | |
5823 | ' href="' + htmlEscape(event.url) + '"' : | |
5824 | '' | |
5825 | ) + | |
5826 | (skinCss ? | |
5827 | ' style="' + skinCss + '"' : | |
5828 | '' | |
5829 | ) + | |
5830 | '>' + | |
5831 | '<div class="fc-content">' + | |
5832 | (timeText ? | |
5833 | '<div class="fc-time"' + | |
5834 | ' data-start="' + htmlEscape(startTimeText) + '"' + | |
5835 | ' data-full="' + htmlEscape(fullTimeText) + '"' + | |
5836 | '>' + | |
5837 | '<span>' + htmlEscape(timeText) + '</span>' + | |
5838 | '</div>' : | |
5839 | '' | |
5840 | ) + | |
5841 | (event.title ? | |
5842 | '<div class="fc-title">' + | |
5843 | htmlEscape(event.title) + | |
5844 | '</div>' : | |
5845 | '' | |
5846 | ) + | |
5847 | '</div>' + | |
5848 | '<div class="fc-bg"/>' + | |
5849 | (isResizable ? | |
5850 | '<div class="fc-resizer"/>' : | |
5851 | '' | |
5852 | ) + | |
5853 | '</a>'; | |
5854 | }, | |
5855 | ||
5856 | ||
5857 | // Generates an object with CSS properties/values that should be applied to an event segment element. | |
5858 | // Contains important positioning-related properties that should be applied to any event element, customized or not. | |
5859 | generateSegPositionCss: function(seg) { | |
5860 | var shouldOverlap = this.view.opt('slotEventOverlap'); | |
5861 | var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point | |
5862 | var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point | |
5863 | var props = this.generateSegVerticalCss(seg); // get top/bottom first | |
5864 | var left; // amount of space from left edge, a fraction of the total width | |
5865 | var right; // amount of space from right edge, a fraction of the total width | |
5866 | ||
5867 | if (shouldOverlap) { | |
5868 | // double the width, but don't go beyond the maximum forward coordinate (1.0) | |
5869 | forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2); | |
5870 | } | |
5871 | ||
5872 | if (this.isRTL) { | |
5873 | left = 1 - forwardCoord; | |
5874 | right = backwardCoord; | |
5875 | } | |
5876 | else { | |
5877 | left = backwardCoord; | |
5878 | right = 1 - forwardCoord; | |
5879 | } | |
5880 | ||
5881 | props.zIndex = seg.level + 1; // convert from 0-base to 1-based | |
5882 | props.left = left * 100 + '%'; | |
5883 | props.right = right * 100 + '%'; | |
5884 | ||
5885 | if (shouldOverlap && seg.forwardPressure) { | |
5886 | // add padding to the edge so that forward stacked events don't cover the resizer's icon | |
5887 | props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width | |
5888 | } | |
5889 | ||
5890 | return props; | |
5891 | }, | |
5892 | ||
5893 | ||
5894 | // Generates an object with CSS properties for the top/bottom coordinates of a segment element | |
5895 | generateSegVerticalCss: function(seg) { | |
5896 | return { | |
5897 | top: seg.top, | |
5898 | bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container | |
5899 | }; | |
5900 | }, | |
5901 | ||
5902 | ||
5903 | // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col | |
5904 | groupSegCols: function(segs) { | |
5905 | var segCols = []; | |
5906 | var i; | |
5907 | ||
5908 | for (i = 0; i < this.colCnt; i++) { | |
5909 | segCols.push([]); | |
5910 | } | |
5911 | ||
5912 | for (i = 0; i < segs.length; i++) { | |
5913 | segCols[segs[i].col].push(segs[i]); | |
5914 | } | |
5915 | ||
5916 | return segCols; | |
5917 | } | |
5918 | ||
5919 | }); | |
5920 | ||
5921 | ||
5922 | // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each. | |
5923 | // Also reorders the given array by date! | |
5924 | function placeSlotSegs(segs) { | |
5925 | var levels; | |
5926 | var level0; | |
5927 | var i; | |
5928 | ||
5929 | segs.sort(compareSegs); // order by date | |
5930 | levels = buildSlotSegLevels(segs); | |
5931 | computeForwardSlotSegs(levels); | |
5932 | ||
5933 | if ((level0 = levels[0])) { | |
5934 | ||
5935 | for (i = 0; i < level0.length; i++) { | |
5936 | computeSlotSegPressures(level0[i]); | |
5937 | } | |
5938 | ||
5939 | for (i = 0; i < level0.length; i++) { | |
5940 | computeSlotSegCoords(level0[i], 0, 0); | |
5941 | } | |
5942 | } | |
5943 | } | |
5944 | ||
5945 | ||
5946 | // Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is | |
5947 | // left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date. | |
5948 | function buildSlotSegLevels(segs) { | |
5949 | var levels = []; | |
5950 | var i, seg; | |
5951 | var j; | |
5952 | ||
5953 | for (i=0; i<segs.length; i++) { | |
5954 | seg = segs[i]; | |
5955 | ||
5956 | // go through all the levels and stop on the first level where there are no collisions | |
5957 | for (j=0; j<levels.length; j++) { | |
5958 | if (!computeSlotSegCollisions(seg, levels[j]).length) { | |
5959 | break; | |
5960 | } | |
5961 | } | |
5962 | ||
5963 | seg.level = j; | |
5964 | ||
5965 | (levels[j] || (levels[j] = [])).push(seg); | |
5966 | } | |
5967 | ||
5968 | return levels; | |
5969 | } | |
5970 | ||
5971 | ||
5972 | // For every segment, figure out the other segments that are in subsequent | |
5973 | // levels that also occupy the same vertical space. Accumulate in seg.forwardSegs | |
5974 | function computeForwardSlotSegs(levels) { | |
5975 | var i, level; | |
5976 | var j, seg; | |
5977 | var k; | |
5978 | ||
5979 | for (i=0; i<levels.length; i++) { | |
5980 | level = levels[i]; | |
5981 | ||
5982 | for (j=0; j<level.length; j++) { | |
5983 | seg = level[j]; | |
5984 | ||
5985 | seg.forwardSegs = []; | |
5986 | for (k=i+1; k<levels.length; k++) { | |
5987 | computeSlotSegCollisions(seg, levels[k], seg.forwardSegs); | |
5988 | } | |
5989 | } | |
5990 | } | |
5991 | } | |
5992 | ||
5993 | ||
5994 | // Figure out which path forward (via seg.forwardSegs) results in the longest path until | |
5995 | // the furthest edge is reached. The number of segments in this path will be seg.forwardPressure | |
5996 | function computeSlotSegPressures(seg) { | |
5997 | var forwardSegs = seg.forwardSegs; | |
5998 | var forwardPressure = 0; | |
5999 | var i, forwardSeg; | |
6000 | ||
6001 | if (seg.forwardPressure === undefined) { // not already computed | |
6002 | ||
6003 | for (i=0; i<forwardSegs.length; i++) { | |
6004 | forwardSeg = forwardSegs[i]; | |
6005 | ||
6006 | // figure out the child's maximum forward path | |
6007 | computeSlotSegPressures(forwardSeg); | |
6008 | ||
6009 | // either use the existing maximum, or use the child's forward pressure | |
6010 | // plus one (for the forwardSeg itself) | |
6011 | forwardPressure = Math.max( | |
6012 | forwardPressure, | |
6013 | 1 + forwardSeg.forwardPressure | |
6014 | ); | |
6015 | } | |
6016 | ||
6017 | seg.forwardPressure = forwardPressure; | |
6018 | } | |
6019 | } | |
6020 | ||
6021 | ||
6022 | // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range | |
6023 | // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and | |
6024 | // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left. | |
6025 | // | |
6026 | // The segment might be part of a "series", which means consecutive segments with the same pressure | |
6027 | // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of | |
6028 | // segments behind this one in the current series, and `seriesBackwardCoord` is the starting | |
6029 | // coordinate of the first segment in the series. | |
6030 | function computeSlotSegCoords(seg, seriesBackwardPressure, seriesBackwardCoord) { | |
6031 | var forwardSegs = seg.forwardSegs; | |
6032 | var i; | |
6033 | ||
6034 | if (seg.forwardCoord === undefined) { // not already computed | |
6035 | ||
6036 | if (!forwardSegs.length) { | |
6037 | ||
6038 | // if there are no forward segments, this segment should butt up against the edge | |
6039 | seg.forwardCoord = 1; | |
6040 | } | |
6041 | else { | |
6042 | ||
6043 | // sort highest pressure first | |
6044 | forwardSegs.sort(compareForwardSlotSegs); | |
6045 | ||
6046 | // this segment's forwardCoord will be calculated from the backwardCoord of the | |
6047 | // highest-pressure forward segment. | |
6048 | computeSlotSegCoords(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord); | |
6049 | seg.forwardCoord = forwardSegs[0].backwardCoord; | |
6050 | } | |
6051 | ||
6052 | // calculate the backwardCoord from the forwardCoord. consider the series | |
6053 | seg.backwardCoord = seg.forwardCoord - | |
6054 | (seg.forwardCoord - seriesBackwardCoord) / // available width for series | |
6055 | (seriesBackwardPressure + 1); // # of segments in the series | |
6056 | ||
6057 | // use this segment's coordinates to computed the coordinates of the less-pressurized | |
6058 | // forward segments | |
6059 | for (i=0; i<forwardSegs.length; i++) { | |
6060 | computeSlotSegCoords(forwardSegs[i], 0, seg.forwardCoord); | |
6061 | } | |
6062 | } | |
6063 | } | |
6064 | ||
6065 | ||
6066 | // Find all the segments in `otherSegs` that vertically collide with `seg`. | |
6067 | // Append into an optionally-supplied `results` array and return. | |
6068 | function computeSlotSegCollisions(seg, otherSegs, results) { | |
6069 | results = results || []; | |
6070 | ||
6071 | for (var i=0; i<otherSegs.length; i++) { | |
6072 | if (isSlotSegCollision(seg, otherSegs[i])) { | |
6073 | results.push(otherSegs[i]); | |
6074 | } | |
6075 | } | |
6076 | ||
6077 | return results; | |
6078 | } | |
6079 | ||
6080 | ||
6081 | // Do these segments occupy the same vertical space? | |
6082 | function isSlotSegCollision(seg1, seg2) { | |
6083 | return seg1.bottom > seg2.top && seg1.top < seg2.bottom; | |
6084 | } | |
6085 | ||
6086 | ||
6087 | // A cmp function for determining which forward segment to rely on more when computing coordinates. | |
6088 | function compareForwardSlotSegs(seg1, seg2) { | |
6089 | // put higher-pressure first | |
6090 | return seg2.forwardPressure - seg1.forwardPressure || | |
6091 | // put segments that are closer to initial edge first (and favor ones with no coords yet) | |
6092 | (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) || | |
6093 | // do normal sorting... | |
6094 | compareSegs(seg1, seg2); | |
6095 | } | |
6096 | ||
6097 | /* An abstract class from which other views inherit from | |
6098 | ----------------------------------------------------------------------------------------------------------------------*/ | |
6099 | ||
6100 | var View = fc.View = Class.extend({ | |
6101 | ||
6102 | type: null, // subclass' view name (string) | |
6103 | name: null, // deprecated. use `type` instead | |
6104 | ||
6105 | calendar: null, // owner Calendar object | |
6106 | options: null, // view-specific options | |
6107 | coordMap: null, // a CoordMap object for converting pixel regions to dates | |
6108 | el: null, // the view's containing element. set by Calendar | |
6109 | ||
6110 | // range the view is actually displaying (moments) | |
6111 | start: null, | |
6112 | end: null, // exclusive | |
6113 | ||
6114 | // range the view is formally responsible for (moments) | |
6115 | // may be different from start/end. for example, a month view might have 1st-31st, excluding padded dates | |
6116 | intervalStart: null, | |
6117 | intervalEnd: null, // exclusive | |
6118 | ||
6119 | intervalDuration: null, // the whole-unit duration that is being displayed | |
6120 | intervalUnit: null, // name of largest unit being displayed, like "month" or "week" | |
6121 | ||
6122 | isSelected: false, // boolean whether a range of time is user-selected or not | |
6123 | ||
6124 | // subclasses can optionally use a scroll container | |
6125 | scrollerEl: null, // the element that will most likely scroll when content is too tall | |
6126 | scrollTop: null, // cached vertical scroll value | |
6127 | ||
6128 | // classNames styled by jqui themes | |
6129 | widgetHeaderClass: null, | |
6130 | widgetContentClass: null, | |
6131 | highlightStateClass: null, | |
6132 | ||
6133 | // for date utils, computed from options | |
6134 | nextDayThreshold: null, | |
6135 | isHiddenDayHash: null, | |
6136 | ||
6137 | // document handlers, bound to `this` object | |
6138 | documentMousedownProxy: null, // TODO: doesn't work with touch | |
6139 | ||
6140 | ||
6141 | constructor: function(calendar, viewOptions, viewType) { | |
6142 | this.calendar = calendar; | |
6143 | this.options = viewOptions; | |
6144 | this.type = this.name = viewType; // .name is deprecated | |
6145 | ||
6146 | this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold')); | |
6147 | this.initTheming(); | |
6148 | this.initHiddenDays(); | |
6149 | ||
6150 | this.documentMousedownProxy = $.proxy(this, 'documentMousedown'); | |
6151 | ||
6152 | this.initialize(); | |
6153 | }, | |
6154 | ||
6155 | ||
6156 | // A good place for subclasses to initialize member variables | |
6157 | initialize: function() { | |
6158 | // subclasses can implement | |
6159 | }, | |
6160 | ||
6161 | ||
6162 | // Retrieves an option with the given name | |
6163 | opt: function(name) { | |
6164 | var val; | |
6165 | ||
6166 | val = this.options[name]; // look at view-specific options first | |
6167 | if (val !== undefined) { | |
6168 | return val; | |
6169 | } | |
6170 | ||
6171 | val = this.calendar.options[name]; | |
6172 | if ($.isPlainObject(val) && !isForcedAtomicOption(name)) { // view-option-hashes are deprecated | |
6173 | return smartProperty(val, this.type); | |
6174 | } | |
6175 | ||
6176 | return val; | |
6177 | }, | |
6178 | ||
6179 | ||
6180 | // Triggers handlers that are view-related. Modifies args before passing to calendar. | |
6181 | trigger: function(name, thisObj) { // arguments beyond thisObj are passed along | |
6182 | var calendar = this.calendar; | |
6183 | ||
6184 | return calendar.trigger.apply( | |
6185 | calendar, | |
6186 | [name, thisObj || this].concat( | |
6187 | Array.prototype.slice.call(arguments, 2), // arguments beyond thisObj | |
6188 | [ this ] // always make the last argument a reference to the view. TODO: deprecate | |
6189 | ) | |
6190 | ); | |
6191 | }, | |
6192 | ||
6193 | ||
6194 | /* Dates | |
6195 | ------------------------------------------------------------------------------------------------------------------*/ | |
6196 | ||
6197 | ||
6198 | // Updates all internal dates to center around the given current date | |
6199 | setDate: function(date) { | |
6200 | this.setRange(this.computeRange(date)); | |
6201 | }, | |
6202 | ||
6203 | ||
6204 | // Updates all internal dates for displaying the given range. | |
6205 | // Expects all values to be normalized (like what computeRange does). | |
6206 | setRange: function(range) { | |
6207 | $.extend(this, range); | |
6208 | }, | |
6209 | ||
6210 | ||
6211 | // Given a single current date, produce information about what range to display. | |
6212 | // Subclasses can override. Must return all properties. | |
6213 | computeRange: function(date) { | |
6214 | var intervalDuration = moment.duration(this.opt('duration') || this.constructor.duration || { days: 1 }); | |
6215 | var intervalUnit = computeIntervalUnit(intervalDuration); | |
6216 | var intervalStart = date.clone().startOf(intervalUnit); | |
6217 | var intervalEnd = intervalStart.clone().add(intervalDuration); | |
6218 | var start, end; | |
6219 | ||
6220 | // normalize the range's time-ambiguity | |
6221 | if (computeIntervalAs('days', intervalDuration)) { // whole-days? | |
6222 | intervalStart.stripTime(); | |
6223 | intervalEnd.stripTime(); | |
6224 | } | |
6225 | else { // needs to have a time? | |
6226 | if (!intervalStart.hasTime()) { | |
6227 | intervalStart = this.calendar.rezoneDate(intervalStart); // convert to current timezone, with 00:00 | |
6228 | } | |
6229 | if (!intervalEnd.hasTime()) { | |
6230 | intervalEnd = this.calendar.rezoneDate(intervalEnd); // convert to current timezone, with 00:00 | |
6231 | } | |
6232 | } | |
6233 | ||
6234 | start = intervalStart.clone(); | |
6235 | start = this.skipHiddenDays(start); | |
6236 | end = intervalEnd.clone(); | |
6237 | end = this.skipHiddenDays(end, -1, true); // exclusively move backwards | |
6238 | ||
6239 | return { | |
6240 | intervalDuration: intervalDuration, | |
6241 | intervalUnit: intervalUnit, | |
6242 | intervalStart: intervalStart, | |
6243 | intervalEnd: intervalEnd, | |
6244 | start: start, | |
6245 | end: end | |
6246 | }; | |
6247 | }, | |
6248 | ||
6249 | ||
6250 | // Computes the new date when the user hits the prev button, given the current date | |
6251 | computePrevDate: function(date) { | |
6252 | return this.skipHiddenDays( | |
6253 | date.clone().startOf(this.intervalUnit).subtract(this.intervalDuration), -1 | |
6254 | ); | |
6255 | }, | |
6256 | ||
6257 | ||
6258 | // Computes the new date when the user hits the next button, given the current date | |
6259 | computeNextDate: function(date) { | |
6260 | return this.skipHiddenDays( | |
6261 | date.clone().startOf(this.intervalUnit).add(this.intervalDuration) | |
6262 | ); | |
6263 | }, | |
6264 | ||
6265 | ||
6266 | /* Title and Date Formatting | |
6267 | ------------------------------------------------------------------------------------------------------------------*/ | |
6268 | ||
6269 | ||
6270 | // Computes what the title at the top of the calendar should be for this view | |
6271 | computeTitle: function() { | |
6272 | return this.formatRange( | |
6273 | { start: this.intervalStart, end: this.intervalEnd }, | |
6274 | this.opt('titleFormat') || this.computeTitleFormat(), | |
6275 | this.opt('titleRangeSeparator') | |
6276 | ); | |
6277 | }, | |
6278 | ||
6279 | ||
6280 | // Generates the format string that should be used to generate the title for the current date range. | |
6281 | // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`. | |
6282 | computeTitleFormat: function() { | |
6283 | if (this.intervalUnit == 'year') { | |
6284 | return 'YYYY'; | |
6285 | } | |
6286 | else if (this.intervalUnit == 'month') { | |
6287 | return this.opt('monthYearFormat'); // like "September 2014" | |
6288 | } | |
6289 | else if (this.intervalDuration.as('days') > 1) { | |
6290 | return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014" | |
6291 | } | |
6292 | else { | |
6293 | return 'LL'; // one day. longer, like "September 9 2014" | |
6294 | } | |
6295 | }, | |
6296 | ||
6297 | ||
6298 | // Utility for formatting a range. Accepts a range object, formatting string, and optional separator. | |
6299 | // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account. | |
6300 | formatRange: function(range, formatStr, separator) { | |
6301 | var end = range.end; | |
6302 | ||
6303 | if (!end.hasTime()) { // all-day? | |
6304 | end = end.clone().subtract(1); // convert to inclusive. last ms of previous day | |
6305 | } | |
6306 | ||
6307 | return formatRange(range.start, end, formatStr, separator, this.opt('isRTL')); | |
6308 | }, | |
6309 | ||
6310 | ||
6311 | /* Rendering | |
6312 | ------------------------------------------------------------------------------------------------------------------*/ | |
6313 | ||
6314 | ||
6315 | // Wraps the basic render() method with more View-specific logic. Called by the owner Calendar. | |
6316 | renderView: function() { | |
6317 | this.render(); | |
6318 | this.updateSize(); | |
6319 | this.initializeScroll(); | |
6320 | this.trigger('viewRender', this, this, this.el); | |
6321 | ||
6322 | // attach handlers to document. do it here to allow for destroy/rerender | |
6323 | $(document).on('mousedown', this.documentMousedownProxy); | |
6324 | }, | |
6325 | ||
6326 | ||
6327 | // Renders the view inside an already-defined `this.el` | |
6328 | render: function() { | |
6329 | // subclasses should implement | |
6330 | }, | |
6331 | ||
6332 | ||
6333 | // Wraps the basic destroy() method with more View-specific logic. Called by the owner Calendar. | |
6334 | destroyView: function() { | |
6335 | this.unselect(); | |
6336 | this.destroyViewEvents(); | |
6337 | this.destroy(); | |
6338 | this.trigger('viewDestroy', this, this, this.el); | |
6339 | ||
6340 | $(document).off('mousedown', this.documentMousedownProxy); | |
6341 | }, | |
6342 | ||
6343 | ||
6344 | // Clears the view's rendering | |
6345 | destroy: function() { | |
6346 | this.el.empty(); // removes inner contents but leaves the element intact | |
6347 | }, | |
6348 | ||
6349 | ||
6350 | // Initializes internal variables related to theming | |
6351 | initTheming: function() { | |
6352 | var tm = this.opt('theme') ? 'ui' : 'fc'; | |
6353 | ||
6354 | this.widgetHeaderClass = tm + '-widget-header'; | |
6355 | this.widgetContentClass = tm + '-widget-content'; | |
6356 | this.highlightStateClass = tm + '-state-highlight'; | |
6357 | }, | |
6358 | ||
6359 | ||
6360 | /* Dimensions | |
6361 | ------------------------------------------------------------------------------------------------------------------*/ | |
6362 | ||
6363 | ||
6364 | // Refreshes anything dependant upon sizing of the container element of the grid | |
6365 | updateSize: function(isResize) { | |
6366 | if (isResize) { | |
6367 | this.recordScroll(); | |
6368 | } | |
6369 | this.updateHeight(); | |
6370 | this.updateWidth(); | |
6371 | }, | |
6372 | ||
6373 | ||
6374 | // Refreshes the horizontal dimensions of the calendar | |
6375 | updateWidth: function() { | |
6376 | // subclasses should implement | |
6377 | }, | |
6378 | ||
6379 | ||
6380 | // Refreshes the vertical dimensions of the calendar | |
6381 | updateHeight: function() { | |
6382 | var calendar = this.calendar; // we poll the calendar for height information | |
6383 | ||
6384 | this.setHeight( | |
6385 | calendar.getSuggestedViewHeight(), | |
6386 | calendar.isHeightAuto() | |
6387 | ); | |
6388 | }, | |
6389 | ||
6390 | ||
6391 | // Updates the vertical dimensions of the calendar to the specified height. | |
6392 | // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height. | |
6393 | setHeight: function(height, isAuto) { | |
6394 | // subclasses should implement | |
6395 | }, | |
6396 | ||
6397 | ||
6398 | /* Scroller | |
6399 | ------------------------------------------------------------------------------------------------------------------*/ | |
6400 | ||
6401 | ||
6402 | // Given the total height of the view, return the number of pixels that should be used for the scroller. | |
6403 | // By default, uses this.scrollerEl, but can pass this in as well. | |
6404 | // Utility for subclasses. | |
6405 | computeScrollerHeight: function(totalHeight, scrollerEl) { | |
6406 | var both; | |
6407 | var otherHeight; // cumulative height of everything that is not the scrollerEl in the view (header+borders) | |
6408 | ||
6409 | scrollerEl = scrollerEl || this.scrollerEl; | |
6410 | both = this.el.add(scrollerEl); | |
6411 | ||
6412 | // fuckin IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked | |
6413 | both.css({ | |
6414 | position: 'relative', // cause a reflow, which will force fresh dimension recalculation | |
6415 | left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll | |
6416 | }); | |
6417 | otherHeight = this.el.outerHeight() - scrollerEl.height(); // grab the dimensions | |
6418 | both.css({ position: '', left: '' }); // undo hack | |
6419 | ||
6420 | return totalHeight - otherHeight; | |
6421 | }, | |
6422 | ||
6423 | ||
6424 | // Sets the scroll value of the scroller to the initial pre-configured state prior to allowing the user to change it | |
6425 | initializeScroll: function() { | |
6426 | }, | |
6427 | ||
6428 | ||
6429 | // Called for remembering the current scroll value of the scroller. | |
6430 | // Should be called before there is a destructive operation (like removing DOM elements) that might inadvertently | |
6431 | // change the scroll of the container. | |
6432 | recordScroll: function() { | |
6433 | if (this.scrollerEl) { | |
6434 | this.scrollTop = this.scrollerEl.scrollTop(); | |
6435 | } | |
6436 | }, | |
6437 | ||
6438 | ||
6439 | // Set the scroll value of the scroller to the previously recorded value. | |
6440 | // Should be called after we know the view's dimensions have been restored following some type of destructive | |
6441 | // operation (like temporarily removing DOM elements). | |
6442 | restoreScroll: function() { | |
6443 | if (this.scrollTop !== null) { | |
6444 | this.scrollerEl.scrollTop(this.scrollTop); | |
6445 | } | |
6446 | }, | |
6447 | ||
6448 | ||
6449 | /* Event Elements / Segments | |
6450 | ------------------------------------------------------------------------------------------------------------------*/ | |
6451 | ||
6452 | ||
6453 | // Wraps the basic renderEvents() method with more View-specific logic | |
6454 | renderViewEvents: function(events) { | |
6455 | this.renderEvents(events); | |
6456 | ||
6457 | this.eventSegEach(function(seg) { | |
6458 | this.trigger('eventAfterRender', seg.event, seg.event, seg.el); | |
6459 | }); | |
6460 | this.trigger('eventAfterAllRender'); | |
6461 | }, | |
6462 | ||
6463 | ||
6464 | // Renders the events onto the view. | |
6465 | renderEvents: function() { | |
6466 | // subclasses should implement | |
6467 | }, | |
6468 | ||
6469 | ||
6470 | // Wraps the basic destroyEvents() method with more View-specific logic | |
6471 | destroyViewEvents: function() { | |
6472 | this.eventSegEach(function(seg) { | |
6473 | this.trigger('eventDestroy', seg.event, seg.event, seg.el); | |
6474 | }); | |
6475 | ||
6476 | this.destroyEvents(); | |
6477 | }, | |
6478 | ||
6479 | ||
6480 | // Removes event elements from the view. | |
6481 | destroyEvents: function() { | |
6482 | // subclasses should implement | |
6483 | }, | |
6484 | ||
6485 | ||
6486 | // Given an event and the default element used for rendering, returns the element that should actually be used. | |
6487 | // Basically runs events and elements through the eventRender hook. | |
6488 | resolveEventEl: function(event, el) { | |
6489 | var custom = this.trigger('eventRender', event, event, el); | |
6490 | ||
6491 | if (custom === false) { // means don't render at all | |
6492 | el = null; | |
6493 | } | |
6494 | else if (custom && custom !== true) { | |
6495 | el = $(custom); | |
6496 | } | |
6497 | ||
6498 | return el; | |
6499 | }, | |
6500 | ||
6501 | ||
6502 | // Hides all rendered event segments linked to the given event | |
6503 | showEvent: function(event) { | |
6504 | this.eventSegEach(function(seg) { | |
6505 | seg.el.css('visibility', ''); | |
6506 | }, event); | |
6507 | }, | |
6508 | ||
6509 | ||
6510 | // Shows all rendered event segments linked to the given event | |
6511 | hideEvent: function(event) { | |
6512 | this.eventSegEach(function(seg) { | |
6513 | seg.el.css('visibility', 'hidden'); | |
6514 | }, event); | |
6515 | }, | |
6516 | ||
6517 | ||
6518 | // Iterates through event segments. Goes through all by default. | |
6519 | // If the optional `event` argument is specified, only iterates through segments linked to that event. | |
6520 | // The `this` value of the callback function will be the view. | |
6521 | eventSegEach: function(func, event) { | |
6522 | var segs = this.getEventSegs(); | |
6523 | var i; | |
6524 | ||
6525 | for (i = 0; i < segs.length; i++) { | |
6526 | if (!event || segs[i].event._id === event._id) { | |
6527 | func.call(this, segs[i]); | |
6528 | } | |
6529 | } | |
6530 | }, | |
6531 | ||
6532 | ||
6533 | // Retrieves all the rendered segment objects for the view | |
6534 | getEventSegs: function() { | |
6535 | // subclasses must implement | |
6536 | return []; | |
6537 | }, | |
6538 | ||
6539 | ||
6540 | /* Event Drag-n-Drop | |
6541 | ------------------------------------------------------------------------------------------------------------------*/ | |
6542 | ||
6543 | ||
6544 | // Computes if the given event is allowed to be dragged by the user | |
6545 | isEventDraggable: function(event) { | |
6546 | var source = event.source || {}; | |
6547 | ||
6548 | return firstDefined( | |
6549 | event.startEditable, | |
6550 | source.startEditable, | |
6551 | this.opt('eventStartEditable'), | |
6552 | event.editable, | |
6553 | source.editable, | |
6554 | this.opt('editable') | |
6555 | ); | |
6556 | }, | |
6557 | ||
6558 | ||
6559 | // Must be called when an event in the view is dropped onto new location. | |
6560 | // `dropLocation` is an object that contains the new start/end/allDay values for the event. | |
6561 | reportEventDrop: function(event, dropLocation, el, ev) { | |
6562 | var calendar = this.calendar; | |
6563 | var mutateResult = calendar.mutateEvent(event, dropLocation); | |
6564 | var undoFunc = function() { | |
6565 | mutateResult.undo(); | |
6566 | calendar.reportEventChange(); | |
6567 | }; | |
6568 | ||
6569 | this.triggerEventDrop(event, mutateResult.dateDelta, undoFunc, el, ev); | |
6570 | calendar.reportEventChange(); // will rerender events | |
6571 | }, | |
6572 | ||
6573 | ||
6574 | // Triggers event-drop handlers that have subscribed via the API | |
6575 | triggerEventDrop: function(event, dateDelta, undoFunc, el, ev) { | |
6576 | this.trigger('eventDrop', el[0], event, dateDelta, undoFunc, ev, {}); // {} = jqui dummy | |
6577 | }, | |
6578 | ||
6579 | ||
6580 | /* External Element Drag-n-Drop | |
6581 | ------------------------------------------------------------------------------------------------------------------*/ | |
6582 | ||
6583 | ||
6584 | // Must be called when an external element, via jQuery UI, has been dropped onto the calendar. | |
6585 | // `meta` is the parsed data that has been embedded into the dragging event. | |
6586 | // `dropLocation` is an object that contains the new start/end/allDay values for the event. | |
6587 | reportExternalDrop: function(meta, dropLocation, el, ev, ui) { | |
6588 | var eventProps = meta.eventProps; | |
6589 | var eventInput; | |
6590 | var event; | |
6591 | ||
6592 | // Try to build an event object and render it. TODO: decouple the two | |
6593 | if (eventProps) { | |
6594 | eventInput = $.extend({}, eventProps, dropLocation); | |
6595 | event = this.calendar.renderEvent(eventInput, meta.stick)[0]; // renderEvent returns an array | |
6596 | } | |
6597 | ||
6598 | this.triggerExternalDrop(event, dropLocation, el, ev, ui); | |
6599 | }, | |
6600 | ||
6601 | ||
6602 | // Triggers external-drop handlers that have subscribed via the API | |
6603 | triggerExternalDrop: function(event, dropLocation, el, ev, ui) { | |
6604 | ||
6605 | // trigger 'drop' regardless of whether element represents an event | |
6606 | this.trigger('drop', el[0], dropLocation.start, ev, ui); | |
6607 | ||
6608 | if (event) { | |
6609 | this.trigger('eventReceive', null, event); // signal an external event landed | |
6610 | } | |
6611 | }, | |
6612 | ||
6613 | ||
6614 | /* Drag-n-Drop Rendering (for both events and external elements) | |
6615 | ------------------------------------------------------------------------------------------------------------------*/ | |
6616 | ||
6617 | ||
6618 | // Renders a visual indication of a event or external-element drag over the given drop zone. | |
6619 | // If an external-element, seg will be `null` | |
6620 | renderDrag: function(dropLocation, seg) { | |
6621 | // subclasses must implement | |
6622 | }, | |
6623 | ||
6624 | ||
6625 | // Unrenders a visual indication of an event or external-element being dragged. | |
6626 | destroyDrag: function() { | |
6627 | // subclasses must implement | |
6628 | }, | |
6629 | ||
6630 | ||
6631 | /* Event Resizing | |
6632 | ------------------------------------------------------------------------------------------------------------------*/ | |
6633 | ||
6634 | ||
6635 | // Computes if the given event is allowed to be resize by the user | |
6636 | isEventResizable: function(event) { | |
6637 | var source = event.source || {}; | |
6638 | ||
6639 | return firstDefined( | |
6640 | event.durationEditable, | |
6641 | source.durationEditable, | |
6642 | this.opt('eventDurationEditable'), | |
6643 | event.editable, | |
6644 | source.editable, | |
6645 | this.opt('editable') | |
6646 | ); | |
6647 | }, | |
6648 | ||
6649 | ||
6650 | // Must be called when an event in the view has been resized to a new length | |
6651 | reportEventResize: function(event, newEnd, el, ev) { | |
6652 | var calendar = this.calendar; | |
6653 | var mutateResult = calendar.mutateEvent(event, { end: newEnd }); | |
6654 | var undoFunc = function() { | |
6655 | mutateResult.undo(); | |
6656 | calendar.reportEventChange(); | |
6657 | }; | |
6658 | ||
6659 | this.triggerEventResize(event, mutateResult.durationDelta, undoFunc, el, ev); | |
6660 | calendar.reportEventChange(); // will rerender events | |
6661 | }, | |
6662 | ||
6663 | ||
6664 | // Triggers event-resize handlers that have subscribed via the API | |
6665 | triggerEventResize: function(event, durationDelta, undoFunc, el, ev) { | |
6666 | this.trigger('eventResize', el[0], event, durationDelta, undoFunc, ev, {}); // {} = jqui dummy | |
6667 | }, | |
6668 | ||
6669 | ||
6670 | /* Selection | |
6671 | ------------------------------------------------------------------------------------------------------------------*/ | |
6672 | ||
6673 | ||
6674 | // Selects a date range on the view. `start` and `end` are both Moments. | |
6675 | // `ev` is the native mouse event that begin the interaction. | |
6676 | select: function(range, ev) { | |
6677 | this.unselect(ev); | |
6678 | this.renderSelection(range); | |
6679 | this.reportSelection(range, ev); | |
6680 | }, | |
6681 | ||
6682 | ||
6683 | // Renders a visual indication of the selection | |
6684 | renderSelection: function(range) { | |
6685 | // subclasses should implement | |
6686 | }, | |
6687 | ||
6688 | ||
6689 | // Called when a new selection is made. Updates internal state and triggers handlers. | |
6690 | reportSelection: function(range, ev) { | |
6691 | this.isSelected = true; | |
6692 | this.trigger('select', null, range.start, range.end, ev); | |
6693 | }, | |
6694 | ||
6695 | ||
6696 | // Undoes a selection. updates in the internal state and triggers handlers. | |
6697 | // `ev` is the native mouse event that began the interaction. | |
6698 | unselect: function(ev) { | |
6699 | if (this.isSelected) { | |
6700 | this.isSelected = false; | |
6701 | this.destroySelection(); | |
6702 | this.trigger('unselect', null, ev); | |
6703 | } | |
6704 | }, | |
6705 | ||
6706 | ||
6707 | // Unrenders a visual indication of selection | |
6708 | destroySelection: function() { | |
6709 | // subclasses should implement | |
6710 | }, | |
6711 | ||
6712 | ||
6713 | // Handler for unselecting when the user clicks something and the 'unselectAuto' setting is on | |
6714 | documentMousedown: function(ev) { | |
6715 | var ignore; | |
6716 | ||
6717 | // is there a selection, and has the user made a proper left click? | |
6718 | if (this.isSelected && this.opt('unselectAuto') && isPrimaryMouseButton(ev)) { | |
6719 | ||
6720 | // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element | |
6721 | ignore = this.opt('unselectCancel'); | |
6722 | if (!ignore || !$(ev.target).closest(ignore).length) { | |
6723 | this.unselect(ev); | |
6724 | } | |
6725 | } | |
6726 | }, | |
6727 | ||
6728 | ||
6729 | /* Date Utils | |
6730 | ------------------------------------------------------------------------------------------------------------------*/ | |
6731 | ||
6732 | ||
6733 | // Initializes internal variables related to calculating hidden days-of-week | |
6734 | initHiddenDays: function() { | |
6735 | var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden | |
6736 | var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool) | |
6737 | var dayCnt = 0; | |
6738 | var i; | |
6739 | ||
6740 | if (this.opt('weekends') === false) { | |
6741 | hiddenDays.push(0, 6); // 0=sunday, 6=saturday | |
6742 | } | |
6743 | ||
6744 | for (i = 0; i < 7; i++) { | |
6745 | if ( | |
6746 | !(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1) | |
6747 | ) { | |
6748 | dayCnt++; | |
6749 | } | |
6750 | } | |
6751 | ||
6752 | if (!dayCnt) { | |
6753 | throw 'invalid hiddenDays'; // all days were hidden? bad. | |
6754 | } | |
6755 | ||
6756 | this.isHiddenDayHash = isHiddenDayHash; | |
6757 | }, | |
6758 | ||
6759 | ||
6760 | // Is the current day hidden? | |
6761 | // `day` is a day-of-week index (0-6), or a Moment | |
6762 | isHiddenDay: function(day) { | |
6763 | if (moment.isMoment(day)) { | |
6764 | day = day.day(); | |
6765 | } | |
6766 | return this.isHiddenDayHash[day]; | |
6767 | }, | |
6768 | ||
6769 | ||
6770 | // Incrementing the current day until it is no longer a hidden day, returning a copy. | |
6771 | // If the initial value of `date` is not a hidden day, don't do anything. | |
6772 | // Pass `isExclusive` as `true` if you are dealing with an end date. | |
6773 | // `inc` defaults to `1` (increment one day forward each time) | |
6774 | skipHiddenDays: function(date, inc, isExclusive) { | |
6775 | var out = date.clone(); | |
6776 | inc = inc || 1; | |
6777 | while ( | |
6778 | this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7] | |
6779 | ) { | |
6780 | out.add(inc, 'days'); | |
6781 | } | |
6782 | return out; | |
6783 | }, | |
6784 | ||
6785 | ||
6786 | // Returns the date range of the full days the given range visually appears to occupy. | |
6787 | // Returns a new range object. | |
6788 | computeDayRange: function(range) { | |
6789 | var startDay = range.start.clone().stripTime(); // the beginning of the day the range starts | |
6790 | var end = range.end; | |
6791 | var endDay = null; | |
6792 | var endTimeMS; | |
6793 | ||
6794 | if (end) { | |
6795 | endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends | |
6796 | endTimeMS = +end.time(); // # of milliseconds into `endDay` | |
6797 | ||
6798 | // If the end time is actually inclusively part of the next day and is equal to or | |
6799 | // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`. | |
6800 | // Otherwise, leaving it as inclusive will cause it to exclude `endDay`. | |
6801 | if (endTimeMS && endTimeMS >= this.nextDayThreshold) { | |
6802 | endDay.add(1, 'days'); | |
6803 | } | |
6804 | } | |
6805 | ||
6806 | // If no end was specified, or if it is within `startDay` but not past nextDayThreshold, | |
6807 | // assign the default duration of one day. | |
6808 | if (!end || endDay <= startDay) { | |
6809 | endDay = startDay.clone().add(1, 'days'); | |
6810 | } | |
6811 | ||
6812 | return { start: startDay, end: endDay }; | |
6813 | }, | |
6814 | ||
6815 | ||
6816 | // Does the given event visually appear to occupy more than one day? | |
6817 | isMultiDayEvent: function(event) { | |
6818 | var range = this.computeDayRange(event); // event is range-ish | |
6819 | ||
6820 | return range.end.diff(range.start, 'days') > 1; | |
6821 | } | |
6822 | ||
6823 | }); | |
6824 | ||
6825 | function Calendar(element, instanceOptions) { | |
6826 | var t = this; | |
6827 | ||
6828 | ||
6829 | ||
6830 | // Build options object | |
6831 | // ----------------------------------------------------------------------------------- | |
6832 | // Precedence (lowest to highest): defaults, rtlDefaults, langOptions, instanceOptions | |
6833 | ||
6834 | instanceOptions = instanceOptions || {}; | |
6835 | ||
6836 | var options = mergeOptions({}, defaults, instanceOptions); | |
6837 | var langOptions; | |
6838 | ||
6839 | // determine language options | |
6840 | if (options.lang in langOptionHash) { | |
6841 | langOptions = langOptionHash[options.lang]; | |
6842 | } | |
6843 | else { | |
6844 | langOptions = langOptionHash[defaults.lang]; | |
6845 | } | |
6846 | ||
6847 | if (langOptions) { // if language options exist, rebuild... | |
6848 | options = mergeOptions({}, defaults, langOptions, instanceOptions); | |
6849 | } | |
6850 | ||
6851 | if (options.isRTL) { // is isRTL, rebuild... | |
6852 | options = mergeOptions({}, defaults, rtlDefaults, langOptions || {}, instanceOptions); | |
6853 | } | |
6854 | ||
6855 | ||
6856 | ||
6857 | // Exports | |
6858 | // ----------------------------------------------------------------------------------- | |
6859 | ||
6860 | t.options = options; | |
6861 | t.render = render; | |
6862 | t.destroy = destroy; | |
6863 | t.refetchEvents = refetchEvents; | |
6864 | t.reportEvents = reportEvents; | |
6865 | t.reportEventChange = reportEventChange; | |
6866 | t.rerenderEvents = renderEvents; // `renderEvents` serves as a rerender. an API method | |
6867 | t.changeView = changeView; | |
6868 | t.select = select; | |
6869 | t.unselect = unselect; | |
6870 | t.prev = prev; | |
6871 | t.next = next; | |
6872 | t.prevYear = prevYear; | |
6873 | t.nextYear = nextYear; | |
6874 | t.today = today; | |
6875 | t.gotoDate = gotoDate; | |
6876 | t.incrementDate = incrementDate; | |
6877 | t.zoomTo = zoomTo; | |
6878 | t.getDate = getDate; | |
6879 | t.getCalendar = getCalendar; | |
6880 | t.getView = getView; | |
6881 | t.option = option; | |
6882 | t.trigger = trigger; | |
6883 | t.isValidViewType = isValidViewType; | |
6884 | t.getViewButtonText = getViewButtonText; | |
6885 | ||
6886 | ||
6887 | ||
6888 | // Language-data Internals | |
6889 | // ----------------------------------------------------------------------------------- | |
6890 | // Apply overrides to the current language's data | |
6891 | ||
6892 | ||
6893 | var localeData = createObject( // make a cheap copy | |
6894 | getMomentLocaleData(options.lang) // will fall back to en | |
6895 | ); | |
6896 | ||
6897 | if (options.monthNames) { | |
6898 | localeData._months = options.monthNames; | |
6899 | } | |
6900 | if (options.monthNamesShort) { | |
6901 | localeData._monthsShort = options.monthNamesShort; | |
6902 | } | |
6903 | if (options.dayNames) { | |
6904 | localeData._weekdays = options.dayNames; | |
6905 | } | |
6906 | if (options.dayNamesShort) { | |
6907 | localeData._weekdaysShort = options.dayNamesShort; | |
6908 | } | |
6909 | if (options.firstDay != null) { | |
6910 | var _week = createObject(localeData._week); // _week: { dow: # } | |
6911 | _week.dow = options.firstDay; | |
6912 | localeData._week = _week; | |
6913 | } | |
6914 | ||
6915 | ||
6916 | ||
6917 | // Calendar-specific Date Utilities | |
6918 | // ----------------------------------------------------------------------------------- | |
6919 | ||
6920 | ||
6921 | t.defaultAllDayEventDuration = moment.duration(options.defaultAllDayEventDuration); | |
6922 | t.defaultTimedEventDuration = moment.duration(options.defaultTimedEventDuration); | |
6923 | ||
6924 | ||
6925 | // Builds a moment using the settings of the current calendar: timezone and language. | |
6926 | // Accepts anything the vanilla moment() constructor accepts. | |
6927 | t.moment = function() { | |
6928 | var mom; | |
6929 | ||
6930 | if (options.timezone === 'local') { | |
6931 | mom = fc.moment.apply(null, arguments); | |
6932 | ||
6933 | // Force the moment to be local, because fc.moment doesn't guarantee it. | |
6934 | if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone | |
6935 | mom.local(); | |
6936 | } | |
6937 | } | |
6938 | else if (options.timezone === 'UTC') { | |
6939 | mom = fc.moment.utc.apply(null, arguments); // process as UTC | |
6940 | } | |
6941 | else { | |
6942 | mom = fc.moment.parseZone.apply(null, arguments); // let the input decide the zone | |
6943 | } | |
6944 | ||
6945 | if ('_locale' in mom) { // moment 2.8 and above | |
6946 | mom._locale = localeData; | |
6947 | } | |
6948 | else { // pre-moment-2.8 | |
6949 | mom._lang = localeData; | |
6950 | } | |
6951 | ||
6952 | return mom; | |
6953 | }; | |
6954 | ||
6955 | ||
6956 | // Returns a boolean about whether or not the calendar knows how to calculate | |
6957 | // the timezone offset of arbitrary dates in the current timezone. | |
6958 | t.getIsAmbigTimezone = function() { | |
6959 | return options.timezone !== 'local' && options.timezone !== 'UTC'; | |
6960 | }; | |
6961 | ||
6962 | ||
6963 | // Returns a copy of the given date in the current timezone of it is ambiguously zoned. | |
6964 | // This will also give the date an unambiguous time. | |
6965 | t.rezoneDate = function(date) { | |
6966 | return t.moment(date.toArray()); | |
6967 | }; | |
6968 | ||
6969 | ||
6970 | // Returns a moment for the current date, as defined by the client's computer, | |
6971 | // or overridden by the `now` option. | |
6972 | t.getNow = function() { | |
6973 | var now = options.now; | |
6974 | if (typeof now === 'function') { | |
6975 | now = now(); | |
6976 | } | |
6977 | return t.moment(now); | |
6978 | }; | |
6979 | ||
6980 | ||
6981 | // Calculates the week number for a moment according to the calendar's | |
6982 | // `weekNumberCalculation` setting. | |
6983 | t.calculateWeekNumber = function(mom) { | |
6984 | var calc = options.weekNumberCalculation; | |
6985 | ||
6986 | if (typeof calc === 'function') { | |
6987 | return calc(mom); | |
6988 | } | |
6989 | else if (calc === 'local') { | |
6990 | return mom.week(); | |
6991 | } | |
6992 | else if (calc.toUpperCase() === 'ISO') { | |
6993 | return mom.isoWeek(); | |
6994 | } | |
6995 | }; | |
6996 | ||
6997 | ||
6998 | // Get an event's normalized end date. If not present, calculate it from the defaults. | |
6999 | t.getEventEnd = function(event) { | |
7000 | if (event.end) { | |
7001 | return event.end.clone(); | |
7002 | } | |
7003 | else { | |
7004 | return t.getDefaultEventEnd(event.allDay, event.start); | |
7005 | } | |
7006 | }; | |
7007 | ||
7008 | ||
7009 | // Given an event's allDay status and start date, return swhat its fallback end date should be. | |
7010 | t.getDefaultEventEnd = function(allDay, start) { // TODO: rename to computeDefaultEventEnd | |
7011 | var end = start.clone(); | |
7012 | ||
7013 | if (allDay) { | |
7014 | end.stripTime().add(t.defaultAllDayEventDuration); | |
7015 | } | |
7016 | else { | |
7017 | end.add(t.defaultTimedEventDuration); | |
7018 | } | |
7019 | ||
7020 | if (t.getIsAmbigTimezone()) { | |
7021 | end.stripZone(); // we don't know what the tzo should be | |
7022 | } | |
7023 | ||
7024 | return end; | |
7025 | }; | |
7026 | ||
7027 | ||
7028 | // Produces a human-readable string for the given duration. | |
7029 | // Side-effect: changes the locale of the given duration. | |
7030 | function humanizeDuration(duration) { | |
7031 | return (duration.locale || duration.lang).call(duration, options.lang) // works moment-pre-2.8 | |
7032 | .humanize(); | |
7033 | } | |
7034 | ||
7035 | ||
7036 | ||
7037 | // Imports | |
7038 | // ----------------------------------------------------------------------------------- | |
7039 | ||
7040 | ||
7041 | EventManager.call(t, options); | |
7042 | var isFetchNeeded = t.isFetchNeeded; | |
7043 | var fetchEvents = t.fetchEvents; | |
7044 | ||
7045 | ||
7046 | ||
7047 | // Locals | |
7048 | // ----------------------------------------------------------------------------------- | |
7049 | ||
7050 | ||
7051 | var _element = element[0]; | |
7052 | var header; | |
7053 | var headerElement; | |
7054 | var content; | |
7055 | var tm; // for making theme classes | |
7056 | var viewSpecCache = {}; | |
7057 | var currentView; | |
7058 | var suggestedViewHeight; | |
7059 | var windowResizeProxy; // wraps the windowResize function | |
7060 | var ignoreWindowResize = 0; | |
7061 | var date; | |
7062 | var events = []; | |
7063 | ||
7064 | ||
7065 | ||
7066 | // Main Rendering | |
7067 | // ----------------------------------------------------------------------------------- | |
7068 | ||
7069 | ||
7070 | if (options.defaultDate != null) { | |
7071 | date = t.moment(options.defaultDate); | |
7072 | } | |
7073 | else { | |
7074 | date = t.getNow(); | |
7075 | } | |
7076 | ||
7077 | ||
7078 | function render(inc) { | |
7079 | if (!content) { | |
7080 | initialRender(); | |
7081 | } | |
7082 | else if (elementVisible()) { | |
7083 | // mainly for the public API | |
7084 | calcSize(); | |
7085 | renderView(inc); | |
7086 | } | |
7087 | } | |
7088 | ||
7089 | ||
7090 | function initialRender() { | |
7091 | tm = options.theme ? 'ui' : 'fc'; | |
7092 | element.addClass('fc'); | |
7093 | ||
7094 | if (options.isRTL) { | |
7095 | element.addClass('fc-rtl'); | |
7096 | } | |
7097 | else { | |
7098 | element.addClass('fc-ltr'); | |
7099 | } | |
7100 | ||
7101 | if (options.theme) { | |
7102 | element.addClass('ui-widget'); | |
7103 | } | |
7104 | else { | |
7105 | element.addClass('fc-unthemed'); | |
7106 | } | |
7107 | ||
7108 | content = $("<div class='fc-view-container'/>").prependTo(element); | |
7109 | ||
7110 | header = new Header(t, options); | |
7111 | headerElement = header.render(); | |
7112 | if (headerElement) { | |
7113 | element.prepend(headerElement); | |
7114 | } | |
7115 | ||
7116 | changeView(options.defaultView); | |
7117 | ||
7118 | if (options.handleWindowResize) { | |
7119 | windowResizeProxy = debounce(windowResize, options.windowResizeDelay); // prevents rapid calls | |
7120 | $(window).resize(windowResizeProxy); | |
7121 | } | |
7122 | } | |
7123 | ||
7124 | ||
7125 | function destroy() { | |
7126 | ||
7127 | if (currentView) { | |
7128 | currentView.destroyView(); | |
7129 | } | |
7130 | ||
7131 | header.destroy(); | |
7132 | content.remove(); | |
7133 | element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget'); | |
7134 | ||
7135 | $(window).unbind('resize', windowResizeProxy); | |
7136 | } | |
7137 | ||
7138 | ||
7139 | function elementVisible() { | |
7140 | return element.is(':visible'); | |
7141 | } | |
7142 | ||
7143 | ||
7144 | ||
7145 | // View Rendering | |
7146 | // ----------------------------------------------------------------------------------- | |
7147 | ||
7148 | ||
7149 | function changeView(viewType) { | |
7150 | renderView(0, viewType); | |
7151 | } | |
7152 | ||
7153 | ||
7154 | // Renders a view because of a date change, view-type change, or for the first time | |
7155 | function renderView(delta, viewType) { | |
7156 | ignoreWindowResize++; | |
7157 | ||
7158 | // if viewType is changing, destroy the old view | |
7159 | if (currentView && viewType && currentView.type !== viewType) { | |
7160 | header.deactivateButton(currentView.type); | |
7161 | freezeContentHeight(); // prevent a scroll jump when view element is removed | |
7162 | if (currentView.start) { // rendered before? | |
7163 | currentView.destroyView(); | |
7164 | } | |
7165 | currentView.el.remove(); | |
7166 | currentView = null; | |
7167 | } | |
7168 | ||
7169 | // if viewType changed, or the view was never created, create a fresh view | |
7170 | if (!currentView && viewType) { | |
7171 | currentView = instantiateView(viewType); | |
7172 | currentView.el = $("<div class='fc-view fc-" + viewType + "-view' />").appendTo(content); | |
7173 | header.activateButton(viewType); | |
7174 | } | |
7175 | ||
7176 | if (currentView) { | |
7177 | ||
7178 | // let the view determine what the delta means | |
7179 | if (delta < 0) { | |
7180 | date = currentView.computePrevDate(date); | |
7181 | } | |
7182 | else if (delta > 0) { | |
7183 | date = currentView.computeNextDate(date); | |
7184 | } | |
7185 | ||
7186 | // render or rerender the view | |
7187 | if ( | |
7188 | !currentView.start || // never rendered before | |
7189 | delta || // explicit date window change | |
7190 | !date.isWithin(currentView.intervalStart, currentView.intervalEnd) // implicit date window change | |
7191 | ) { | |
7192 | if (elementVisible()) { | |
7193 | ||
7194 | freezeContentHeight(); | |
7195 | if (currentView.start) { // rendered before? | |
7196 | currentView.destroyView(); | |
7197 | } | |
7198 | currentView.setDate(date); | |
7199 | currentView.renderView(); | |
7200 | unfreezeContentHeight(); | |
7201 | ||
7202 | // need to do this after View::render, so dates are calculated | |
7203 | updateTitle(); | |
7204 | updateTodayButton(); | |
7205 | ||
7206 | getAndRenderEvents(); | |
7207 | } | |
7208 | } | |
7209 | } | |
7210 | ||
7211 | unfreezeContentHeight(); // undo any lone freezeContentHeight calls | |
7212 | ignoreWindowResize--; | |
7213 | } | |
7214 | ||
7215 | ||
7216 | ||
7217 | // View Instantiation | |
7218 | // ----------------------------------------------------------------------------------- | |
7219 | ||
7220 | ||
7221 | // Given a view name for a custom view or a standard view, creates a ready-to-go View object | |
7222 | function instantiateView(viewType) { | |
7223 | var spec = getViewSpec(viewType); | |
7224 | ||
7225 | return new spec['class'](t, spec.options, viewType); | |
7226 | } | |
7227 | ||
7228 | ||
7229 | // Gets information about how to create a view | |
7230 | function getViewSpec(requestedViewType) { | |
7231 | var allDefaultButtonText = options.defaultButtonText || {}; | |
7232 | var allButtonText = options.buttonText || {}; | |
7233 | var hash = options.views || {}; // the `views` option object | |
7234 | var viewType = requestedViewType; | |
7235 | var viewOptionsChain = []; | |
7236 | var viewOptions; | |
7237 | var viewClass; | |
7238 | var duration, unit, unitIsSingle = false; | |
7239 | var buttonText; | |
7240 | ||
7241 | if (viewSpecCache[requestedViewType]) { | |
7242 | return viewSpecCache[requestedViewType]; | |
7243 | } | |
7244 | ||
7245 | function processSpecInput(input) { | |
7246 | if (typeof input === 'function') { | |
7247 | viewClass = input; | |
7248 | } | |
7249 | else if (typeof input === 'object') { | |
7250 | $.extend(viewOptions, input); | |
7251 | } | |
7252 | } | |
7253 | ||
7254 | // iterate up a view's spec ancestor chain util we find a class to instantiate | |
7255 | while (viewType && !viewClass) { | |
7256 | viewOptions = {}; // only for this specific view in the ancestry | |
7257 | processSpecInput(fcViews[viewType]); // $.fullCalendar.views, lower precedence | |
7258 | processSpecInput(hash[viewType]); // options at initialization, higher precedence | |
7259 | viewOptionsChain.unshift(viewOptions); // record older ancestors first | |
7260 | viewType = viewOptions.type; | |
7261 | } | |
7262 | ||
7263 | viewOptionsChain.unshift({}); // jQuery's extend needs at least one arg | |
7264 | viewOptions = $.extend.apply($, viewOptionsChain); // combine all, newer ancestors overwritting old | |
7265 | ||
7266 | if (viewClass) { | |
7267 | ||
7268 | duration = viewOptions.duration || viewClass.duration; | |
7269 | if (duration) { | |
7270 | duration = moment.duration(duration); | |
7271 | unit = computeIntervalUnit(duration); | |
7272 | unitIsSingle = computeIntervalAs(unit, duration) === 1; | |
7273 | } | |
7274 | ||
7275 | // options that are specified per the view's duration, like "week" or "day" | |
7276 | if (unitIsSingle && hash[unit]) { | |
7277 | viewOptions = $.extend({}, hash[unit], viewOptions); // lowest priority | |
7278 | } | |
7279 | ||
7280 | // compute the final text for the button representing this view | |
7281 | buttonText = | |
7282 | allButtonText[requestedViewType] || // init options, like "agendaWeek" | |
7283 | (unitIsSingle ? allButtonText[unit] : null) || // init options, like "week" | |
7284 | allDefaultButtonText[requestedViewType] || // lang data, like "agendaWeek" | |
7285 | (unitIsSingle ? allDefaultButtonText[unit] : null) || // lang data, like "week" | |
7286 | viewOptions.buttonText || | |
7287 | viewClass.buttonText || | |
7288 | (duration ? humanizeDuration(duration) : null) || | |
7289 | requestedViewType; | |
7290 | ||
7291 | return (viewSpecCache[requestedViewType] = { | |
7292 | 'class': viewClass, | |
7293 | options: viewOptions, | |
7294 | buttonText: buttonText | |
7295 | }); | |
7296 | } | |
7297 | } | |
7298 | ||
7299 | ||
7300 | // Returns a boolean about whether the view is okay to instantiate at some point | |
7301 | function isValidViewType(viewType) { | |
7302 | return Boolean(getViewSpec(viewType)); | |
7303 | } | |
7304 | ||
7305 | ||
7306 | // Gets the text that should be displayed on a view's button in the header | |
7307 | function getViewButtonText(viewType) { | |
7308 | var spec = getViewSpec(viewType); | |
7309 | ||
7310 | if (spec) { | |
7311 | return spec.buttonText; | |
7312 | } | |
7313 | } | |
7314 | ||
7315 | ||
7316 | ||
7317 | // Resizing | |
7318 | // ----------------------------------------------------------------------------------- | |
7319 | ||
7320 | ||
7321 | t.getSuggestedViewHeight = function() { | |
7322 | if (suggestedViewHeight === undefined) { | |
7323 | calcSize(); | |
7324 | } | |
7325 | return suggestedViewHeight; | |
7326 | }; | |
7327 | ||
7328 | ||
7329 | t.isHeightAuto = function() { | |
7330 | return options.contentHeight === 'auto' || options.height === 'auto'; | |
7331 | }; | |
7332 | ||
7333 | ||
7334 | function updateSize(shouldRecalc) { | |
7335 | if (elementVisible()) { | |
7336 | ||
7337 | if (shouldRecalc) { | |
7338 | _calcSize(); | |
7339 | } | |
7340 | ||
7341 | ignoreWindowResize++; | |
7342 | currentView.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto() | |
7343 | ignoreWindowResize--; | |
7344 | ||
7345 | return true; // signal success | |
7346 | } | |
7347 | } | |
7348 | ||
7349 | ||
7350 | function calcSize() { | |
7351 | if (elementVisible()) { | |
7352 | _calcSize(); | |
7353 | } | |
7354 | } | |
7355 | ||
7356 | ||
7357 | function _calcSize() { // assumes elementVisible | |
7358 | if (typeof options.contentHeight === 'number') { // exists and not 'auto' | |
7359 | suggestedViewHeight = options.contentHeight; | |
7360 | } | |
7361 | else if (typeof options.height === 'number') { // exists and not 'auto' | |
7362 | suggestedViewHeight = options.height - (headerElement ? headerElement.outerHeight(true) : 0); | |
7363 | } | |
7364 | else { | |
7365 | suggestedViewHeight = Math.round(content.width() / Math.max(options.aspectRatio, .5)); | |
7366 | } | |
7367 | } | |
7368 | ||
7369 | ||
7370 | function windowResize(ev) { | |
7371 | if ( | |
7372 | !ignoreWindowResize && | |
7373 | ev.target === window && // so we don't process jqui "resize" events that have bubbled up | |
7374 | currentView.start // view has already been rendered | |
7375 | ) { | |
7376 | if (updateSize(true)) { | |
7377 | currentView.trigger('windowResize', _element); | |
7378 | } | |
7379 | } | |
7380 | } | |
7381 | ||
7382 | ||
7383 | ||
7384 | /* Event Fetching/Rendering | |
7385 | -----------------------------------------------------------------------------*/ | |
7386 | // TODO: going forward, most of this stuff should be directly handled by the view | |
7387 | ||
7388 | ||
7389 | function refetchEvents() { // can be called as an API method | |
7390 | destroyEvents(); // so that events are cleared before user starts waiting for AJAX | |
7391 | fetchAndRenderEvents(); | |
7392 | } | |
7393 | ||
7394 | ||
7395 | function renderEvents() { // destroys old events if previously rendered | |
7396 | if (elementVisible()) { | |
7397 | freezeContentHeight(); | |
7398 | currentView.destroyViewEvents(); // no performance cost if never rendered | |
7399 | currentView.renderViewEvents(events); | |
7400 | unfreezeContentHeight(); | |
7401 | } | |
7402 | } | |
7403 | ||
7404 | ||
7405 | function destroyEvents() { | |
7406 | freezeContentHeight(); | |
7407 | currentView.destroyViewEvents(); | |
7408 | unfreezeContentHeight(); | |
7409 | } | |
7410 | ||
7411 | ||
7412 | function getAndRenderEvents() { | |
7413 | if (!options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) { | |
7414 | fetchAndRenderEvents(); | |
7415 | } | |
7416 | else { | |
7417 | renderEvents(); | |
7418 | } | |
7419 | } | |
7420 | ||
7421 | ||
7422 | function fetchAndRenderEvents() { | |
7423 | fetchEvents(currentView.start, currentView.end); | |
7424 | // ... will call reportEvents | |
7425 | // ... which will call renderEvents | |
7426 | } | |
7427 | ||
7428 | ||
7429 | // called when event data arrives | |
7430 | function reportEvents(_events) { | |
7431 | events = _events; | |
7432 | renderEvents(); | |
7433 | } | |
7434 | ||
7435 | ||
7436 | // called when a single event's data has been changed | |
7437 | function reportEventChange() { | |
7438 | renderEvents(); | |
7439 | } | |
7440 | ||
7441 | ||
7442 | ||
7443 | /* Header Updating | |
7444 | -----------------------------------------------------------------------------*/ | |
7445 | ||
7446 | ||
7447 | function updateTitle() { | |
7448 | header.updateTitle(currentView.computeTitle()); | |
7449 | } | |
7450 | ||
7451 | ||
7452 | function updateTodayButton() { | |
7453 | var now = t.getNow(); | |
7454 | if (now.isWithin(currentView.intervalStart, currentView.intervalEnd)) { | |
7455 | header.disableButton('today'); | |
7456 | } | |
7457 | else { | |
7458 | header.enableButton('today'); | |
7459 | } | |
7460 | } | |
7461 | ||
7462 | ||
7463 | ||
7464 | /* Selection | |
7465 | -----------------------------------------------------------------------------*/ | |
7466 | ||
7467 | ||
7468 | function select(start, end) { | |
7469 | ||
7470 | start = t.moment(start); | |
7471 | if (end) { | |
7472 | end = t.moment(end); | |
7473 | } | |
7474 | else if (start.hasTime()) { | |
7475 | end = start.clone().add(t.defaultTimedEventDuration); | |
7476 | } | |
7477 | else { | |
7478 | end = start.clone().add(t.defaultAllDayEventDuration); | |
7479 | } | |
7480 | ||
7481 | currentView.select({ start: start, end: end }); // accepts a range | |
7482 | } | |
7483 | ||
7484 | ||
7485 | function unselect() { // safe to be called before renderView | |
7486 | if (currentView) { | |
7487 | currentView.unselect(); | |
7488 | } | |
7489 | } | |
7490 | ||
7491 | ||
7492 | ||
7493 | /* Date | |
7494 | -----------------------------------------------------------------------------*/ | |
7495 | ||
7496 | ||
7497 | function prev() { | |
7498 | renderView(-1); | |
7499 | } | |
7500 | ||
7501 | ||
7502 | function next() { | |
7503 | renderView(1); | |
7504 | } | |
7505 | ||
7506 | ||
7507 | function prevYear() { | |
7508 | date.add(-1, 'years'); | |
7509 | renderView(); | |
7510 | } | |
7511 | ||
7512 | ||
7513 | function nextYear() { | |
7514 | date.add(1, 'years'); | |
7515 | renderView(); | |
7516 | } | |
7517 | ||
7518 | ||
7519 | function today() { | |
7520 | date = t.getNow(); | |
7521 | renderView(); | |
7522 | } | |
7523 | ||
7524 | ||
7525 | function gotoDate(dateInput) { | |
7526 | date = t.moment(dateInput); | |
7527 | renderView(); | |
7528 | } | |
7529 | ||
7530 | ||
7531 | function incrementDate(delta) { | |
7532 | date.add(moment.duration(delta)); | |
7533 | renderView(); | |
7534 | } | |
7535 | ||
7536 | ||
7537 | // Forces navigation to a view for the given date. | |
7538 | // `viewType` can be a specific view name or a generic one like "week" or "day". | |
7539 | function zoomTo(newDate, viewType) { | |
7540 | var viewStr; | |
7541 | var match; | |
7542 | ||
7543 | if (!viewType || !isValidViewType(viewType)) { // a general view name, or "auto" | |
7544 | viewType = viewType || 'day'; | |
7545 | viewStr = header.getViewsWithButtons().join(' '); // space-separated string of all the views in the header | |
7546 | ||
7547 | // try to match a general view name, like "week", against a specific one, like "agendaWeek" | |
7548 | match = viewStr.match(new RegExp('\\w+' + capitaliseFirstLetter(viewType))); | |
7549 | ||
7550 | // fall back to the day view being used in the header | |
7551 | if (!match) { | |
7552 | match = viewStr.match(/\w+Day/); | |
7553 | } | |
7554 | ||
7555 | viewType = match ? match[0] : 'agendaDay'; // fall back to agendaDay | |
7556 | } | |
7557 | ||
7558 | date = newDate; | |
7559 | changeView(viewType); | |
7560 | } | |
7561 | ||
7562 | ||
7563 | function getDate() { | |
7564 | return date.clone(); | |
7565 | } | |
7566 | ||
7567 | ||
7568 | ||
7569 | /* Height "Freezing" | |
7570 | -----------------------------------------------------------------------------*/ | |
7571 | ||
7572 | ||
7573 | function freezeContentHeight() { | |
7574 | content.css({ | |
7575 | width: '100%', | |
7576 | height: content.height(), | |
7577 | overflow: 'hidden' | |
7578 | }); | |
7579 | } | |
7580 | ||
7581 | ||
7582 | function unfreezeContentHeight() { | |
7583 | content.css({ | |
7584 | width: '', | |
7585 | height: '', | |
7586 | overflow: '' | |
7587 | }); | |
7588 | } | |
7589 | ||
7590 | ||
7591 | ||
7592 | /* Misc | |
7593 | -----------------------------------------------------------------------------*/ | |
7594 | ||
7595 | ||
7596 | function getCalendar() { | |
7597 | return t; | |
7598 | } | |
7599 | ||
7600 | ||
7601 | function getView() { | |
7602 | return currentView; | |
7603 | } | |
7604 | ||
7605 | ||
7606 | function option(name, value) { | |
7607 | if (value === undefined) { | |
7608 | return options[name]; | |
7609 | } | |
7610 | if (name == 'height' || name == 'contentHeight' || name == 'aspectRatio') { | |
7611 | options[name] = value; | |
7612 | updateSize(true); // true = allow recalculation of height | |
7613 | } | |
7614 | } | |
7615 | ||
7616 | ||
7617 | function trigger(name, thisObj) { | |
7618 | if (options[name]) { | |
7619 | return options[name].apply( | |
7620 | thisObj || _element, | |
7621 | Array.prototype.slice.call(arguments, 2) | |
7622 | ); | |
7623 | } | |
7624 | } | |
7625 | ||
7626 | } | |
7627 | ||
7628 | /* Top toolbar area with buttons and title | |
7629 | ----------------------------------------------------------------------------------------------------------------------*/ | |
7630 | // TODO: rename all header-related things to "toolbar" | |
7631 | ||
7632 | function Header(calendar, options) { | |
7633 | var t = this; | |
7634 | ||
7635 | // exports | |
7636 | t.render = render; | |
7637 | t.destroy = destroy; | |
7638 | t.updateTitle = updateTitle; | |
7639 | t.activateButton = activateButton; | |
7640 | t.deactivateButton = deactivateButton; | |
7641 | t.disableButton = disableButton; | |
7642 | t.enableButton = enableButton; | |
7643 | t.getViewsWithButtons = getViewsWithButtons; | |
7644 | ||
7645 | // locals | |
7646 | var el = $(); | |
7647 | var viewsWithButtons = []; | |
7648 | var tm; | |
7649 | ||
7650 | ||
7651 | function render() { | |
7652 | var sections = options.header; | |
7653 | ||
7654 | tm = options.theme ? 'ui' : 'fc'; | |
7655 | ||
7656 | if (sections) { | |
7657 | el = $("<div class='fc-toolbar'/>") | |
7658 | .append(renderSection('left')) | |
7659 | .append(renderSection('right')) | |
7660 | .append(renderSection('center')) | |
7661 | .append('<div class="fc-clear"/>'); | |
7662 | ||
7663 | return el; | |
7664 | } | |
7665 | } | |
7666 | ||
7667 | ||
7668 | function destroy() { | |
7669 | el.remove(); | |
7670 | } | |
7671 | ||
7672 | ||
7673 | function renderSection(position) { | |
7674 | var sectionEl = $('<div class="fc-' + position + '"/>'); | |
7675 | var buttonStr = options.header[position]; | |
7676 | ||
7677 | if (buttonStr) { | |
7678 | $.each(buttonStr.split(' '), function(i) { | |
7679 | var groupChildren = $(); | |
7680 | var isOnlyButtons = true; | |
7681 | var groupEl; | |
7682 | ||
7683 | $.each(this.split(','), function(j, buttonName) { | |
7684 | var buttonClick; | |
7685 | var themeIcon; | |
7686 | var normalIcon; | |
7687 | var defaultText; | |
7688 | var viewText; // highest priority | |
7689 | var customText; | |
7690 | var innerHtml; | |
7691 | var classes; | |
7692 | var button; | |
7693 | ||
7694 | if (buttonName == 'title') { | |
7695 | groupChildren = groupChildren.add($('<h2> </h2>')); // we always want it to take up height | |
7696 | isOnlyButtons = false; | |
7697 | } | |
7698 | else { | |
7699 | if (calendar[buttonName]) { // a calendar method | |
7700 | buttonClick = function() { | |
7701 | calendar[buttonName](); | |
7702 | }; | |
7703 | } | |
7704 | else if (calendar.isValidViewType(buttonName)) { // a view type | |
7705 | buttonClick = function() { | |
7706 | calendar.changeView(buttonName); | |
7707 | }; | |
7708 | viewsWithButtons.push(buttonName); | |
7709 | viewText = calendar.getViewButtonText(buttonName); | |
7710 | } | |
7711 | if (buttonClick) { | |
7712 | ||
7713 | // smartProperty allows different text per view button (ex: "Agenda Week" vs "Basic Week") | |
7714 | themeIcon = smartProperty(options.themeButtonIcons, buttonName); | |
7715 | normalIcon = smartProperty(options.buttonIcons, buttonName); | |
7716 | defaultText = smartProperty(options.defaultButtonText, buttonName); // from languages | |
7717 | customText = smartProperty(options.buttonText, buttonName); | |
7718 | ||
7719 | if (viewText || customText) { | |
7720 | innerHtml = htmlEscape(viewText || customText); | |
7721 | } | |
7722 | else if (themeIcon && options.theme) { | |
7723 | innerHtml = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>"; | |
7724 | } | |
7725 | else if (normalIcon && !options.theme) { | |
7726 | innerHtml = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>"; | |
7727 | } | |
7728 | else { | |
7729 | innerHtml = htmlEscape(defaultText || buttonName); | |
7730 | } | |
7731 | ||
7732 | classes = [ | |
7733 | 'fc-' + buttonName + '-button', | |
7734 | tm + '-button', | |
7735 | tm + '-state-default' | |
7736 | ]; | |
7737 | ||
7738 | button = $( // type="button" so that it doesn't submit a form | |
7739 | '<button type="button" class="' + classes.join(' ') + '">' + | |
7740 | innerHtml + | |
7741 | '</button>' | |
7742 | ) | |
7743 | .click(function() { | |
7744 | // don't process clicks for disabled buttons | |
7745 | if (!button.hasClass(tm + '-state-disabled')) { | |
7746 | ||
7747 | buttonClick(); | |
7748 | ||
7749 | // after the click action, if the button becomes the "active" tab, or disabled, | |
7750 | // it should never have a hover class, so remove it now. | |
7751 | if ( | |
7752 | button.hasClass(tm + '-state-active') || | |
7753 | button.hasClass(tm + '-state-disabled') | |
7754 | ) { | |
7755 | button.removeClass(tm + '-state-hover'); | |
7756 | } | |
7757 | } | |
7758 | }) | |
7759 | .mousedown(function() { | |
7760 | // the *down* effect (mouse pressed in). | |
7761 | // only on buttons that are not the "active" tab, or disabled | |
7762 | button | |
7763 | .not('.' + tm + '-state-active') | |
7764 | .not('.' + tm + '-state-disabled') | |
7765 | .addClass(tm + '-state-down'); | |
7766 | }) | |
7767 | .mouseup(function() { | |
7768 | // undo the *down* effect | |
7769 | button.removeClass(tm + '-state-down'); | |
7770 | }) | |
7771 | .hover( | |
7772 | function() { | |
7773 | // the *hover* effect. | |
7774 | // only on buttons that are not the "active" tab, or disabled | |
7775 | button | |
7776 | .not('.' + tm + '-state-active') | |
7777 | .not('.' + tm + '-state-disabled') | |
7778 | .addClass(tm + '-state-hover'); | |
7779 | }, | |
7780 | function() { | |
7781 | // undo the *hover* effect | |
7782 | button | |
7783 | .removeClass(tm + '-state-hover') | |
7784 | .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup | |
7785 | } | |
7786 | ); | |
7787 | ||
7788 | groupChildren = groupChildren.add(button); | |
7789 | } | |
7790 | } | |
7791 | }); | |
7792 | ||
7793 | if (isOnlyButtons) { | |
7794 | groupChildren | |
7795 | .first().addClass(tm + '-corner-left').end() | |
7796 | .last().addClass(tm + '-corner-right').end(); | |
7797 | } | |
7798 | ||
7799 | if (groupChildren.length > 1) { | |
7800 | groupEl = $('<div/>'); | |
7801 | if (isOnlyButtons) { | |
7802 | groupEl.addClass('fc-button-group'); | |
7803 | } | |
7804 | groupEl.append(groupChildren); | |
7805 | sectionEl.append(groupEl); | |
7806 | } | |
7807 | else { | |
7808 | sectionEl.append(groupChildren); // 1 or 0 children | |
7809 | } | |
7810 | }); | |
7811 | } | |
7812 | ||
7813 | return sectionEl; | |
7814 | } | |
7815 | ||
7816 | ||
7817 | function updateTitle(text) { | |
7818 | el.find('h2').text(text); | |
7819 | } | |
7820 | ||
7821 | ||
7822 | function activateButton(buttonName) { | |
7823 | el.find('.fc-' + buttonName + '-button') | |
7824 | .addClass(tm + '-state-active'); | |
7825 | } | |
7826 | ||
7827 | ||
7828 | function deactivateButton(buttonName) { | |
7829 | el.find('.fc-' + buttonName + '-button') | |
7830 | .removeClass(tm + '-state-active'); | |
7831 | } | |
7832 | ||
7833 | ||
7834 | function disableButton(buttonName) { | |
7835 | el.find('.fc-' + buttonName + '-button') | |
7836 | .attr('disabled', 'disabled') | |
7837 | .addClass(tm + '-state-disabled'); | |
7838 | } | |
7839 | ||
7840 | ||
7841 | function enableButton(buttonName) { | |
7842 | el.find('.fc-' + buttonName + '-button') | |
7843 | .removeAttr('disabled') | |
7844 | .removeClass(tm + '-state-disabled'); | |
7845 | } | |
7846 | ||
7847 | ||
7848 | function getViewsWithButtons() { | |
7849 | return viewsWithButtons; | |
7850 | } | |
7851 | ||
7852 | } | |
7853 | ||
7854 | fc.sourceNormalizers = []; | |
7855 | fc.sourceFetchers = []; | |
7856 | ||
7857 | var ajaxDefaults = { | |
7858 | dataType: 'json', | |
7859 | cache: false | |
7860 | }; | |
7861 | ||
7862 | var eventGUID = 1; | |
7863 | ||
7864 | ||
7865 | function EventManager(options) { // assumed to be a calendar | |
7866 | var t = this; | |
7867 | ||
7868 | ||
7869 | // exports | |
7870 | t.isFetchNeeded = isFetchNeeded; | |
7871 | t.fetchEvents = fetchEvents; | |
7872 | t.addEventSource = addEventSource; | |
7873 | t.removeEventSource = removeEventSource; | |
7874 | t.updateEvent = updateEvent; | |
7875 | t.renderEvent = renderEvent; | |
7876 | t.removeEvents = removeEvents; | |
7877 | t.clientEvents = clientEvents; | |
7878 | t.mutateEvent = mutateEvent; | |
7879 | t.normalizeEventDateProps = normalizeEventDateProps; | |
7880 | t.ensureVisibleEventRange = ensureVisibleEventRange; | |
7881 | ||
7882 | ||
7883 | // imports | |
7884 | var trigger = t.trigger; | |
7885 | var getView = t.getView; | |
7886 | var reportEvents = t.reportEvents; | |
7887 | ||
7888 | ||
7889 | // locals | |
7890 | var stickySource = { events: [] }; | |
7891 | var sources = [ stickySource ]; | |
7892 | var rangeStart, rangeEnd; | |
7893 | var currentFetchID = 0; | |
7894 | var pendingSourceCnt = 0; | |
7895 | var loadingLevel = 0; | |
7896 | var cache = []; // holds events that have already been expanded | |
7897 | ||
7898 | ||
7899 | $.each( | |
7900 | (options.events ? [ options.events ] : []).concat(options.eventSources || []), | |
7901 | function(i, sourceInput) { | |
7902 | var source = buildEventSource(sourceInput); | |
7903 | if (source) { | |
7904 | sources.push(source); | |
7905 | } | |
7906 | } | |
7907 | ); | |
7908 | ||
7909 | ||
7910 | ||
7911 | /* Fetching | |
7912 | -----------------------------------------------------------------------------*/ | |
7913 | ||
7914 | ||
7915 | function isFetchNeeded(start, end) { | |
7916 | return !rangeStart || // nothing has been fetched yet? | |
7917 | // or, a part of the new range is outside of the old range? (after normalizing) | |
7918 | start.clone().stripZone() < rangeStart.clone().stripZone() || | |
7919 | end.clone().stripZone() > rangeEnd.clone().stripZone(); | |
7920 | } | |
7921 | ||
7922 | ||
7923 | function fetchEvents(start, end) { | |
7924 | rangeStart = start; | |
7925 | rangeEnd = end; | |
7926 | cache = []; | |
7927 | var fetchID = ++currentFetchID; | |
7928 | var len = sources.length; | |
7929 | pendingSourceCnt = len; | |
7930 | for (var i=0; i<len; i++) { | |
7931 | fetchEventSource(sources[i], fetchID); | |
7932 | } | |
7933 | } | |
7934 | ||
7935 | ||
7936 | function fetchEventSource(source, fetchID) { | |
7937 | _fetchEventSource(source, function(eventInputs) { | |
7938 | var isArraySource = $.isArray(source.events); | |
7939 | var i, eventInput; | |
7940 | var abstractEvent; | |
7941 | ||
7942 | if (fetchID == currentFetchID) { | |
7943 | ||
7944 | if (eventInputs) { | |
7945 | for (i = 0; i < eventInputs.length; i++) { | |
7946 | eventInput = eventInputs[i]; | |
7947 | ||
7948 | if (isArraySource) { // array sources have already been convert to Event Objects | |
7949 | abstractEvent = eventInput; | |
7950 | } | |
7951 | else { | |
7952 | abstractEvent = buildEventFromInput(eventInput, source); | |
7953 | } | |
7954 | ||
7955 | if (abstractEvent) { // not false (an invalid event) | |
7956 | cache.push.apply( | |
7957 | cache, | |
7958 | expandEvent(abstractEvent) // add individual expanded events to the cache | |
7959 | ); | |
7960 | } | |
7961 | } | |
7962 | } | |
7963 | ||
7964 | pendingSourceCnt--; | |
7965 | if (!pendingSourceCnt) { | |
7966 | reportEvents(cache); | |
7967 | } | |
7968 | } | |
7969 | }); | |
7970 | } | |
7971 | ||
7972 | ||
7973 | function _fetchEventSource(source, callback) { | |
7974 | var i; | |
7975 | var fetchers = fc.sourceFetchers; | |
7976 | var res; | |
7977 | ||
7978 | for (i=0; i<fetchers.length; i++) { | |
7979 | res = fetchers[i].call( | |
7980 | t, // this, the Calendar object | |
7981 | source, | |
7982 | rangeStart.clone(), | |
7983 | rangeEnd.clone(), | |
7984 | options.timezone, | |
7985 | callback | |
7986 | ); | |
7987 | ||
7988 | if (res === true) { | |
7989 | // the fetcher is in charge. made its own async request | |
7990 | return; | |
7991 | } | |
7992 | else if (typeof res == 'object') { | |
7993 | // the fetcher returned a new source. process it | |
7994 | _fetchEventSource(res, callback); | |
7995 | return; | |
7996 | } | |
7997 | } | |
7998 | ||
7999 | var events = source.events; | |
8000 | if (events) { | |
8001 | if ($.isFunction(events)) { | |
8002 | pushLoading(); | |
8003 | events.call( | |
8004 | t, // this, the Calendar object | |
8005 | rangeStart.clone(), | |
8006 | rangeEnd.clone(), | |
8007 | options.timezone, | |
8008 | function(events) { | |
8009 | callback(events); | |
8010 | popLoading(); | |
8011 | } | |
8012 | ); | |
8013 | } | |
8014 | else if ($.isArray(events)) { | |
8015 | callback(events); | |
8016 | } | |
8017 | else { | |
8018 | callback(); | |
8019 | } | |
8020 | }else{ | |
8021 | var url = source.url; | |
8022 | if (url) { | |
8023 | var success = source.success; | |
8024 | var error = source.error; | |
8025 | var complete = source.complete; | |
8026 | ||
8027 | // retrieve any outbound GET/POST $.ajax data from the options | |
8028 | var customData; | |
8029 | if ($.isFunction(source.data)) { | |
8030 | // supplied as a function that returns a key/value object | |
8031 | customData = source.data(); | |
8032 | } | |
8033 | else { | |
8034 | // supplied as a straight key/value object | |
8035 | customData = source.data; | |
8036 | } | |
8037 | ||
8038 | // use a copy of the custom data so we can modify the parameters | |
8039 | // and not affect the passed-in object. | |
8040 | var data = $.extend({}, customData || {}); | |
8041 | ||
8042 | var startParam = firstDefined(source.startParam, options.startParam); | |
8043 | var endParam = firstDefined(source.endParam, options.endParam); | |
8044 | var timezoneParam = firstDefined(source.timezoneParam, options.timezoneParam); | |
8045 | ||
8046 | if (startParam) { | |
8047 | data[startParam] = rangeStart.format(); | |
8048 | } | |
8049 | if (endParam) { | |
8050 | data[endParam] = rangeEnd.format(); | |
8051 | } | |
8052 | if (options.timezone && options.timezone != 'local') { | |
8053 | data[timezoneParam] = options.timezone; | |
8054 | } | |
8055 | ||
8056 | pushLoading(); | |
8057 | $.ajax($.extend({}, ajaxDefaults, source, { | |
8058 | data: data, | |
8059 | success: function(events) { | |
8060 | events = events || []; | |
8061 | var res = applyAll(success, this, arguments); | |
8062 | if ($.isArray(res)) { | |
8063 | events = res; | |
8064 | } | |
8065 | callback(events); | |
8066 | }, | |
8067 | error: function() { | |
8068 | applyAll(error, this, arguments); | |
8069 | callback(); | |
8070 | }, | |
8071 | complete: function() { | |
8072 | applyAll(complete, this, arguments); | |
8073 | popLoading(); | |
8074 | } | |
8075 | })); | |
8076 | }else{ | |
8077 | callback(); | |
8078 | } | |
8079 | } | |
8080 | } | |
8081 | ||
8082 | ||
8083 | ||
8084 | /* Sources | |
8085 | -----------------------------------------------------------------------------*/ | |
8086 | ||
8087 | ||
8088 | function addEventSource(sourceInput) { | |
8089 | var source = buildEventSource(sourceInput); | |
8090 | if (source) { | |
8091 | sources.push(source); | |
8092 | pendingSourceCnt++; | |
8093 | fetchEventSource(source, currentFetchID); // will eventually call reportEvents | |
8094 | } | |
8095 | } | |
8096 | ||
8097 | ||
8098 | function buildEventSource(sourceInput) { // will return undefined if invalid source | |
8099 | var normalizers = fc.sourceNormalizers; | |
8100 | var source; | |
8101 | var i; | |
8102 | ||
8103 | if ($.isFunction(sourceInput) || $.isArray(sourceInput)) { | |
8104 | source = { events: sourceInput }; | |
8105 | } | |
8106 | else if (typeof sourceInput === 'string') { | |
8107 | source = { url: sourceInput }; | |
8108 | } | |
8109 | else if (typeof sourceInput === 'object') { | |
8110 | source = $.extend({}, sourceInput); // shallow copy | |
8111 | } | |
8112 | ||
8113 | if (source) { | |
8114 | ||
8115 | // TODO: repeat code, same code for event classNames | |
8116 | if (source.className) { | |
8117 | if (typeof source.className === 'string') { | |
8118 | source.className = source.className.split(/\s+/); | |
8119 | } | |
8120 | // otherwise, assumed to be an array | |
8121 | } | |
8122 | else { | |
8123 | source.className = []; | |
8124 | } | |
8125 | ||
8126 | // for array sources, we convert to standard Event Objects up front | |
8127 | if ($.isArray(source.events)) { | |
8128 | source.origArray = source.events; // for removeEventSource | |
8129 | source.events = $.map(source.events, function(eventInput) { | |
8130 | return buildEventFromInput(eventInput, source); | |
8131 | }); | |
8132 | } | |
8133 | ||
8134 | for (i=0; i<normalizers.length; i++) { | |
8135 | normalizers[i].call(t, source); | |
8136 | } | |
8137 | ||
8138 | return source; | |
8139 | } | |
8140 | } | |
8141 | ||
8142 | ||
8143 | function removeEventSource(source) { | |
8144 | sources = $.grep(sources, function(src) { | |
8145 | return !isSourcesEqual(src, source); | |
8146 | }); | |
8147 | // remove all client events from that source | |
8148 | cache = $.grep(cache, function(e) { | |
8149 | return !isSourcesEqual(e.source, source); | |
8150 | }); | |
8151 | reportEvents(cache); | |
8152 | } | |
8153 | ||
8154 | ||
8155 | function isSourcesEqual(source1, source2) { | |
8156 | return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2); | |
8157 | } | |
8158 | ||
8159 | ||
8160 | function getSourcePrimitive(source) { | |
8161 | return ( | |
8162 | (typeof source === 'object') ? // a normalized event source? | |
8163 | (source.origArray || source.googleCalendarId || source.url || source.events) : // get the primitive | |
8164 | null | |
8165 | ) || | |
8166 | source; // the given argument *is* the primitive | |
8167 | } | |
8168 | ||
8169 | ||
8170 | ||
8171 | /* Manipulation | |
8172 | -----------------------------------------------------------------------------*/ | |
8173 | ||
8174 | ||
8175 | // Only ever called from the externally-facing API | |
8176 | function updateEvent(event) { | |
8177 | ||
8178 | // massage start/end values, even if date string values | |
8179 | event.start = t.moment(event.start); | |
8180 | if (event.end) { | |
8181 | event.end = t.moment(event.end); | |
8182 | } | |
8183 | else { | |
8184 | event.end = null; | |
8185 | } | |
8186 | ||
8187 | mutateEvent(event, getMiscEventProps(event)); // will handle start/end/allDay normalization | |
8188 | reportEvents(cache); // reports event modifications (so we can redraw) | |
8189 | } | |
8190 | ||
8191 | ||
8192 | // Returns a hash of misc event properties that should be copied over to related events. | |
8193 | function getMiscEventProps(event) { | |
8194 | var props = {}; | |
8195 | ||
8196 | $.each(event, function(name, val) { | |
8197 | if (isMiscEventPropName(name)) { | |
8198 | if (val !== undefined && isAtomic(val)) { // a defined non-object | |
8199 | props[name] = val; | |
8200 | } | |
8201 | } | |
8202 | }); | |
8203 | ||
8204 | return props; | |
8205 | } | |
8206 | ||
8207 | // non-date-related, non-id-related, non-secret | |
8208 | function isMiscEventPropName(name) { | |
8209 | return !/^_|^(id|allDay|start|end)$/.test(name); | |
8210 | } | |
8211 | ||
8212 | ||
8213 | // returns the expanded events that were created | |
8214 | function renderEvent(eventInput, stick) { | |
8215 | var abstractEvent = buildEventFromInput(eventInput); | |
8216 | var events; | |
8217 | var i, event; | |
8218 | ||
8219 | if (abstractEvent) { // not false (a valid input) | |
8220 | events = expandEvent(abstractEvent); | |
8221 | ||
8222 | for (i = 0; i < events.length; i++) { | |
8223 | event = events[i]; | |
8224 | ||
8225 | if (!event.source) { | |
8226 | if (stick) { | |
8227 | stickySource.events.push(event); | |
8228 | event.source = stickySource; | |
8229 | } | |
8230 | cache.push(event); | |
8231 | } | |
8232 | } | |
8233 | ||
8234 | reportEvents(cache); | |
8235 | ||
8236 | return events; | |
8237 | } | |
8238 | ||
8239 | return []; | |
8240 | } | |
8241 | ||
8242 | ||
8243 | function removeEvents(filter) { | |
8244 | var eventID; | |
8245 | var i; | |
8246 | ||
8247 | if (filter == null) { // null or undefined. remove all events | |
8248 | filter = function() { return true; }; // will always match | |
8249 | } | |
8250 | else if (!$.isFunction(filter)) { // an event ID | |
8251 | eventID = filter + ''; | |
8252 | filter = function(event) { | |
8253 | return event._id == eventID; | |
8254 | }; | |
8255 | } | |
8256 | ||
8257 | // Purge event(s) from our local cache | |
8258 | cache = $.grep(cache, filter, true); // inverse=true | |
8259 | ||
8260 | // Remove events from array sources. | |
8261 | // This works because they have been converted to official Event Objects up front. | |
8262 | // (and as a result, event._id has been calculated). | |
8263 | for (i=0; i<sources.length; i++) { | |
8264 | if ($.isArray(sources[i].events)) { | |
8265 | sources[i].events = $.grep(sources[i].events, filter, true); | |
8266 | } | |
8267 | } | |
8268 | ||
8269 | reportEvents(cache); | |
8270 | } | |
8271 | ||
8272 | ||
8273 | function clientEvents(filter) { | |
8274 | if ($.isFunction(filter)) { | |
8275 | return $.grep(cache, filter); | |
8276 | } | |
8277 | else if (filter != null) { // not null, not undefined. an event ID | |
8278 | filter += ''; | |
8279 | return $.grep(cache, function(e) { | |
8280 | return e._id == filter; | |
8281 | }); | |
8282 | } | |
8283 | return cache; // else, return all | |
8284 | } | |
8285 | ||
8286 | ||
8287 | ||
8288 | /* Loading State | |
8289 | -----------------------------------------------------------------------------*/ | |
8290 | ||
8291 | ||
8292 | function pushLoading() { | |
8293 | if (!(loadingLevel++)) { | |
8294 | trigger('loading', null, true, getView()); | |
8295 | } | |
8296 | } | |
8297 | ||
8298 | ||
8299 | function popLoading() { | |
8300 | if (!(--loadingLevel)) { | |
8301 | trigger('loading', null, false, getView()); | |
8302 | } | |
8303 | } | |
8304 | ||
8305 | ||
8306 | ||
8307 | /* Event Normalization | |
8308 | -----------------------------------------------------------------------------*/ | |
8309 | ||
8310 | ||
8311 | // Given a raw object with key/value properties, returns an "abstract" Event object. | |
8312 | // An "abstract" event is an event that, if recurring, will not have been expanded yet. | |
8313 | // Will return `false` when input is invalid. | |
8314 | // `source` is optional | |
8315 | function buildEventFromInput(input, source) { | |
8316 | var out = {}; | |
8317 | var start, end; | |
8318 | var allDay; | |
8319 | ||
8320 | if (options.eventDataTransform) { | |
8321 | input = options.eventDataTransform(input); | |
8322 | } | |
8323 | if (source && source.eventDataTransform) { | |
8324 | input = source.eventDataTransform(input); | |
8325 | } | |
8326 | ||
8327 | // Copy all properties over to the resulting object. | |
8328 | // The special-case properties will be copied over afterwards. | |
8329 | $.extend(out, input); | |
8330 | ||
8331 | if (source) { | |
8332 | out.source = source; | |
8333 | } | |
8334 | ||
8335 | out._id = input._id || (input.id === undefined ? '_fc' + eventGUID++ : input.id + ''); | |
8336 | ||
8337 | if (input.className) { | |
8338 | if (typeof input.className == 'string') { | |
8339 | out.className = input.className.split(/\s+/); | |
8340 | } | |
8341 | else { // assumed to be an array | |
8342 | out.className = input.className; | |
8343 | } | |
8344 | } | |
8345 | else { | |
8346 | out.className = []; | |
8347 | } | |
8348 | ||
8349 | start = input.start || input.date; // "date" is an alias for "start" | |
8350 | end = input.end; | |
8351 | ||
8352 | // parse as a time (Duration) if applicable | |
8353 | if (isTimeString(start)) { | |
8354 | start = moment.duration(start); | |
8355 | } | |
8356 | if (isTimeString(end)) { | |
8357 | end = moment.duration(end); | |
8358 | } | |
8359 | ||
8360 | if (input.dow || moment.isDuration(start) || moment.isDuration(end)) { | |
8361 | ||
8362 | // the event is "abstract" (recurring) so don't calculate exact start/end dates just yet | |
8363 | out.start = start ? moment.duration(start) : null; // will be a Duration or null | |
8364 | out.end = end ? moment.duration(end) : null; // will be a Duration or null | |
8365 | out._recurring = true; // our internal marker | |
8366 | } | |
8367 | else { | |
8368 | ||
8369 | if (start) { | |
8370 | start = t.moment(start); | |
8371 | if (!start.isValid()) { | |
8372 | return false; | |
8373 | } | |
8374 | } | |
8375 | ||
8376 | if (end) { | |
8377 | end = t.moment(end); | |
8378 | if (!end.isValid()) { | |
8379 | end = null; // let defaults take over | |
8380 | } | |
8381 | } | |
8382 | ||
8383 | allDay = input.allDay; | |
8384 | if (allDay === undefined) { // still undefined? fallback to default | |
8385 | allDay = firstDefined( | |
8386 | source ? source.allDayDefault : undefined, | |
8387 | options.allDayDefault | |
8388 | ); | |
8389 | // still undefined? normalizeEventDateProps will calculate it | |
8390 | } | |
8391 | ||
8392 | assignDatesToEvent(start, end, allDay, out); | |
8393 | } | |
8394 | ||
8395 | return out; | |
8396 | } | |
8397 | ||
8398 | ||
8399 | // Normalizes and assigns the given dates to the given partially-formed event object. | |
8400 | // NOTE: mutates the given start/end moments. does not make a copy. | |
8401 | function assignDatesToEvent(start, end, allDay, event) { | |
8402 | event.start = start; | |
8403 | event.end = end; | |
8404 | event.allDay = allDay; | |
8405 | normalizeEventDateProps(event); | |
8406 | backupEventDates(event); | |
8407 | } | |
8408 | ||
8409 | ||
8410 | // Ensures the allDay property exists. | |
8411 | // Ensures the start/end dates are consistent with allDay and forceEventDuration. | |
8412 | // Accepts an Event object, or a plain object with event-ish properties. | |
8413 | // NOTE: Will modify the given object. | |
8414 | function normalizeEventDateProps(props) { | |
8415 | ||
8416 | if (props.allDay == null) { | |
8417 | props.allDay = !(props.start.hasTime() || (props.end && props.end.hasTime())); | |
8418 | } | |
8419 | ||
8420 | if (props.allDay) { | |
8421 | props.start.stripTime(); | |
8422 | if (props.end) { | |
8423 | props.end.stripTime(); | |
8424 | } | |
8425 | } | |
8426 | else { | |
8427 | if (!props.start.hasTime()) { | |
8428 | props.start = t.rezoneDate(props.start); // will also give it a 00:00 time | |
8429 | } | |
8430 | if (props.end && !props.end.hasTime()) { | |
8431 | props.end = t.rezoneDate(props.end); // will also give it a 00:00 time | |
8432 | } | |
8433 | } | |
8434 | ||
8435 | if (props.end && !props.end.isAfter(props.start)) { | |
8436 | props.end = null; | |
8437 | } | |
8438 | ||
8439 | if (!props.end) { | |
8440 | if (options.forceEventDuration) { | |
8441 | props.end = t.getDefaultEventEnd(props.allDay, props.start); | |
8442 | } | |
8443 | else { | |
8444 | props.end = null; | |
8445 | } | |
8446 | } | |
8447 | } | |
8448 | ||
8449 | ||
8450 | // If `range` is a proper range with a start and end, returns the original object. | |
8451 | // If missing an end, computes a new range with an end, computing it as if it were an event. | |
8452 | // TODO: make this a part of the event -> eventRange system | |
8453 | function ensureVisibleEventRange(range) { | |
8454 | var allDay; | |
8455 | ||
8456 | if (!range.end) { | |
8457 | ||
8458 | allDay = range.allDay; // range might be more event-ish than we think | |
8459 | if (allDay == null) { | |
8460 | allDay = !range.start.hasTime(); | |
8461 | } | |
8462 | ||
8463 | range = { | |
8464 | start: range.start, | |
8465 | end: t.getDefaultEventEnd(allDay, range.start) | |
8466 | }; | |
8467 | } | |
8468 | return range; | |
8469 | } | |
8470 | ||
8471 | ||
8472 | // If the given event is a recurring event, break it down into an array of individual instances. | |
8473 | // If not a recurring event, return an array with the single original event. | |
8474 | // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array. | |
8475 | // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours). | |
8476 | function expandEvent(abstractEvent, _rangeStart, _rangeEnd) { | |
8477 | var events = []; | |
8478 | var dowHash; | |
8479 | var dow; | |
8480 | var i; | |
8481 | var date; | |
8482 | var startTime, endTime; | |
8483 | var start, end; | |
8484 | var event; | |
8485 | ||
8486 | _rangeStart = _rangeStart || rangeStart; | |
8487 | _rangeEnd = _rangeEnd || rangeEnd; | |
8488 | ||
8489 | if (abstractEvent) { | |
8490 | if (abstractEvent._recurring) { | |
8491 | ||
8492 | // make a boolean hash as to whether the event occurs on each day-of-week | |
8493 | if ((dow = abstractEvent.dow)) { | |
8494 | dowHash = {}; | |
8495 | for (i = 0; i < dow.length; i++) { | |
8496 | dowHash[dow[i]] = true; | |
8497 | } | |
8498 | } | |
8499 | ||
8500 | // iterate through every day in the current range | |
8501 | date = _rangeStart.clone().stripTime(); // holds the date of the current day | |
8502 | while (date.isBefore(_rangeEnd)) { | |
8503 | ||
8504 | if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week | |
8505 | ||
8506 | startTime = abstractEvent.start; // the stored start and end properties are times (Durations) | |
8507 | endTime = abstractEvent.end; // " | |
8508 | start = date.clone(); | |
8509 | end = null; | |
8510 | ||
8511 | if (startTime) { | |
8512 | start = start.time(startTime); | |
8513 | } | |
8514 | if (endTime) { | |
8515 | end = date.clone().time(endTime); | |
8516 | } | |
8517 | ||
8518 | event = $.extend({}, abstractEvent); // make a copy of the original | |
8519 | assignDatesToEvent( | |
8520 | start, end, | |
8521 | !startTime && !endTime, // allDay? | |
8522 | event | |
8523 | ); | |
8524 | events.push(event); | |
8525 | } | |
8526 | ||
8527 | date.add(1, 'days'); | |
8528 | } | |
8529 | } | |
8530 | else { | |
8531 | events.push(abstractEvent); // return the original event. will be a one-item array | |
8532 | } | |
8533 | } | |
8534 | ||
8535 | return events; | |
8536 | } | |
8537 | ||
8538 | ||
8539 | ||
8540 | /* Event Modification Math | |
8541 | -----------------------------------------------------------------------------------------*/ | |
8542 | ||
8543 | ||
8544 | // Modifies an event and all related events by applying the given properties. | |
8545 | // Special date-diffing logic is used for manipulation of dates. | |
8546 | // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end. | |
8547 | // All date comparisons are done against the event's pristine _start and _end dates. | |
8548 | // Returns an object with delta information and a function to undo all operations. | |
8549 | // | |
8550 | function mutateEvent(event, props) { | |
8551 | var miscProps = {}; | |
8552 | var clearEnd; | |
8553 | var dateDelta; | |
8554 | var durationDelta; | |
8555 | var undoFunc; | |
8556 | ||
8557 | props = props || {}; | |
8558 | ||
8559 | // ensure new date-related values to compare against | |
8560 | if (!props.start) { | |
8561 | props.start = event.start.clone(); | |
8562 | } | |
8563 | if (props.end === undefined) { | |
8564 | props.end = event.end ? event.end.clone() : null; | |
8565 | } | |
8566 | if (props.allDay == null) { // is null or undefined? | |
8567 | props.allDay = event.allDay; | |
8568 | } | |
8569 | ||
8570 | normalizeEventDateProps(props); // massages start/end/allDay | |
8571 | ||
8572 | // clear the end date if explicitly changed to null | |
8573 | clearEnd = event._end !== null && props.end === null; | |
8574 | ||
8575 | // compute the delta for moving the start and end dates together | |
8576 | if (props.allDay) { | |
8577 | dateDelta = diffDay(props.start, event._start); // whole-day diff from start-of-day | |
8578 | } | |
8579 | else { | |
8580 | dateDelta = diffDayTime(props.start, event._start); | |
8581 | } | |
8582 | ||
8583 | // compute the delta for moving the end date (after applying dateDelta) | |
8584 | if (!clearEnd && props.end) { | |
8585 | durationDelta = diffDayTime( | |
8586 | // new duration | |
8587 | props.end, | |
8588 | props.start | |
8589 | ).subtract(diffDayTime( | |
8590 | // subtract old duration | |
8591 | event._end || t.getDefaultEventEnd(event._allDay, event._start), | |
8592 | event._start | |
8593 | )); | |
8594 | } | |
8595 | ||
8596 | // gather all non-date-related properties | |
8597 | $.each(props, function(name, val) { | |
8598 | if (isMiscEventPropName(name)) { | |
8599 | if (val !== undefined) { | |
8600 | miscProps[name] = val; | |
8601 | } | |
8602 | } | |
8603 | }); | |
8604 | ||
8605 | // apply the operations to the event and all related events | |
8606 | undoFunc = mutateEvents( | |
8607 | clientEvents(event._id), // get events with this ID | |
8608 | clearEnd, | |
8609 | props.allDay, | |
8610 | dateDelta, | |
8611 | durationDelta, | |
8612 | miscProps | |
8613 | ); | |
8614 | ||
8615 | return { | |
8616 | dateDelta: dateDelta, | |
8617 | durationDelta: durationDelta, | |
8618 | undo: undoFunc | |
8619 | }; | |
8620 | } | |
8621 | ||
8622 | ||
8623 | // Modifies an array of events in the following ways (operations are in order): | |
8624 | // - clear the event's `end` | |
8625 | // - convert the event to allDay | |
8626 | // - add `dateDelta` to the start and end | |
8627 | // - add `durationDelta` to the event's duration | |
8628 | // - assign `miscProps` to the event | |
8629 | // | |
8630 | // Returns a function that can be called to undo all the operations. | |
8631 | // | |
8632 | // TODO: don't use so many closures. possible memory issues when lots of events with same ID. | |
8633 | // | |
8634 | function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) { | |
8635 | var isAmbigTimezone = t.getIsAmbigTimezone(); | |
8636 | var undoFunctions = []; | |
8637 | ||
8638 | // normalize zero-length deltas to be null | |
8639 | if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; } | |
8640 | if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; } | |
8641 | ||
8642 | $.each(events, function(i, event) { | |
8643 | var oldProps; | |
8644 | var newProps; | |
8645 | ||
8646 | // build an object holding all the old values, both date-related and misc. | |
8647 | // for the undo function. | |
8648 | oldProps = { | |
8649 | start: event.start.clone(), | |
8650 | end: event.end ? event.end.clone() : null, | |
8651 | allDay: event.allDay | |
8652 | }; | |
8653 | $.each(miscProps, function(name) { | |
8654 | oldProps[name] = event[name]; | |
8655 | }); | |
8656 | ||
8657 | // new date-related properties. work off the original date snapshot. | |
8658 | // ok to use references because they will be thrown away when backupEventDates is called. | |
8659 | newProps = { | |
8660 | start: event._start, | |
8661 | end: event._end, | |
8662 | allDay: event._allDay | |
8663 | }; | |
8664 | ||
8665 | if (clearEnd) { | |
8666 | newProps.end = null; | |
8667 | } | |
8668 | ||
8669 | newProps.allDay = allDay; | |
8670 | ||
8671 | normalizeEventDateProps(newProps); // massages start/end/allDay | |
8672 | ||
8673 | if (dateDelta) { | |
8674 | newProps.start.add(dateDelta); | |
8675 | if (newProps.end) { | |
8676 | newProps.end.add(dateDelta); | |
8677 | } | |
8678 | } | |
8679 | ||
8680 | if (durationDelta) { | |
8681 | if (!newProps.end) { | |
8682 | newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start); | |
8683 | } | |
8684 | newProps.end.add(durationDelta); | |
8685 | } | |
8686 | ||
8687 | // if the dates have changed, and we know it is impossible to recompute the | |
8688 | // timezone offsets, strip the zone. | |
8689 | if ( | |
8690 | isAmbigTimezone && | |
8691 | !newProps.allDay && | |
8692 | (dateDelta || durationDelta) | |
8693 | ) { | |
8694 | newProps.start.stripZone(); | |
8695 | if (newProps.end) { | |
8696 | newProps.end.stripZone(); | |
8697 | } | |
8698 | } | |
8699 | ||
8700 | $.extend(event, miscProps, newProps); // copy over misc props, then date-related props | |
8701 | backupEventDates(event); // regenerate internal _start/_end/_allDay | |
8702 | ||
8703 | undoFunctions.push(function() { | |
8704 | $.extend(event, oldProps); | |
8705 | backupEventDates(event); // regenerate internal _start/_end/_allDay | |
8706 | }); | |
8707 | }); | |
8708 | ||
8709 | return function() { | |
8710 | for (var i = 0; i < undoFunctions.length; i++) { | |
8711 | undoFunctions[i](); | |
8712 | } | |
8713 | }; | |
8714 | } | |
8715 | ||
8716 | ||
8717 | /* Business Hours | |
8718 | -----------------------------------------------------------------------------------------*/ | |
8719 | ||
8720 | t.getBusinessHoursEvents = getBusinessHoursEvents; | |
8721 | ||
8722 | ||
8723 | // Returns an array of events as to when the business hours occur in the given view. | |
8724 | // Abuse of our event system :( | |
8725 | function getBusinessHoursEvents() { | |
8726 | var optionVal = options.businessHours; | |
8727 | var defaultVal = { | |
8728 | className: 'fc-nonbusiness', | |
8729 | start: '09:00', | |
8730 | end: '17:00', | |
8731 | dow: [ 1, 2, 3, 4, 5 ], // monday - friday | |
8732 | rendering: 'inverse-background' | |
8733 | }; | |
8734 | var view = t.getView(); | |
8735 | var eventInput; | |
8736 | ||
8737 | if (optionVal) { | |
8738 | if (typeof optionVal === 'object') { | |
8739 | // option value is an object that can override the default business hours | |
8740 | eventInput = $.extend({}, defaultVal, optionVal); | |
8741 | } | |
8742 | else { | |
8743 | // option value is `true`. use default business hours | |
8744 | eventInput = defaultVal; | |
8745 | } | |
8746 | } | |
8747 | ||
8748 | if (eventInput) { | |
8749 | return expandEvent( | |
8750 | buildEventFromInput(eventInput), | |
8751 | view.start, | |
8752 | view.end | |
8753 | ); | |
8754 | } | |
8755 | ||
8756 | return []; | |
8757 | } | |
8758 | ||
8759 | ||
8760 | /* Overlapping / Constraining | |
8761 | -----------------------------------------------------------------------------------------*/ | |
8762 | ||
8763 | t.isEventRangeAllowed = isEventRangeAllowed; | |
8764 | t.isSelectionRangeAllowed = isSelectionRangeAllowed; | |
8765 | t.isExternalDropRangeAllowed = isExternalDropRangeAllowed; | |
8766 | ||
8767 | ||
8768 | function isEventRangeAllowed(range, event) { | |
8769 | var source = event.source || {}; | |
8770 | var constraint = firstDefined( | |
8771 | event.constraint, | |
8772 | source.constraint, | |
8773 | options.eventConstraint | |
8774 | ); | |
8775 | var overlap = firstDefined( | |
8776 | event.overlap, | |
8777 | source.overlap, | |
8778 | options.eventOverlap | |
8779 | ); | |
8780 | ||
8781 | range = ensureVisibleEventRange(range); // ensure a proper range with an end for isRangeAllowed | |
8782 | ||
8783 | return isRangeAllowed(range, constraint, overlap, event); | |
8784 | } | |
8785 | ||
8786 | ||
8787 | function isSelectionRangeAllowed(range) { | |
8788 | return isRangeAllowed(range, options.selectConstraint, options.selectOverlap); | |
8789 | } | |
8790 | ||
8791 | ||
8792 | // when `eventProps` is defined, consider this an event. | |
8793 | // `eventProps` can contain misc non-date-related info about the event. | |
8794 | function isExternalDropRangeAllowed(range, eventProps) { | |
8795 | var eventInput; | |
8796 | var event; | |
8797 | ||
8798 | // note: very similar logic is in View's reportExternalDrop | |
8799 | if (eventProps) { | |
8800 | eventInput = $.extend({}, eventProps, range); | |
8801 | event = expandEvent(buildEventFromInput(eventInput))[0]; | |
8802 | } | |
8803 | ||
8804 | if (event) { | |
8805 | return isEventRangeAllowed(range, event); | |
8806 | } | |
8807 | else { // treat it as a selection | |
8808 | ||
8809 | range = ensureVisibleEventRange(range); // ensure a proper range with an end for isSelectionRangeAllowed | |
8810 | ||
8811 | return isSelectionRangeAllowed(range); | |
8812 | } | |
8813 | } | |
8814 | ||
8815 | ||
8816 | // Returns true if the given range (caused by an event drop/resize or a selection) is allowed to exist | |
8817 | // according to the constraint/overlap settings. | |
8818 | // `event` is not required if checking a selection. | |
8819 | function isRangeAllowed(range, constraint, overlap, event) { | |
8820 | var constraintEvents; | |
8821 | var anyContainment; | |
8822 | var i, otherEvent; | |
8823 | var otherOverlap; | |
8824 | ||
8825 | // normalize. fyi, we're normalizing in too many places :( | |
8826 | range = { | |
8827 | start: range.start.clone().stripZone(), | |
8828 | end: range.end.clone().stripZone() | |
8829 | }; | |
8830 | ||
8831 | // the range must be fully contained by at least one of produced constraint events | |
8832 | if (constraint != null) { | |
8833 | ||
8834 | // not treated as an event! intermediate data structure | |
8835 | // TODO: use ranges in the future | |
8836 | constraintEvents = constraintToEvents(constraint); | |
8837 | ||
8838 | anyContainment = false; | |
8839 | for (i = 0; i < constraintEvents.length; i++) { | |
8840 | if (eventContainsRange(constraintEvents[i], range)) { | |
8841 | anyContainment = true; | |
8842 | break; | |
8843 | } | |
8844 | } | |
8845 | ||
8846 | if (!anyContainment) { | |
8847 | return false; | |
8848 | } | |
8849 | } | |
8850 | ||
8851 | for (i = 0; i < cache.length; i++) { // loop all events and detect overlap | |
8852 | otherEvent = cache[i]; | |
8853 | ||
8854 | // don't compare the event to itself or other related [repeating] events | |
8855 | if (event && event._id === otherEvent._id) { | |
8856 | continue; | |
8857 | } | |
8858 | ||
8859 | // there needs to be an actual intersection before disallowing anything | |
8860 | if (eventIntersectsRange(otherEvent, range)) { | |
8861 | ||
8862 | // evaluate overlap for the given range and short-circuit if necessary | |
8863 | if (overlap === false) { | |
8864 | return false; | |
8865 | } | |
8866 | else if (typeof overlap === 'function' && !overlap(otherEvent, event)) { | |
8867 | return false; | |
8868 | } | |
8869 | ||
8870 | // if we are computing if the given range is allowable for an event, consider the other event's | |
8871 | // EventObject-specific or Source-specific `overlap` property | |
8872 | if (event) { | |
8873 | otherOverlap = firstDefined( | |
8874 | otherEvent.overlap, | |
8875 | (otherEvent.source || {}).overlap | |
8876 | // we already considered the global `eventOverlap` | |
8877 | ); | |
8878 | if (otherOverlap === false) { | |
8879 | return false; | |
8880 | } | |
8881 | if (typeof otherOverlap === 'function' && !otherOverlap(event, otherEvent)) { | |
8882 | return false; | |
8883 | } | |
8884 | } | |
8885 | } | |
8886 | } | |
8887 | ||
8888 | return true; | |
8889 | } | |
8890 | ||
8891 | ||
8892 | // Given an event input from the API, produces an array of event objects. Possible event inputs: | |
8893 | // 'businessHours' | |
8894 | // An event ID (number or string) | |
8895 | // An object with specific start/end dates or a recurring event (like what businessHours accepts) | |
8896 | function constraintToEvents(constraintInput) { | |
8897 | ||
8898 | if (constraintInput === 'businessHours') { | |
8899 | return getBusinessHoursEvents(); | |
8900 | } | |
8901 | ||
8902 | if (typeof constraintInput === 'object') { | |
8903 | return expandEvent(buildEventFromInput(constraintInput)); | |
8904 | } | |
8905 | ||
8906 | return clientEvents(constraintInput); // probably an ID | |
8907 | } | |
8908 | ||
8909 | ||
8910 | // Does the event's date range fully contain the given range? | |
8911 | // start/end already assumed to have stripped zones :( | |
8912 | function eventContainsRange(event, range) { | |
8913 | var eventStart = event.start.clone().stripZone(); | |
8914 | var eventEnd = t.getEventEnd(event).stripZone(); | |
8915 | ||
8916 | return range.start >= eventStart && range.end <= eventEnd; | |
8917 | } | |
8918 | ||
8919 | ||
8920 | // Does the event's date range intersect with the given range? | |
8921 | // start/end already assumed to have stripped zones :( | |
8922 | function eventIntersectsRange(event, range) { | |
8923 | var eventStart = event.start.clone().stripZone(); | |
8924 | var eventEnd = t.getEventEnd(event).stripZone(); | |
8925 | ||
8926 | return range.start < eventEnd && range.end > eventStart; | |
8927 | } | |
8928 | ||
8929 | } | |
8930 | ||
8931 | ||
8932 | // updates the "backup" properties, which are preserved in order to compute diffs later on. | |
8933 | function backupEventDates(event) { | |
8934 | event._allDay = event.allDay; | |
8935 | event._start = event.start.clone(); | |
8936 | event._end = event.end ? event.end.clone() : null; | |
8937 | } | |
8938 | ||
8939 | /* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells. | |
8940 | ----------------------------------------------------------------------------------------------------------------------*/ | |
8941 | // It is a manager for a DayGrid subcomponent, which does most of the heavy lifting. | |
8942 | // It is responsible for managing width/height. | |
8943 | ||
8944 | var BasicView = fcViews.basic = View.extend({ | |
8945 | ||
8946 | dayGrid: null, // the main subcomponent that does most of the heavy lifting | |
8947 | ||
8948 | dayNumbersVisible: false, // display day numbers on each day cell? | |
8949 | weekNumbersVisible: false, // display week numbers along the side? | |
8950 | ||
8951 | weekNumberWidth: null, // width of all the week-number cells running down the side | |
8952 | ||
8953 | headRowEl: null, // the fake row element of the day-of-week header | |
8954 | ||
8955 | ||
8956 | initialize: function() { | |
8957 | this.dayGrid = new DayGrid(this); | |
8958 | this.coordMap = this.dayGrid.coordMap; // the view's date-to-cell mapping is identical to the subcomponent's | |
8959 | }, | |
8960 | ||
8961 | ||
8962 | // Sets the display range and computes all necessary dates | |
8963 | setRange: function(range) { | |
8964 | View.prototype.setRange.call(this, range); // call the super-method | |
8965 | ||
8966 | this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange | |
8967 | this.dayGrid.setRange(range); | |
8968 | }, | |
8969 | ||
8970 | ||
8971 | // Compute the value to feed into setRange. Overrides superclass. | |
8972 | computeRange: function(date) { | |
8973 | var range = View.prototype.computeRange.call(this, date); // get value from the super-method | |
8974 | ||
8975 | // year and month views should be aligned with weeks. this is already done for week | |
8976 | if (/year|month/.test(range.intervalUnit)) { | |
8977 | range.start.startOf('week'); | |
8978 | range.start = this.skipHiddenDays(range.start); | |
8979 | ||
8980 | // make end-of-week if not already | |
8981 | if (range.end.weekday()) { | |
8982 | range.end.add(1, 'week').startOf('week'); | |
8983 | range.end = this.skipHiddenDays(range.end, -1, true); // exclusively move backwards | |
8984 | } | |
8985 | } | |
8986 | ||
8987 | return range; | |
8988 | }, | |
8989 | ||
8990 | ||
8991 | // Renders the view into `this.el`, which should already be assigned | |
8992 | render: function() { | |
8993 | ||
8994 | this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible | |
8995 | this.weekNumbersVisible = this.opt('weekNumbers'); | |
8996 | this.dayGrid.numbersVisible = this.dayNumbersVisible || this.weekNumbersVisible; | |
8997 | ||
8998 | this.el.addClass('fc-basic-view').html(this.renderHtml()); | |
8999 | ||
9000 | this.headRowEl = this.el.find('thead .fc-row'); | |
9001 | ||
9002 | this.scrollerEl = this.el.find('.fc-day-grid-container'); | |
9003 | this.dayGrid.coordMap.containerEl = this.scrollerEl; // constrain clicks/etc to the dimensions of the scroller | |
9004 | ||
9005 | this.dayGrid.el = this.el.find('.fc-day-grid'); | |
9006 | this.dayGrid.render(this.hasRigidRows()); | |
9007 | }, | |
9008 | ||
9009 | ||
9010 | // Make subcomponents ready for cleanup | |
9011 | destroy: function() { | |
9012 | this.dayGrid.destroy(); | |
9013 | View.prototype.destroy.call(this); // call the super-method | |
9014 | }, | |
9015 | ||
9016 | ||
9017 | // Builds the HTML skeleton for the view. | |
9018 | // The day-grid component will render inside of a container defined by this HTML. | |
9019 | renderHtml: function() { | |
9020 | return '' + | |
9021 | '<table>' + | |
9022 | '<thead>' + | |
9023 | '<tr>' + | |
9024 | '<td class="' + this.widgetHeaderClass + '">' + | |
9025 | this.dayGrid.headHtml() + // render the day-of-week headers | |
9026 | '</td>' + | |
9027 | '</tr>' + | |
9028 | '</thead>' + | |
9029 | '<tbody>' + | |
9030 | '<tr>' + | |
9031 | '<td class="' + this.widgetContentClass + '">' + | |
9032 | '<div class="fc-day-grid-container">' + | |
9033 | '<div class="fc-day-grid"/>' + | |
9034 | '</div>' + | |
9035 | '</td>' + | |
9036 | '</tr>' + | |
9037 | '</tbody>' + | |
9038 | '</table>'; | |
9039 | }, | |
9040 | ||
9041 | ||
9042 | // Generates the HTML that will go before the day-of week header cells. | |
9043 | // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL. | |
9044 | headIntroHtml: function() { | |
9045 | if (this.weekNumbersVisible) { | |
9046 | return '' + | |
9047 | '<th class="fc-week-number ' + this.widgetHeaderClass + '" ' + this.weekNumberStyleAttr() + '>' + | |
9048 | '<span>' + // needed for matchCellWidths | |
9049 | htmlEscape(this.opt('weekNumberTitle')) + | |
9050 | '</span>' + | |
9051 | '</th>'; | |
9052 | } | |
9053 | }, | |
9054 | ||
9055 | ||
9056 | // Generates the HTML that will go before content-skeleton cells that display the day/week numbers. | |
9057 | // Queried by the DayGrid subcomponent. Ordering depends on isRTL. | |
9058 | numberIntroHtml: function(row) { | |
9059 | if (this.weekNumbersVisible) { | |
9060 | return '' + | |
9061 | '<td class="fc-week-number" ' + this.weekNumberStyleAttr() + '>' + | |
9062 | '<span>' + // needed for matchCellWidths | |
9063 | this.calendar.calculateWeekNumber(this.dayGrid.getCell(row, 0).start) + | |
9064 | '</span>' + | |
9065 | '</td>'; | |
9066 | } | |
9067 | }, | |
9068 | ||
9069 | ||
9070 | // Generates the HTML that goes before the day bg cells for each day-row. | |
9071 | // Queried by the DayGrid subcomponent. Ordering depends on isRTL. | |
9072 | dayIntroHtml: function() { | |
9073 | if (this.weekNumbersVisible) { | |
9074 | return '<td class="fc-week-number ' + this.widgetContentClass + '" ' + | |
9075 | this.weekNumberStyleAttr() + '></td>'; | |
9076 | } | |
9077 | }, | |
9078 | ||
9079 | ||
9080 | // Generates the HTML that goes before every other type of row generated by DayGrid. Ordering depends on isRTL. | |
9081 | // Affects helper-skeleton and highlight-skeleton rows. | |
9082 | introHtml: function() { | |
9083 | if (this.weekNumbersVisible) { | |
9084 | return '<td class="fc-week-number" ' + this.weekNumberStyleAttr() + '></td>'; | |
9085 | } | |
9086 | }, | |
9087 | ||
9088 | ||
9089 | // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton. | |
9090 | // The number row will only exist if either day numbers or week numbers are turned on. | |
9091 | numberCellHtml: function(cell) { | |
9092 | var date = cell.start; | |
9093 | var classes; | |
9094 | ||
9095 | if (!this.dayNumbersVisible) { // if there are week numbers but not day numbers | |
9096 | return '<td/>'; // will create an empty space above events :( | |
9097 | } | |
9098 | ||
9099 | classes = this.dayGrid.getDayClasses(date); | |
9100 | classes.unshift('fc-day-number'); | |
9101 | ||
9102 | return '' + | |
9103 | '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '">' + | |
9104 | date.date() + | |
9105 | '</td>'; | |
9106 | }, | |
9107 | ||
9108 | ||
9109 | // Generates an HTML attribute string for setting the width of the week number column, if it is known | |
9110 | weekNumberStyleAttr: function() { | |
9111 | if (this.weekNumberWidth !== null) { | |
9112 | return 'style="width:' + this.weekNumberWidth + 'px"'; | |
9113 | } | |
9114 | return ''; | |
9115 | }, | |
9116 | ||
9117 | ||
9118 | // Determines whether each row should have a constant height | |
9119 | hasRigidRows: function() { | |
9120 | var eventLimit = this.opt('eventLimit'); | |
9121 | return eventLimit && typeof eventLimit !== 'number'; | |
9122 | }, | |
9123 | ||
9124 | ||
9125 | /* Dimensions | |
9126 | ------------------------------------------------------------------------------------------------------------------*/ | |
9127 | ||
9128 | ||
9129 | // Refreshes the horizontal dimensions of the view | |
9130 | updateWidth: function() { | |
9131 | if (this.weekNumbersVisible) { | |
9132 | // Make sure all week number cells running down the side have the same width. | |
9133 | // Record the width for cells created later. | |
9134 | this.weekNumberWidth = matchCellWidths( | |
9135 | this.el.find('.fc-week-number') | |
9136 | ); | |
9137 | } | |
9138 | }, | |
9139 | ||
9140 | ||
9141 | // Adjusts the vertical dimensions of the view to the specified values | |
9142 | setHeight: function(totalHeight, isAuto) { | |
9143 | var eventLimit = this.opt('eventLimit'); | |
9144 | var scrollerHeight; | |
9145 | ||
9146 | // reset all heights to be natural | |
9147 | unsetScroller(this.scrollerEl); | |
9148 | uncompensateScroll(this.headRowEl); | |
9149 | ||
9150 | this.dayGrid.destroySegPopover(); // kill the "more" popover if displayed | |
9151 | ||
9152 | // is the event limit a constant level number? | |
9153 | if (eventLimit && typeof eventLimit === 'number') { | |
9154 | this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after | |
9155 | } | |
9156 | ||
9157 | scrollerHeight = this.computeScrollerHeight(totalHeight); | |
9158 | this.setGridHeight(scrollerHeight, isAuto); | |
9159 | ||
9160 | // is the event limit dynamically calculated? | |
9161 | if (eventLimit && typeof eventLimit !== 'number') { | |
9162 | this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set | |
9163 | } | |
9164 | ||
9165 | if (!isAuto && setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars? | |
9166 | ||
9167 | compensateScroll(this.headRowEl, getScrollbarWidths(this.scrollerEl)); | |
9168 | ||
9169 | // doing the scrollbar compensation might have created text overflow which created more height. redo | |
9170 | scrollerHeight = this.computeScrollerHeight(totalHeight); | |
9171 | this.scrollerEl.height(scrollerHeight); | |
9172 | ||
9173 | this.restoreScroll(); | |
9174 | } | |
9175 | }, | |
9176 | ||
9177 | ||
9178 | // Sets the height of just the DayGrid component in this view | |
9179 | setGridHeight: function(height, isAuto) { | |
9180 | if (isAuto) { | |
9181 | undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding | |
9182 | } | |
9183 | else { | |
9184 | distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows | |
9185 | } | |
9186 | }, | |
9187 | ||
9188 | ||
9189 | /* Events | |
9190 | ------------------------------------------------------------------------------------------------------------------*/ | |
9191 | ||
9192 | ||
9193 | // Renders the given events onto the view and populates the segments array | |
9194 | renderEvents: function(events) { | |
9195 | this.dayGrid.renderEvents(events); | |
9196 | ||
9197 | this.updateHeight(); // must compensate for events that overflow the row | |
9198 | }, | |
9199 | ||
9200 | ||
9201 | // Retrieves all segment objects that are rendered in the view | |
9202 | getEventSegs: function() { | |
9203 | return this.dayGrid.getEventSegs(); | |
9204 | }, | |
9205 | ||
9206 | ||
9207 | // Unrenders all event elements and clears internal segment data | |
9208 | destroyEvents: function() { | |
9209 | this.recordScroll(); // removing events will reduce height and mess with the scroll, so record beforehand | |
9210 | this.dayGrid.destroyEvents(); | |
9211 | ||
9212 | // we DON'T need to call updateHeight() because: | |
9213 | // A) a renderEvents() call always happens after this, which will eventually call updateHeight() | |
9214 | // B) in IE8, this causes a flash whenever events are rerendered | |
9215 | }, | |
9216 | ||
9217 | ||
9218 | /* Dragging (for both events and external elements) | |
9219 | ------------------------------------------------------------------------------------------------------------------*/ | |
9220 | ||
9221 | ||
9222 | // A returned value of `true` signals that a mock "helper" event has been rendered. | |
9223 | renderDrag: function(dropLocation, seg) { | |
9224 | return this.dayGrid.renderDrag(dropLocation, seg); | |
9225 | }, | |
9226 | ||
9227 | ||
9228 | destroyDrag: function() { | |
9229 | this.dayGrid.destroyDrag(); | |
9230 | }, | |
9231 | ||
9232 | ||
9233 | /* Selection | |
9234 | ------------------------------------------------------------------------------------------------------------------*/ | |
9235 | ||
9236 | ||
9237 | // Renders a visual indication of a selection | |
9238 | renderSelection: function(range) { | |
9239 | this.dayGrid.renderSelection(range); | |
9240 | }, | |
9241 | ||
9242 | ||
9243 | // Unrenders a visual indications of a selection | |
9244 | destroySelection: function() { | |
9245 | this.dayGrid.destroySelection(); | |
9246 | } | |
9247 | ||
9248 | }); | |
9249 | ||
9250 | /* A month view with day cells running in rows (one-per-week) and columns | |
9251 | ----------------------------------------------------------------------------------------------------------------------*/ | |
9252 | ||
9253 | setDefaults({ | |
9254 | fixedWeekCount: true | |
9255 | }); | |
9256 | ||
9257 | var MonthView = fcViews.month = BasicView.extend({ | |
9258 | ||
9259 | // Produces information about what range to display | |
9260 | computeRange: function(date) { | |
9261 | var range = BasicView.prototype.computeRange.call(this, date); // get value from super-method | |
9262 | ||
9263 | if (this.isFixedWeeks()) { | |
9264 | // ensure 6 weeks | |
9265 | range.end.add( | |
9266 | 6 - range.end.diff(range.start, 'weeks'), | |
9267 | 'weeks' | |
9268 | ); | |
9269 | } | |
9270 | ||
9271 | return range; | |
9272 | }, | |
9273 | ||
9274 | ||
9275 | // Overrides the default BasicView behavior to have special multi-week auto-height logic | |
9276 | setGridHeight: function(height, isAuto) { | |
9277 | ||
9278 | isAuto = isAuto || this.opt('weekMode') === 'variable'; // LEGACY: weekMode is deprecated | |
9279 | ||
9280 | // if auto, make the height of each row the height that it would be if there were 6 weeks | |
9281 | if (isAuto) { | |
9282 | height *= this.rowCnt / 6; | |
9283 | } | |
9284 | ||
9285 | distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows | |
9286 | }, | |
9287 | ||
9288 | ||
9289 | isFixedWeeks: function() { | |
9290 | var weekMode = this.opt('weekMode'); // LEGACY: weekMode is deprecated | |
9291 | if (weekMode) { | |
9292 | return weekMode === 'fixed'; // if any other type of weekMode, assume NOT fixed | |
9293 | } | |
9294 | ||
9295 | return this.opt('fixedWeekCount'); | |
9296 | } | |
9297 | ||
9298 | }); | |
9299 | ||
9300 | MonthView.duration = { months: 1 }; | |
9301 | ||
9302 | /* A week view with simple day cells running horizontally | |
9303 | ----------------------------------------------------------------------------------------------------------------------*/ | |
9304 | ||
9305 | fcViews.basicWeek = { | |
9306 | type: 'basic', | |
9307 | duration: { weeks: 1 } | |
9308 | }; | |
9309 | /* A view with a single simple day cell | |
9310 | ----------------------------------------------------------------------------------------------------------------------*/ | |
9311 | ||
9312 | fcViews.basicDay = { | |
9313 | type: 'basic', | |
9314 | duration: { days: 1 } | |
9315 | }; | |
9316 | /* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically. | |
9317 | ----------------------------------------------------------------------------------------------------------------------*/ | |
9318 | // Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on). | |
9319 | // Responsible for managing width/height. | |
9320 | ||
9321 | setDefaults({ | |
9322 | allDaySlot: true, | |
9323 | allDayText: 'all-day', | |
9324 | scrollTime: '06:00:00', | |
9325 | slotDuration: '00:30:00', | |
9326 | minTime: '00:00:00', | |
9327 | maxTime: '24:00:00', | |
9328 | slotEventOverlap: true | |
9329 | }); | |
9330 | ||
9331 | var AGENDA_ALL_DAY_EVENT_LIMIT = 5; | |
9332 | ||
9333 | fcViews.agenda = View.extend({ // AgendaView | |
9334 | ||
9335 | timeGrid: null, // the main time-grid subcomponent of this view | |
9336 | dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null | |
9337 | ||
9338 | axisWidth: null, // the width of the time axis running down the side | |
9339 | ||
9340 | noScrollRowEls: null, // set of fake row elements that must compensate when scrollerEl has scrollbars | |
9341 | ||
9342 | // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath | |
9343 | bottomRuleEl: null, | |
9344 | bottomRuleHeight: null, | |
9345 | ||
9346 | ||
9347 | initialize: function() { | |
9348 | this.timeGrid = new TimeGrid(this); | |
9349 | ||
9350 | if (this.opt('allDaySlot')) { // should we display the "all-day" area? | |
9351 | this.dayGrid = new DayGrid(this); // the all-day subcomponent of this view | |
9352 | ||
9353 | // the coordinate grid will be a combination of both subcomponents' grids | |
9354 | this.coordMap = new ComboCoordMap([ | |
9355 | this.dayGrid.coordMap, | |
9356 | this.timeGrid.coordMap | |
9357 | ]); | |
9358 | } | |
9359 | else { | |
9360 | this.coordMap = this.timeGrid.coordMap; | |
9361 | } | |
9362 | }, | |
9363 | ||
9364 | ||
9365 | /* Rendering | |
9366 | ------------------------------------------------------------------------------------------------------------------*/ | |
9367 | ||
9368 | ||
9369 | // Sets the display range and computes all necessary dates | |
9370 | setRange: function(range) { | |
9371 | View.prototype.setRange.call(this, range); // call the super-method | |
9372 | ||
9373 | this.timeGrid.setRange(range); | |
9374 | if (this.dayGrid) { | |
9375 | this.dayGrid.setRange(range); | |
9376 | } | |
9377 | }, | |
9378 | ||
9379 | ||
9380 | // Renders the view into `this.el`, which has already been assigned | |
9381 | render: function() { | |
9382 | ||
9383 | this.el.addClass('fc-agenda-view').html(this.renderHtml()); | |
9384 | ||
9385 | // the element that wraps the time-grid that will probably scroll | |
9386 | this.scrollerEl = this.el.find('.fc-time-grid-container'); | |
9387 | this.timeGrid.coordMap.containerEl = this.scrollerEl; // don't accept clicks/etc outside of this | |
9388 | ||
9389 | this.timeGrid.el = this.el.find('.fc-time-grid'); | |
9390 | this.timeGrid.render(); | |
9391 | ||
9392 | // the <hr> that sometimes displays under the time-grid | |
9393 | this.bottomRuleEl = $('<hr class="' + this.widgetHeaderClass + '"/>') | |
9394 | .appendTo(this.timeGrid.el); // inject it into the time-grid | |
9395 | ||
9396 | if (this.dayGrid) { | |
9397 | this.dayGrid.el = this.el.find('.fc-day-grid'); | |
9398 | this.dayGrid.render(); | |
9399 | ||
9400 | // have the day-grid extend it's coordinate area over the <hr> dividing the two grids | |
9401 | this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight(); | |
9402 | } | |
9403 | ||
9404 | this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller | |
9405 | }, | |
9406 | ||
9407 | ||
9408 | // Make subcomponents ready for cleanup | |
9409 | destroy: function() { | |
9410 | this.timeGrid.destroy(); | |
9411 | if (this.dayGrid) { | |
9412 | this.dayGrid.destroy(); | |
9413 | } | |
9414 | View.prototype.destroy.call(this); // call the super-method | |
9415 | }, | |
9416 | ||
9417 | ||
9418 | // Builds the HTML skeleton for the view. | |
9419 | // The day-grid and time-grid components will render inside containers defined by this HTML. | |
9420 | renderHtml: function() { | |
9421 | return '' + | |
9422 | '<table>' + | |
9423 | '<thead>' + | |
9424 | '<tr>' + | |
9425 | '<td class="' + this.widgetHeaderClass + '">' + | |
9426 | this.timeGrid.headHtml() + // render the day-of-week headers | |
9427 | '</td>' + | |
9428 | '</tr>' + | |
9429 | '</thead>' + | |
9430 | '<tbody>' + | |
9431 | '<tr>' + | |
9432 | '<td class="' + this.widgetContentClass + '">' + | |
9433 | (this.dayGrid ? | |
9434 | '<div class="fc-day-grid"/>' + | |
9435 | '<hr class="' + this.widgetHeaderClass + '"/>' : | |
9436 | '' | |
9437 | ) + | |
9438 | '<div class="fc-time-grid-container">' + | |
9439 | '<div class="fc-time-grid"/>' + | |
9440 | '</div>' + | |
9441 | '</td>' + | |
9442 | '</tr>' + | |
9443 | '</tbody>' + | |
9444 | '</table>'; | |
9445 | }, | |
9446 | ||
9447 | ||
9448 | // Generates the HTML that will go before the day-of week header cells. | |
9449 | // Queried by the TimeGrid subcomponent when generating rows. Ordering depends on isRTL. | |
9450 | headIntroHtml: function() { | |
9451 | var date; | |
9452 | var weekNumber; | |
9453 | var weekTitle; | |
9454 | var weekText; | |
9455 | ||
9456 | if (this.opt('weekNumbers')) { | |
9457 | date = this.timeGrid.getCell(0).start; | |
9458 | weekNumber = this.calendar.calculateWeekNumber(date); | |
9459 | weekTitle = this.opt('weekNumberTitle'); | |
9460 | ||
9461 | if (this.opt('isRTL')) { | |
9462 | weekText = weekNumber + weekTitle; | |
9463 | } | |
9464 | else { | |
9465 | weekText = weekTitle + weekNumber; | |
9466 | } | |
9467 | ||
9468 | return '' + | |
9469 | '<th class="fc-axis fc-week-number ' + this.widgetHeaderClass + '" ' + this.axisStyleAttr() + '>' + | |
9470 | '<span>' + // needed for matchCellWidths | |
9471 | htmlEscape(weekText) + | |
9472 | '</span>' + | |
9473 | '</th>'; | |
9474 | } | |
9475 | else { | |
9476 | return '<th class="fc-axis ' + this.widgetHeaderClass + '" ' + this.axisStyleAttr() + '></th>'; | |
9477 | } | |
9478 | }, | |
9479 | ||
9480 | ||
9481 | // Generates the HTML that goes before the all-day cells. | |
9482 | // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL. | |
9483 | dayIntroHtml: function() { | |
9484 | return '' + | |
9485 | '<td class="fc-axis ' + this.widgetContentClass + '" ' + this.axisStyleAttr() + '>' + | |
9486 | '<span>' + // needed for matchCellWidths | |
9487 | (this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'))) + | |
9488 | '</span>' + | |
9489 | '</td>'; | |
9490 | }, | |
9491 | ||
9492 | ||
9493 | // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column. | |
9494 | slotBgIntroHtml: function() { | |
9495 | return '<td class="fc-axis ' + this.widgetContentClass + '" ' + this.axisStyleAttr() + '></td>'; | |
9496 | }, | |
9497 | ||
9498 | ||
9499 | // Generates the HTML that goes before all other types of cells. | |
9500 | // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. | |
9501 | // Queried by the TimeGrid and DayGrid subcomponents when generating rows. Ordering depends on isRTL. | |
9502 | introHtml: function() { | |
9503 | return '<td class="fc-axis" ' + this.axisStyleAttr() + '></td>'; | |
9504 | }, | |
9505 | ||
9506 | ||
9507 | // Generates an HTML attribute string for setting the width of the axis, if it is known | |
9508 | axisStyleAttr: function() { | |
9509 | if (this.axisWidth !== null) { | |
9510 | return 'style="width:' + this.axisWidth + 'px"'; | |
9511 | } | |
9512 | return ''; | |
9513 | }, | |
9514 | ||
9515 | ||
9516 | /* Dimensions | |
9517 | ------------------------------------------------------------------------------------------------------------------*/ | |
9518 | ||
9519 | ||
9520 | updateSize: function(isResize) { | |
9521 | if (isResize) { | |
9522 | this.timeGrid.resize(); | |
9523 | } | |
9524 | View.prototype.updateSize.call(this, isResize); | |
9525 | }, | |
9526 | ||
9527 | ||
9528 | // Refreshes the horizontal dimensions of the view | |
9529 | updateWidth: function() { | |
9530 | // make all axis cells line up, and record the width so newly created axis cells will have it | |
9531 | this.axisWidth = matchCellWidths(this.el.find('.fc-axis')); | |
9532 | }, | |
9533 | ||
9534 | ||
9535 | // Adjusts the vertical dimensions of the view to the specified values | |
9536 | setHeight: function(totalHeight, isAuto) { | |
9537 | var eventLimit; | |
9538 | var scrollerHeight; | |
9539 | ||
9540 | if (this.bottomRuleHeight === null) { | |
9541 | // calculate the height of the rule the very first time | |
9542 | this.bottomRuleHeight = this.bottomRuleEl.outerHeight(); | |
9543 | } | |
9544 | this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary | |
9545 | ||
9546 | // reset all dimensions back to the original state | |
9547 | this.scrollerEl.css('overflow', ''); | |
9548 | unsetScroller(this.scrollerEl); | |
9549 | uncompensateScroll(this.noScrollRowEls); | |
9550 | ||
9551 | // limit number of events in the all-day area | |
9552 | if (this.dayGrid) { | |
9553 | this.dayGrid.destroySegPopover(); // kill the "more" popover if displayed | |
9554 | ||
9555 | eventLimit = this.opt('eventLimit'); | |
9556 | if (eventLimit && typeof eventLimit !== 'number') { | |
9557 | eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number | |
9558 | } | |
9559 | if (eventLimit) { | |
9560 | this.dayGrid.limitRows(eventLimit); | |
9561 | } | |
9562 | } | |
9563 | ||
9564 | if (!isAuto) { // should we force dimensions of the scroll container, or let the contents be natural height? | |
9565 | ||
9566 | scrollerHeight = this.computeScrollerHeight(totalHeight); | |
9567 | if (setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars? | |
9568 | ||
9569 | // make the all-day and header rows lines up | |
9570 | compensateScroll(this.noScrollRowEls, getScrollbarWidths(this.scrollerEl)); | |
9571 | ||
9572 | // the scrollbar compensation might have changed text flow, which might affect height, so recalculate | |
9573 | // and reapply the desired height to the scroller. | |
9574 | scrollerHeight = this.computeScrollerHeight(totalHeight); | |
9575 | this.scrollerEl.height(scrollerHeight); | |
9576 | ||
9577 | this.restoreScroll(); | |
9578 | } | |
9579 | else { // no scrollbars | |
9580 | // still, force a height and display the bottom rule (marks the end of day) | |
9581 | this.scrollerEl.height(scrollerHeight).css('overflow', 'hidden'); // in case <hr> goes outside | |
9582 | this.bottomRuleEl.show(); | |
9583 | } | |
9584 | } | |
9585 | }, | |
9586 | ||
9587 | ||
9588 | // Sets the scroll value of the scroller to the initial pre-configured state prior to allowing the user to change it | |
9589 | initializeScroll: function() { | |
9590 | var _this = this; | |
9591 | var scrollTime = moment.duration(this.opt('scrollTime')); | |
9592 | var top = this.timeGrid.computeTimeTop(scrollTime); | |
9593 | ||
9594 | // zoom can give weird floating-point values. rather scroll a little bit further | |
9595 | top = Math.ceil(top); | |
9596 | ||
9597 | if (top) { | |
9598 | top++; // to overcome top border that slots beyond the first have. looks better | |
9599 | } | |
9600 | ||
9601 | function scroll() { | |
9602 | _this.scrollerEl.scrollTop(top); | |
9603 | } | |
9604 | ||
9605 | scroll(); | |
9606 | setTimeout(scroll, 0); // overrides any previous scroll state made by the browser | |
9607 | }, | |
9608 | ||
9609 | ||
9610 | /* Events | |
9611 | ------------------------------------------------------------------------------------------------------------------*/ | |
9612 | ||
9613 | ||
9614 | // Renders events onto the view and populates the View's segment array | |
9615 | renderEvents: function(events) { | |
9616 | var dayEvents = []; | |
9617 | var timedEvents = []; | |
9618 | var daySegs = []; | |
9619 | var timedSegs; | |
9620 | var i; | |
9621 | ||
9622 | // separate the events into all-day and timed | |
9623 | for (i = 0; i < events.length; i++) { | |
9624 | if (events[i].allDay) { | |
9625 | dayEvents.push(events[i]); | |
9626 | } | |
9627 | else { | |
9628 | timedEvents.push(events[i]); | |
9629 | } | |
9630 | } | |
9631 | ||
9632 | // render the events in the subcomponents | |
9633 | timedSegs = this.timeGrid.renderEvents(timedEvents); | |
9634 | if (this.dayGrid) { | |
9635 | daySegs = this.dayGrid.renderEvents(dayEvents); | |
9636 | } | |
9637 | ||
9638 | // the all-day area is flexible and might have a lot of events, so shift the height | |
9639 | this.updateHeight(); | |
9640 | }, | |
9641 | ||
9642 | ||
9643 | // Retrieves all segment objects that are rendered in the view | |
9644 | getEventSegs: function() { | |
9645 | return this.timeGrid.getEventSegs().concat( | |
9646 | this.dayGrid ? this.dayGrid.getEventSegs() : [] | |
9647 | ); | |
9648 | }, | |
9649 | ||
9650 | ||
9651 | // Unrenders all event elements and clears internal segment data | |
9652 | destroyEvents: function() { | |
9653 | ||
9654 | // if destroyEvents is being called as part of an event rerender, renderEvents will be called shortly | |
9655 | // after, so remember what the scroll value was so we can restore it. | |
9656 | this.recordScroll(); | |
9657 | ||
9658 | // destroy the events in the subcomponents | |
9659 | this.timeGrid.destroyEvents(); | |
9660 | if (this.dayGrid) { | |
9661 | this.dayGrid.destroyEvents(); | |
9662 | } | |
9663 | ||
9664 | // we DON'T need to call updateHeight() because: | |
9665 | // A) a renderEvents() call always happens after this, which will eventually call updateHeight() | |
9666 | // B) in IE8, this causes a flash whenever events are rerendered | |
9667 | }, | |
9668 | ||
9669 | ||
9670 | /* Dragging (for events and external elements) | |
9671 | ------------------------------------------------------------------------------------------------------------------*/ | |
9672 | ||
9673 | ||
9674 | // A returned value of `true` signals that a mock "helper" event has been rendered. | |
9675 | renderDrag: function(dropLocation, seg) { | |
9676 | if (dropLocation.start.hasTime()) { | |
9677 | return this.timeGrid.renderDrag(dropLocation, seg); | |
9678 | } | |
9679 | else if (this.dayGrid) { | |
9680 | return this.dayGrid.renderDrag(dropLocation, seg); | |
9681 | } | |
9682 | }, | |
9683 | ||
9684 | ||
9685 | destroyDrag: function() { | |
9686 | this.timeGrid.destroyDrag(); | |
9687 | if (this.dayGrid) { | |
9688 | this.dayGrid.destroyDrag(); | |
9689 | } | |
9690 | }, | |
9691 | ||
9692 | ||
9693 | /* Selection | |
9694 | ------------------------------------------------------------------------------------------------------------------*/ | |
9695 | ||
9696 | ||
9697 | // Renders a visual indication of a selection | |
9698 | renderSelection: function(range) { | |
9699 | if (range.start.hasTime() || range.end.hasTime()) { | |
9700 | this.timeGrid.renderSelection(range); | |
9701 | } | |
9702 | else if (this.dayGrid) { | |
9703 | this.dayGrid.renderSelection(range); | |
9704 | } | |
9705 | }, | |
9706 | ||
9707 | ||
9708 | // Unrenders a visual indications of a selection | |
9709 | destroySelection: function() { | |
9710 | this.timeGrid.destroySelection(); | |
9711 | if (this.dayGrid) { | |
9712 | this.dayGrid.destroySelection(); | |
9713 | } | |
9714 | } | |
9715 | ||
9716 | }); | |
9717 | ||
9718 | /* A week view with an all-day cell area at the top, and a time grid below | |
9719 | ----------------------------------------------------------------------------------------------------------------------*/ | |
9720 | ||
9721 | fcViews.agendaWeek = { | |
9722 | type: 'agenda', | |
9723 | duration: { weeks: 1 } | |
9724 | }; | |
9725 | /* A day view with an all-day cell area at the top, and a time grid below | |
9726 | ----------------------------------------------------------------------------------------------------------------------*/ | |
9727 | ||
9728 | fcViews.agendaDay = { | |
9729 | type: 'agenda', | |
9730 | duration: { days: 1 } | |
9731 | }; | |
9732 | }); |