diff --git a/css/fullcalendar.css b/css/fullcalendar.css rename from css/fullcalendar.min.css rename to css/fullcalendar.css index d7ea430..56ad93b 100644 --- a/css/fullcalendar.min.css +++ b/css/fullcalendar.css @@ -1,5 +1,1516 @@ /*! - * FullCalendar v3.8.0 + * FullCalendar v0.0.0 Stylesheet * Docs & License: https://fullcalendar.io/ * (c) 2017 Adam Shaw - */.fc button,.fc table,body .fc{font-size:1em}.fc-bg,.fc-row .fc-bgevent-skeleton,.fc-row .fc-highlight-skeleton{bottom:0}.fc-icon,.fc-unselectable{-webkit-touch-callout:none;-khtml-user-select:none}.fc{direction:ltr;text-align:left}.fc-rtl{text-align:right}.fc th,.fc-basic-view td.fc-week-number,.fc-icon,.fc-toolbar{text-align:center}.fc-highlight{background:#bce8f1;opacity:.3}.fc-bgevent{background:#8fdf82;opacity:.3}.fc-nonbusiness{background:#d7d7d7}.fc button{-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;margin:0;height:2.1em;padding:0 .6em;white-space:nowrap;cursor:pointer}.fc button::-moz-focus-inner{margin:0;padding:0}.fc-state-default{border:1px solid;background-color:#f5f5f5;background-image:-moz-linear-gradient(top,#fff,#e6e6e6);background-image:-webkit-gradient(linear,0 0,0 100%,from(#fff),to(#e6e6e6));background-image:-webkit-linear-gradient(top,#fff,#e6e6e6);background-image:-o-linear-gradient(top,#fff,#e6e6e6);background-image:linear-gradient(to bottom,#fff,#e6e6e6);background-repeat:repeat-x;border-color:#e6e6e6 #e6e6e6 #bfbfbf;border-color:rgba(0,0,0,.1) rgba(0,0,0,.1) rgba(0,0,0,.25);color:#333;text-shadow:0 1px 1px rgba(255,255,255,.75);box-shadow:inset 0 1px 0 rgba(255,255,255,.2),0 1px 2px rgba(0,0,0,.05)}.fc-state-default.fc-corner-left{border-top-left-radius:4px;border-bottom-left-radius:4px}.fc-state-default.fc-corner-right{border-top-right-radius:4px;border-bottom-right-radius:4px}.fc button .fc-icon{position:relative;top:-.05em;margin:0 .2em;vertical-align:middle}.fc-state-active,.fc-state-disabled,.fc-state-down,.fc-state-hover{color:#333;background-color:#e6e6e6}.fc-state-hover{color:#333;text-decoration:none;background-position:0 -15px;-webkit-transition:background-position .1s linear;-moz-transition:background-position .1s linear;-o-transition:background-position .1s linear;transition:background-position .1s linear}.fc-state-active,.fc-state-down{background-color:#ccc;background-image:none;box-shadow:inset 0 2px 4px rgba(0,0,0,.15),0 1px 2px rgba(0,0,0,.05)}.fc-state-disabled{cursor:default;background-image:none;opacity:.65;box-shadow:none}.fc-event.fc-draggable,.fc-event[href],.fc-popover .fc-header .fc-close,a[data-goto]{cursor:pointer}.fc-button-group{display:inline-block}.fc .fc-button-group>*{float:left;margin:0 0 0 -1px}.fc .fc-button-group>:first-child{margin-left:0}.fc-popover{position:absolute;box-shadow:0 2px 6px rgba(0,0,0,.15)}.fc-popover .fc-header{padding:2px 4px}.fc-popover .fc-header .fc-title{margin:0 2px}.fc-ltr .fc-popover .fc-header .fc-title,.fc-rtl .fc-popover .fc-header .fc-close{float:left}.fc-ltr .fc-popover .fc-header .fc-close,.fc-rtl .fc-popover .fc-header .fc-title{float:right}.fc-divider{border-style:solid;border-width:1px}hr.fc-divider{height:0;margin:0;padding:0 0 2px;border-width:1px 0}.fc-bg table,.fc-row .fc-bgevent-skeleton table,.fc-row .fc-highlight-skeleton table{height:100%}.fc-clear{clear:both}.fc-bg,.fc-bgevent-skeleton,.fc-helper-skeleton,.fc-highlight-skeleton{position:absolute;top:0;left:0;right:0}.fc table{width:100%;box-sizing:border-box;table-layout:fixed;border-collapse:collapse;border-spacing:0}.fc td,.fc th{border-style:solid;border-width:1px;padding:0;vertical-align:top}.fc td.fc-today{border-style:double}a[data-goto]:hover{text-decoration:underline}.fc .fc-row{border-style:solid;border-width:0}.fc-row table{border-left:0 hidden transparent;border-right:0 hidden transparent;border-bottom:0 hidden transparent}.fc-row:first-child table{border-top:0 hidden transparent}.fc-row{position:relative}.fc-row .fc-bg{z-index:1}.fc-row .fc-bgevent-skeleton td,.fc-row .fc-highlight-skeleton td{border-color:transparent}.fc-row .fc-bgevent-skeleton{z-index:2}.fc-row .fc-highlight-skeleton{z-index:3}.fc-row .fc-content-skeleton{position:relative;z-index:4;padding-bottom:2px}.fc-row .fc-helper-skeleton{z-index:5}.fc .fc-row .fc-content-skeleton table,.fc .fc-row .fc-content-skeleton td,.fc .fc-row .fc-helper-skeleton td{background:0 0;border-color:transparent}.fc-row .fc-content-skeleton td,.fc-row .fc-helper-skeleton td{border-bottom:0}.fc-row .fc-content-skeleton tbody td,.fc-row .fc-helper-skeleton tbody td{border-top:0}.fc-scroller{-webkit-overflow-scrolling:touch}.fc-icon,.fc-row.fc-rigid,.fc-time-grid-event{overflow:hidden}.fc-scroller>.fc-day-grid,.fc-scroller>.fc-time-grid{position:relative;width:100%}.fc-event{position:relative;display:block;font-size:.85em;line-height:1.3;border-radius:3px;border:1px solid #3a87ad}.fc-event,.fc-event-dot{background-color:#3a87ad}.fc-event,.fc-event:hover{color:#fff;text-decoration:none}.fc-not-allowed,.fc-not-allowed .fc-event{cursor:not-allowed}.fc-event .fc-bg{z-index:1;background:#fff;opacity:.25}.fc-event .fc-content{position:relative;z-index:2}.fc-event .fc-resizer{position:absolute;z-index:4;display:none}.fc-event.fc-allow-mouse-resize .fc-resizer,.fc-event.fc-selected .fc-resizer{display:block}.fc-event.fc-selected .fc-resizer:before{content:"";position:absolute;z-index:9999;top:50%;left:50%;width:40px;height:40px;margin-left:-20px;margin-top:-20px}.fc-event.fc-selected{z-index:9999!important;box-shadow:0 2px 5px rgba(0,0,0,.2)}.fc-event.fc-selected.fc-dragging{box-shadow:0 2px 7px rgba(0,0,0,.3)}.fc-h-event.fc-selected:before{content:"";position:absolute;z-index:3;top:-10px;bottom:-10px;left:0;right:0}.fc-ltr .fc-h-event.fc-not-start,.fc-rtl .fc-h-event.fc-not-end{margin-left:0;border-left-width:0;padding-left:1px;border-top-left-radius:0;border-bottom-left-radius:0}.fc-ltr .fc-h-event.fc-not-end,.fc-rtl .fc-h-event.fc-not-start{margin-right:0;border-right-width:0;padding-right:1px;border-top-right-radius:0;border-bottom-right-radius:0}.fc-ltr .fc-h-event .fc-start-resizer,.fc-rtl .fc-h-event .fc-end-resizer{cursor:w-resize;left:-1px}.fc-ltr .fc-h-event .fc-end-resizer,.fc-rtl .fc-h-event .fc-start-resizer{cursor:e-resize;right:-1px}.fc-h-event.fc-allow-mouse-resize .fc-resizer{width:7px;top:-1px;bottom:-1px}.fc-h-event.fc-selected .fc-resizer{border-radius:4px;border-width:1px;width:6px;height:6px;border-style:solid;border-color:inherit;background:#fff;top:50%;margin-top:-4px}.fc-ltr .fc-h-event.fc-selected .fc-start-resizer,.fc-rtl .fc-h-event.fc-selected .fc-end-resizer{margin-left:-4px}.fc-ltr .fc-h-event.fc-selected .fc-end-resizer,.fc-rtl .fc-h-event.fc-selected .fc-start-resizer{margin-right:-4px}.fc-day-grid-event{margin:1px 2px 0;padding:0 1px}tr:first-child>td>.fc-day-grid-event{margin-top:2px}.fc-day-grid-event.fc-selected:after{content:"";position:absolute;z-index:1;top:-1px;right:-1px;bottom:-1px;left:-1px;background:#000;opacity:.25}.fc-day-grid-event .fc-content{white-space:nowrap;overflow:hidden}.fc-day-grid-event .fc-time{font-weight:700}.fc-ltr .fc-day-grid-event.fc-allow-mouse-resize .fc-start-resizer,.fc-rtl .fc-day-grid-event.fc-allow-mouse-resize .fc-end-resizer{margin-left:-2px}.fc-ltr .fc-day-grid-event.fc-allow-mouse-resize .fc-end-resizer,.fc-rtl .fc-day-grid-event.fc-allow-mouse-resize .fc-start-resizer{margin-right:-2px}a.fc-more{margin:1px 3px;font-size:.85em;cursor:pointer;text-decoration:none}a.fc-more:hover{text-decoration:underline}.fc.fc-bootstrap3 a,.ui-widget .fc-event{text-decoration:none}.fc-limited{display:none}.fc-icon,.fc-toolbar .fc-center{display:inline-block}.fc-day-grid .fc-row{z-index:1}.fc-more-popover{z-index:2;width:220px}.fc-more-popover .fc-event-container{padding:10px}.fc-now-indicator{position:absolute;border:0 solid red}.fc-icon:after,.fc-toolbar button{position:relative}.fc-unselectable{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}.fc-unthemed .fc-content,.fc-unthemed .fc-divider,.fc-unthemed .fc-list-heading td,.fc-unthemed .fc-list-view,.fc-unthemed .fc-popover,.fc-unthemed .fc-row,.fc-unthemed tbody,.fc-unthemed td,.fc-unthemed th,.fc-unthemed thead{border-color:#ddd}.fc-unthemed .fc-popover{background-color:#fff;border-width:1px;border-style:solid}.fc-unthemed .fc-divider,.fc-unthemed .fc-list-heading td,.fc-unthemed .fc-popover .fc-header{background:#eee}.fc-unthemed td.fc-today{background:#fcf8e3}.fc-unthemed .fc-disabled-day{background:#d7d7d7;opacity:.3}.fc-icon{height:1em;line-height:1em;font-size:1em;font-family:"Courier New",Courier,monospace;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.fc-icon-left-single-arrow:after{content:"\2039";font-weight:700;font-size:200%;top:-7%}.fc-icon-right-single-arrow:after{content:"\203A";font-weight:700;font-size:200%;top:-7%}.fc-icon-left-double-arrow:after{content:"\AB";font-size:160%;top:-7%}.fc-icon-right-double-arrow:after{content:"\BB";font-size:160%;top:-7%}.fc-icon-left-triangle:after{content:"\25C4";font-size:125%;top:3%}.fc-icon-right-triangle:after{content:"\25BA";font-size:125%;top:3%}.fc-icon-down-triangle:after{content:"\25BC";font-size:125%;top:2%}.fc-icon-x:after{content:"\D7";font-size:200%;top:6%}.fc-unthemed .fc-popover .fc-header .fc-close{color:#666;font-size:.9em;margin-top:2px}.fc-unthemed .fc-list-item:hover td{background-color:#f5f5f5}.ui-widget .fc-disabled-day{background-image:none}.fc-bootstrap3 .fc-time-grid .fc-slats table,.fc-time-grid .fc-slats .ui-widget-content{background:0 0}.fc-popover>.ui-widget-header+.ui-widget-content{border-top:0}.ui-widget .fc-event{color:#fff;font-weight:400}.ui-widget td.fc-axis{font-weight:400}.fc.fc-bootstrap3 a[data-goto]:hover{text-decoration:underline}.fc-bootstrap3 hr.fc-divider{border-color:inherit}.fc-bootstrap3 .fc-today.alert{border-radius:0}.fc-bootstrap3 .fc-popover .panel-body{padding:0}.fc-toolbar.fc-header-toolbar{margin-bottom:1em}.fc-toolbar.fc-footer-toolbar{margin-top:1em}.fc-toolbar .fc-left{float:left}.fc-toolbar .fc-right{float:right}.fc .fc-toolbar>*>*{float:left;margin-left:.75em}.fc .fc-toolbar>*>:first-child{margin-left:0}.fc-toolbar h2{margin:0}.fc-toolbar .fc-state-hover,.fc-toolbar .ui-state-hover{z-index:2}.fc-toolbar .fc-state-down{z-index:3}.fc-toolbar .fc-state-active,.fc-toolbar .ui-state-active{z-index:4}.fc-toolbar button:focus{z-index:5}.fc-view-container *,.fc-view-container :after,.fc-view-container :before{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}.fc-view,.fc-view>table{position:relative;z-index:1}.fc-basicDay-view .fc-content-skeleton,.fc-basicWeek-view .fc-content-skeleton{padding-bottom:1em}.fc-basic-view .fc-body .fc-row{min-height:4em}.fc-row.fc-rigid .fc-content-skeleton{position:absolute;top:0;left:0;right:0}.fc-day-top.fc-other-month{opacity:.3}.fc-basic-view .fc-day-number,.fc-basic-view .fc-week-number{padding:2px}.fc-basic-view th.fc-day-number,.fc-basic-view th.fc-week-number{padding:0 2px}.fc-ltr .fc-basic-view .fc-day-top .fc-day-number{float:right}.fc-rtl .fc-basic-view .fc-day-top .fc-day-number{float:left}.fc-ltr .fc-basic-view .fc-day-top .fc-week-number{float:left;border-radius:0 0 3px}.fc-rtl .fc-basic-view .fc-day-top .fc-week-number{float:right;border-radius:0 0 0 3px}.fc-basic-view .fc-day-top .fc-week-number{min-width:1.5em;text-align:center;background-color:#f2f2f2;color:grey}.fc-basic-view td.fc-week-number>*{display:inline-block;min-width:1.25em}.fc-agenda-view .fc-day-grid{position:relative;z-index:2}.fc-agenda-view .fc-day-grid .fc-row{min-height:3em}.fc-agenda-view .fc-day-grid .fc-row .fc-content-skeleton{padding-bottom:1em}.fc .fc-axis{vertical-align:middle;padding:0 4px;white-space:nowrap}.fc-ltr .fc-axis{text-align:right}.fc-rtl .fc-axis{text-align:left}.fc-time-grid,.fc-time-grid-container{position:relative;z-index:1}.fc-time-grid{min-height:100%}.fc-time-grid table{border:0 hidden transparent}.fc-time-grid>.fc-bg{z-index:1}.fc-time-grid .fc-slats,.fc-time-grid>hr{position:relative;z-index:2}.fc-time-grid .fc-content-col{position:relative}.fc-time-grid .fc-content-skeleton{position:absolute;z-index:3;top:0;left:0;right:0}.fc-time-grid .fc-business-container{position:relative;z-index:1}.fc-time-grid .fc-bgevent-container{position:relative;z-index:2}.fc-time-grid .fc-highlight-container{z-index:3;position:relative}.fc-time-grid .fc-event-container{position:relative;z-index:4}.fc-time-grid .fc-now-indicator-line{z-index:5}.fc-time-grid .fc-helper-container{position:relative;z-index:6}.fc-time-grid .fc-slats td{height:1.5em;border-bottom:0}.fc-time-grid .fc-slats .fc-minor td{border-top-style:dotted}.fc-time-grid .fc-highlight{position:absolute;left:0;right:0}.fc-ltr .fc-time-grid .fc-event-container{margin:0 2.5% 0 2px}.fc-rtl .fc-time-grid .fc-event-container{margin:0 2px 0 2.5%}.fc-time-grid .fc-bgevent,.fc-time-grid .fc-event{position:absolute;z-index:1}.fc-time-grid .fc-bgevent{left:0;right:0}.fc-v-event.fc-not-start{border-top-width:0;padding-top:1px;border-top-left-radius:0;border-top-right-radius:0}.fc-v-event.fc-not-end{border-bottom-width:0;padding-bottom:1px;border-bottom-left-radius:0;border-bottom-right-radius:0}.fc-time-grid-event.fc-selected{overflow:visible}.fc-time-grid-event.fc-selected .fc-bg{display:none}.fc-time-grid-event .fc-content{overflow:hidden}.fc-time-grid-event .fc-time,.fc-time-grid-event .fc-title{padding:0 1px}.fc-time-grid-event .fc-time{font-size:.85em;white-space:nowrap}.fc-time-grid-event.fc-short .fc-content{white-space:nowrap}.fc-time-grid-event.fc-short .fc-time,.fc-time-grid-event.fc-short .fc-title{display:inline-block;vertical-align:top}.fc-time-grid-event.fc-short .fc-time span{display:none}.fc-time-grid-event.fc-short .fc-time:before{content:attr(data-start)}.fc-time-grid-event.fc-short .fc-time:after{content:"\A0-\A0"}.fc-time-grid-event.fc-short .fc-title{font-size:.85em;padding:0}.fc-time-grid-event.fc-allow-mouse-resize .fc-resizer{left:0;right:0;bottom:0;height:8px;overflow:hidden;line-height:8px;font-size:11px;font-family:monospace;text-align:center;cursor:s-resize}.fc-time-grid-event.fc-allow-mouse-resize .fc-resizer:after{content:"="}.fc-time-grid-event.fc-selected .fc-resizer{border-radius:5px;border-width:1px;width:8px;height:8px;border-style:solid;border-color:inherit;background:#fff;left:50%;margin-left:-5px;bottom:-5px}.fc-time-grid .fc-now-indicator-line{border-top-width:1px;left:0;right:0}.fc-time-grid .fc-now-indicator-arrow{margin-top:-5px}.fc-ltr .fc-time-grid .fc-now-indicator-arrow{left:0;border-width:5px 0 5px 6px;border-top-color:transparent;border-bottom-color:transparent}.fc-rtl .fc-time-grid .fc-now-indicator-arrow{right:0;border-width:5px 6px 5px 0;border-top-color:transparent;border-bottom-color:transparent}.fc-event-dot{display:inline-block;width:10px;height:10px;border-radius:5px}.fc-rtl .fc-list-view{direction:rtl}.fc-list-view{border-width:1px;border-style:solid}.fc .fc-list-table{table-layout:auto}.fc-list-table td{border-width:1px 0 0;padding:8px 14px}.fc-list-table tr:first-child td{border-top-width:0}.fc-list-heading{border-bottom-width:1px}.fc-list-heading td{font-weight:700}.fc-ltr .fc-list-heading-main{float:left}.fc-ltr .fc-list-heading-alt,.fc-rtl .fc-list-heading-main{float:right}.fc-rtl .fc-list-heading-alt{float:left}.fc-list-item.fc-has-url{cursor:pointer}.fc-list-item-marker,.fc-list-item-time{white-space:nowrap;width:1px}.fc-ltr .fc-list-item-marker{padding-right:0}.fc-rtl .fc-list-item-marker{padding-left:0}.fc-list-item-title a{text-decoration:none;color:inherit}.fc-list-item-title a[href]:hover{text-decoration:underline}.fc-list-empty-wrap2{position:absolute;top:0;left:0;right:0;bottom:0}.fc-list-empty-wrap1{width:100%;height:100%;display:table}.fc-list-empty{display:table-cell;vertical-align:middle;text-align:center}.fc-unthemed .fc-list-empty{background-color:#eee} \ No newline at end of file + */ + + +.fc { + direction: ltr; + text-align: left; +} + +.fc-rtl { + text-align: right; +} + +body .fc { /* extra precedence to overcome jqui */ + font-size: 1em; +} + + +/* Colors +--------------------------------------------------------------------------------------------------*/ + +.fc-unthemed th, +.fc-unthemed td, +.fc-unthemed thead, +.fc-unthemed tbody, +.fc-unthemed .fc-divider, +.fc-unthemed .fc-row, +.fc-unthemed .fc-content, /* for gutter border */ +.fc-unthemed .fc-popover, +.fc-unthemed .fc-list-view, +.fc-unthemed .fc-list-heading td { + border-color: #ddd; +} + +.fc-unthemed .fc-popover { + background-color: #fff; +} + +.fc-unthemed .fc-divider, +.fc-unthemed .fc-popover .fc-header, +.fc-unthemed .fc-list-heading td { + background: #eee; +} + +.fc-unthemed .fc-popover .fc-header .fc-close { + color: #666; +} + +.fc-unthemed td.fc-today { + background: #fcf8e3; +} + +.fc-highlight { /* when user is selecting cells */ + background: #bce8f1; + opacity: .3; +} + +.fc-bgevent { /* default look for background events */ + background: rgb(143, 223, 130); + opacity: .3; +} + +.fc-nonbusiness { /* default look for non-business-hours areas */ + /* will inherit .fc-bgevent's styles */ + background: #d7d7d7; +} + +.fc-unthemed .fc-disabled-day { + background: #d7d7d7; + opacity: .3; +} + +.ui-widget .fc-disabled-day { /* themed */ + background-image: none; +} + + +/* Icons (inline elements with styled text that mock arrow icons) +--------------------------------------------------------------------------------------------------*/ + +.fc-icon { + display: inline-block; + height: 1em; + line-height: 1em; + font-size: 1em; + text-align: center; + overflow: hidden; + font-family: "Courier New", Courier, monospace; + + /* don't allow browser text-selection */ + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +/* +Acceptable font-family overrides for individual icons: + "Arial", sans-serif + "Times New Roman", serif + +NOTE: use percentage font sizes or else old IE chokes +*/ + +.fc-icon:after { + position: relative; +} + +.fc-icon-left-single-arrow:after { + content: "\02039"; + font-weight: bold; + font-size: 200%; + top: -7%; +} + +.fc-icon-right-single-arrow:after { + content: "\0203A"; + font-weight: bold; + font-size: 200%; + top: -7%; +} + +.fc-icon-left-double-arrow:after { + content: "\000AB"; + font-size: 160%; + top: -7%; +} + +.fc-icon-right-double-arrow:after { + content: "\000BB"; + font-size: 160%; + top: -7%; +} + +.fc-icon-left-triangle:after { + content: "\25C4"; + font-size: 125%; + top: 3%; +} + +.fc-icon-right-triangle:after { + content: "\25BA"; + font-size: 125%; + top: 3%; +} + +.fc-icon-down-triangle:after { + content: "\25BC"; + font-size: 125%; + top: 2%; +} + +.fc-icon-x:after { + content: "\000D7"; + font-size: 200%; + top: 6%; +} + + +/* Buttons (styled ' + ) + .click(function(ev) { + // don't process clicks for disabled buttons + if (!button.hasClass(tm + '-state-disabled')) { + + buttonClick(ev); + + // after the click action, if the button becomes the "active" tab, or disabled, + // it should never have a hover class, so remove it now. + if ( + button.hasClass(tm + '-state-active') || + button.hasClass(tm + '-state-disabled') + ) { + button.removeClass(tm + '-state-hover'); + } + } + }) + .mousedown(function() { + // the *down* effect (mouse pressed in). + // only on buttons that are not the "active" tab, or disabled + button + .not('.' + tm + '-state-active') + .not('.' + tm + '-state-disabled') + .addClass(tm + '-state-down'); + }) + .mouseup(function() { + // undo the *down* effect + button.removeClass(tm + '-state-down'); + }) + .hover( + function() { + // the *hover* effect. + // only on buttons that are not the "active" tab, or disabled + button + .not('.' + tm + '-state-active') + .not('.' + tm + '-state-disabled') + .addClass(tm + '-state-hover'); + }, + function() { + // undo the *hover* effect + button + .removeClass(tm + '-state-hover') + .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup + } + ); + + groupChildren = groupChildren.add(button); + } + } + }); + + if (isOnlyButtons) { + groupChildren + .first().addClass(tm + '-corner-left').end() + .last().addClass(tm + '-corner-right').end(); + } + + if (groupChildren.length > 1) { + groupEl = $('
'); + if (isOnlyButtons) { + groupEl.addClass('fc-button-group'); + } + groupEl.append(groupChildren); + sectionEl.append(groupEl); + } + else { + sectionEl.append(groupChildren); // 1 or 0 children + } + }); + } + + return sectionEl; + } + + + function updateTitle(text) { + if (el) { + el.find('h2').text(text); + } + } + + + function activateButton(buttonName) { + if (el) { + el.find('.fc-' + buttonName + '-button') + .addClass(tm + '-state-active'); + } + } + + + function deactivateButton(buttonName) { + if (el) { + el.find('.fc-' + buttonName + '-button') + .removeClass(tm + '-state-active'); + } + } + + + function disableButton(buttonName) { + if (el) { + el.find('.fc-' + buttonName + '-button') + .prop('disabled', true) + .addClass(tm + '-state-disabled'); + } + } + + + function enableButton(buttonName) { + if (el) { + el.find('.fc-' + buttonName + '-button') + .prop('disabled', false) + .removeClass(tm + '-state-disabled'); + } + } + + + function getViewsWithButtons() { + return viewsWithButtons; + } + + } + + ;; + + var Calendar = FC.Calendar = Class.extend(EmitterMixin, { + + view: null, // current View object + viewsByType: null, // holds all instantiated view instances, current or not + currentDate: null, // unzoned moment. private (public API should use getDate instead) + loadingLevel: 0, // number of simultaneous loading tasks + + + constructor: function(el, overrides) { + + // declare the current calendar instance relies on GlobalEmitter. needed for garbage collection. + // unneeded() is called in destroy. + GlobalEmitter.needed(); + + this.el = el; + this.viewsByType = {}; + this.viewSpecCache = {}; + + this.initOptionsInternals(overrides); + this.initMomentInternals(); // needs to happen after options hash initialized + this.initCurrentDate(); + + EventManager.call(this); // needs options immediately + this.initialize(); + }, + + + // Subclasses can override this for initialization logic after the constructor has been called + initialize: function() { + }, + + + // Public API + // ----------------------------------------------------------------------------------------------------------------- + + + getCalendar: function() { + return this; + }, + + + getView: function() { + return this.view; + }, + + + publiclyTrigger: function(name, thisObj) { + var args = Array.prototype.slice.call(arguments, 2); + var optHandler = this.opt(name); + + thisObj = thisObj || this.el[0]; + this.triggerWith(name, thisObj, args); // Emitter's method + + if (optHandler) { + return optHandler.apply(thisObj, args); + } + }, + + + // View + // ----------------------------------------------------------------------------------------------------------------- + + + // Given a view name for a custom view or a standard view, creates a ready-to-go View object + instantiateView: function(viewType) { + var spec = this.getViewSpec(viewType); + + return new spec['class'](this, spec); + }, + + + // Returns a boolean about whether the view is okay to instantiate at some point + isValidViewType: function(viewType) { + return Boolean(this.getViewSpec(viewType)); + }, + + + changeView: function(viewName, dateOrRange) { + + if (dateOrRange) { + + if (dateOrRange.start && dateOrRange.end) { // a range + this.recordOptionOverrides({ // will not rerender + visibleRange: dateOrRange + }); + } + else { // a date + this.currentDate = this.moment(dateOrRange).stripZone(); // just like gotoDate + } + } + + this.renderView(viewName); + }, + + + // Forces navigation to a view for the given date. + // `viewType` can be a specific view name or a generic one like "week" or "day". + zoomTo: function(newDate, viewType) { + var spec; + + viewType = viewType || 'day'; // day is default zoom + spec = this.getViewSpec(viewType) || this.getUnitViewSpec(viewType); + + this.currentDate = newDate.clone(); + this.renderView(spec ? spec.type : null); + }, + + + // Current Date + // ----------------------------------------------------------------------------------------------------------------- + + + initCurrentDate: function() { + var defaultDateInput = this.opt('defaultDate'); + + // compute the initial ambig-timezone date + if (defaultDateInput != null) { + this.currentDate = this.moment(defaultDateInput).stripZone(); + } + else { + this.currentDate = this.getNow(); // getNow already returns unzoned + } + }, + + + prev: function() { + var prevInfo = this.view.buildPrevDateProfile(this.currentDate); + + if (prevInfo.isValid) { + this.currentDate = prevInfo.date; + this.renderView(); + } + }, + + + next: function() { + var nextInfo = this.view.buildNextDateProfile(this.currentDate); + + if (nextInfo.isValid) { + this.currentDate = nextInfo.date; + this.renderView(); + } + }, + + + prevYear: function() { + this.currentDate.add(-1, 'years'); + this.renderView(); + }, + + + nextYear: function() { + this.currentDate.add(1, 'years'); + this.renderView(); + }, + + + today: function() { + this.currentDate = this.getNow(); // should deny like prev/next? + this.renderView(); + }, + + + gotoDate: function(zonedDateInput) { + this.currentDate = this.moment(zonedDateInput).stripZone(); + this.renderView(); + }, + + + incrementDate: function(delta) { + this.currentDate.add(moment.duration(delta)); + this.renderView(); + }, + + + // for external API + getDate: function() { + return this.applyTimezone(this.currentDate); // infuse the calendar's timezone + }, + + + // Loading Triggering + // ----------------------------------------------------------------------------------------------------------------- + + + // Should be called when any type of async data fetching begins + pushLoading: function() { + if (!(this.loadingLevel++)) { + this.publiclyTrigger('loading', null, true, this.view); + } + }, + + + // Should be called when any type of async data fetching completes + popLoading: function() { + if (!(--this.loadingLevel)) { + this.publiclyTrigger('loading', null, false, this.view); + } + }, + + + // Selection + // ----------------------------------------------------------------------------------------------------------------- + + + // this public method receives start/end dates in any format, with any timezone + select: function(zonedStartInput, zonedEndInput) { + this.view.select( + this.buildSelectSpan.apply(this, arguments) + ); + }, + + + unselect: function() { // safe to be called before renderView + if (this.view) { + this.view.unselect(); + } + }, + + + // Given arguments to the select method in the API, returns a span (unzoned start/end and other info) + buildSelectSpan: function(zonedStartInput, zonedEndInput) { + var start = this.moment(zonedStartInput).stripZone(); + var end; + + if (zonedEndInput) { + end = this.moment(zonedEndInput).stripZone(); + } + else if (start.hasTime()) { + end = start.clone().add(this.defaultTimedEventDuration); + } + else { + end = start.clone().add(this.defaultAllDayEventDuration); + } + + return { start: start, end: end }; + }, + + + // Misc + // ----------------------------------------------------------------------------------------------------------------- + + + // will return `null` if invalid range + parseRange: function(rangeInput) { + var start = null; + var end = null; + + if (rangeInput.start) { + start = this.moment(rangeInput.start).stripZone(); + } + + if (rangeInput.end) { + end = this.moment(rangeInput.end).stripZone(); + } + + if (!start && !end) { + return null; + } + + if (start && end && end.isBefore(start)) { + return null; + } + + return { start: start, end: end }; + }, + + + rerenderEvents: function() { // API method. destroys old events if previously rendered. + if (this.elementVisible()) { + this.reportEventChange(); // will re-trasmit events to the view, causing a rerender + } + } + + }); + + ;; + /* +Options binding/triggering system. +*/ + Calendar.mixin({ + + dirDefaults: null, // option defaults related to LTR or RTL + localeDefaults: null, // option defaults related to current locale + overrides: null, // option overrides given to the fullCalendar constructor + dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides. + optionsModel: null, // all defaults combined with overrides + + + initOptionsInternals: function(overrides) { + this.overrides = $.extend({}, overrides); // make a copy + this.dynamicOverrides = {}; + this.optionsModel = new Model(); + + this.populateOptionsHash(); + }, + + + // public getter/setter + option: function(name, value) { + var newOptionHash; + + if (typeof name === 'string') { + if (value === undefined) { // getter + return this.optionsModel.get(name); + } + else { // setter for individual option + newOptionHash = {}; + newOptionHash[name] = value; + this.setOptions(newOptionHash); + } + } + else if (typeof name === 'object') { // compound setter with object input + this.setOptions(name); + } + }, + + + // private getter + opt: function(name) { + return this.optionsModel.get(name); + }, + + + setOptions: function(newOptionHash) { + var optionCnt = 0; + var optionName; + + this.recordOptionOverrides(newOptionHash); + + for (optionName in newOptionHash) { + optionCnt++; + } + + // special-case handling of single option change. + // if only one option change, `optionName` will be its name. + if (optionCnt === 1) { + if (optionName === 'height' || optionName === 'contentHeight' || optionName === 'aspectRatio') { + this.updateSize(true); // true = allow recalculation of height + return; + } + else if (optionName === 'defaultDate') { + return; // can't change date this way. use gotoDate instead + } + else if (optionName === 'businessHours') { + if (this.view) { + this.view.unrenderBusinessHours(); + this.view.renderBusinessHours(); + } + return; + } + else if (optionName === 'timezone') { + this.rezoneArrayEventSources(); + this.refetchEvents(); + return; + } + } + + // catch-all. rerender the header and footer and rebuild/rerender the current view + this.renderHeader(); + this.renderFooter(); + + // even non-current views will be affected by this option change. do before rerender + // TODO: detangle + this.viewsByType = {}; + + this.reinitView(); + }, + + + // Computes the flattened options hash for the calendar and assigns to `this.options`. + // Assumes this.overrides and this.dynamicOverrides have already been initialized. + populateOptionsHash: function() { + var locale, localeDefaults; + var isRTL, dirDefaults; + var rawOptions; + + locale = firstDefined( // explicit locale option given? + this.dynamicOverrides.locale, + this.overrides.locale + ); + localeDefaults = localeOptionHash[locale]; + if (!localeDefaults) { // explicit locale option not given or invalid? + locale = Calendar.defaults.locale; + localeDefaults = localeOptionHash[locale] || {}; + } + + isRTL = firstDefined( // based on options computed so far, is direction RTL? + this.dynamicOverrides.isRTL, + this.overrides.isRTL, + localeDefaults.isRTL, + Calendar.defaults.isRTL + ); + dirDefaults = isRTL ? Calendar.rtlDefaults : {}; + + this.dirDefaults = dirDefaults; + this.localeDefaults = localeDefaults; + + rawOptions = mergeOptions([ // merge defaults and overrides. lowest to highest precedence + Calendar.defaults, // global defaults + dirDefaults, + localeDefaults, + this.overrides, + this.dynamicOverrides + ]); + populateInstanceComputableOptions(rawOptions); // fill in gaps with computed options + + this.optionsModel.reset(rawOptions); + }, + + + // stores the new options internally, but does not rerender anything. + recordOptionOverrides: function(newOptionHash) { + var optionName; + + for (optionName in newOptionHash) { + this.dynamicOverrides[optionName] = newOptionHash[optionName]; + } + + this.viewSpecCache = {}; // the dynamic override invalidates the options in this cache, so just clear it + this.populateOptionsHash(); // this.options needs to be recomputed after the dynamic override + } + + }); + + ;; + + Calendar.mixin({ + + defaultAllDayEventDuration: null, + defaultTimedEventDuration: null, + localeData: null, + + + initMomentInternals: function() { + var _this = this; + + this.defaultAllDayEventDuration = moment.duration(this.opt('defaultAllDayEventDuration')); + this.defaultTimedEventDuration = moment.duration(this.opt('defaultTimedEventDuration')); + + // Called immediately, and when any of the options change. + // Happens before any internal objects rebuild or rerender, because this is very core. + this.optionsModel.watch('buildingMomentLocale', [ + '?locale', '?monthNames', '?monthNamesShort', '?dayNames', '?dayNamesShort', + '?firstDay', '?weekNumberCalculation' + ], function(opts) { + var weekNumberCalculation = opts.weekNumberCalculation; + var firstDay = opts.firstDay; + var _week; + + // normalize + if (weekNumberCalculation === 'iso') { + weekNumberCalculation = 'ISO'; // normalize + } + + var localeData = createObject( // make a cheap copy + getMomentLocaleData(opts.locale) // will fall back to en + ); + + if (opts.monthNames) { + localeData._months = opts.monthNames; + } + if (opts.monthNamesShort) { + localeData._monthsShort = opts.monthNamesShort; + } + if (opts.dayNames) { + localeData._weekdays = opts.dayNames; + } + if (opts.dayNamesShort) { + localeData._weekdaysShort = opts.dayNamesShort; + } + + if (firstDay == null && weekNumberCalculation === 'ISO') { + firstDay = 1; + } + if (firstDay != null) { + _week = createObject(localeData._week); // _week: { dow: # } + _week.dow = firstDay; + localeData._week = _week; + } + + if ( // whitelist certain kinds of input + weekNumberCalculation === 'ISO' || + weekNumberCalculation === 'local' || + typeof weekNumberCalculation === 'function' + ) { + localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it + } + + _this.localeData = localeData; + + // If the internal current date object already exists, move to new locale. + // We do NOT need to do this technique for event dates, because this happens when converting to "segments". + if (_this.currentDate) { + _this.localizeMoment(_this.currentDate); // sets to localeData + } + }); + }, + + + // Builds a moment using the settings of the current calendar: timezone and locale. + // Accepts anything the vanilla moment() constructor accepts. + moment: function() { + var mom; + + if (this.opt('timezone') === 'local') { + mom = FC.moment.apply(null, arguments); + + // Force the moment to be local, because FC.moment doesn't guarantee it. + if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone + mom.local(); + } + } + else if (this.opt('timezone') === 'UTC') { + mom = FC.moment.utc.apply(null, arguments); // process as UTC + } + else { + mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone + } + + this.localizeMoment(mom); // TODO + + return mom; + }, + + + // Updates the given moment's locale settings to the current calendar locale settings. + localizeMoment: function(mom) { + mom._locale = this.localeData; + }, + + + // Returns a boolean about whether or not the calendar knows how to calculate + // the timezone offset of arbitrary dates in the current timezone. + getIsAmbigTimezone: function() { + return this.opt('timezone') !== 'local' && this.opt('timezone') !== 'UTC'; + }, + + + // Returns a copy of the given date in the current timezone. Has no effect on dates without times. + applyTimezone: function(date) { + if (!date.hasTime()) { + return date.clone(); + } + + var zonedDate = this.moment(date.toArray()); + var timeAdjust = date.time() - zonedDate.time(); + var adjustedZonedDate; + + // Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396) + if (timeAdjust) { // is the time result different than expected? + adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds + if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now? + zonedDate = adjustedZonedDate; + } + } + + return zonedDate; + }, + + + // Returns a moment for the current date, as defined by the client's computer or from the `now` option. + // Will return an moment with an ambiguous timezone. + getNow: function() { + var now = this.opt('now'); + if (typeof now === 'function') { + now = now(); + } + return this.moment(now).stripZone(); + }, + + + // Produces a human-readable string for the given duration. + // Side-effect: changes the locale of the given duration. + humanizeDuration: function(duration) { + return duration.locale(this.opt('locale')).humanize(); + }, + + + + // Event-Specific Date Utilities. TODO: move + // ----------------------------------------------------------------------------------------------------------------- + + + // Get an event's normalized end date. If not present, calculate it from the defaults. + getEventEnd: function(event) { + if (event.end) { + return event.end.clone(); + } + else { + return this.getDefaultEventEnd(event.allDay, event.start); + } + }, + + + // Given an event's allDay status and start date, return what its fallback end date should be. + // TODO: rename to computeDefaultEventEnd + getDefaultEventEnd: function(allDay, zonedStart) { + var end = zonedStart.clone(); + + if (allDay) { + end.stripTime().add(this.defaultAllDayEventDuration); + } + else { + end.add(this.defaultTimedEventDuration); + } + + if (this.getIsAmbigTimezone()) { + end.stripZone(); // we don't know what the tzo should be + } + + return end; + } + + }); + + ;; + + Calendar.mixin({ + + viewSpecCache: null, // cache of view definitions (initialized in Calendar.js) + + + // Gets information about how to create a view. Will use a cache. + getViewSpec: function(viewType) { + var cache = this.viewSpecCache; + + return cache[viewType] || (cache[viewType] = this.buildViewSpec(viewType)); + }, + + + // Given a duration singular unit, like "week" or "day", finds a matching view spec. + // Preference is given to views that have corresponding buttons. + getUnitViewSpec: function(unit) { + var viewTypes; + var i; + var spec; + + if ($.inArray(unit, unitsDesc) != -1) { + + // put views that have buttons first. there will be duplicates, but oh well + viewTypes = this.header.getViewsWithButtons(); // TODO: include footer as well? + $.each(FC.views, function(viewType) { // all views + viewTypes.push(viewType); + }); + + for (i = 0; i < viewTypes.length; i++) { + spec = this.getViewSpec(viewTypes[i]); + if (spec) { + if (spec.singleUnit == unit) { + return spec; + } + } + } + } + }, + + + // Builds an object with information on how to create a given view + buildViewSpec: function(requestedViewType) { + var viewOverrides = this.overrides.views || {}; + var specChain = []; // for the view. lowest to highest priority + var defaultsChain = []; // for the view. lowest to highest priority + var overridesChain = []; // for the view. lowest to highest priority + var viewType = requestedViewType; + var spec; // for the view + var overrides; // for the view + var durationInput; + var duration; + var unit; + + // iterate from the specific view definition to a more general one until we hit an actual View class + while (viewType) { + spec = fcViews[viewType]; + overrides = viewOverrides[viewType]; + viewType = null; // clear. might repopulate for another iteration + + if (typeof spec === 'function') { // TODO: deprecate + spec = { 'class': spec }; + } + + if (spec) { + specChain.unshift(spec); + defaultsChain.unshift(spec.defaults || {}); + durationInput = durationInput || spec.duration; + viewType = viewType || spec.type; + } + + if (overrides) { + overridesChain.unshift(overrides); // view-specific option hashes have options at zero-level + durationInput = durationInput || overrides.duration; + viewType = viewType || overrides.type; + } + } + + spec = mergeProps(specChain); + spec.type = requestedViewType; + if (!spec['class']) { + return false; + } + + // fall back to top-level `duration` option + durationInput = durationInput || + this.dynamicOverrides.duration || + this.overrides.duration; + + if (durationInput) { + duration = moment.duration(durationInput); + + if (duration.valueOf()) { // valid? + + unit = computeDurationGreatestUnit(duration, durationInput); + + spec.duration = duration; + spec.durationUnit = unit; + + // view is a single-unit duration, like "week" or "day" + // incorporate options for this. lowest priority + if (duration.as(unit) === 1) { + spec.singleUnit = unit; + overridesChain.unshift(viewOverrides[unit] || {}); + } + } + } + + spec.defaults = mergeOptions(defaultsChain); + spec.overrides = mergeOptions(overridesChain); + + this.buildViewSpecOptions(spec); + this.buildViewSpecButtonText(spec, requestedViewType); + + return spec; + }, + + + // Builds and assigns a view spec's options object from its already-assigned defaults and overrides + buildViewSpecOptions: function(spec) { + spec.options = mergeOptions([ // lowest to highest priority + Calendar.defaults, // global defaults + spec.defaults, // view's defaults (from ViewSubclass.defaults) + this.dirDefaults, + this.localeDefaults, // locale and dir take precedence over view's defaults! + this.overrides, // calendar's overrides (options given to constructor) + spec.overrides, // view's overrides (view-specific options) + this.dynamicOverrides // dynamically set via setter. highest precedence + ]); + populateInstanceComputableOptions(spec.options); + }, + + + // Computes and assigns a view spec's buttonText-related options + buildViewSpecButtonText: function(spec, requestedViewType) { + + // given an options object with a possible `buttonText` hash, lookup the buttonText for the + // requested view, falling back to a generic unit entry like "week" or "day" + function queryButtonText(options) { + var buttonText = options.buttonText || {}; + return buttonText[requestedViewType] || + // view can decide to look up a certain key + (spec.buttonTextKey ? buttonText[spec.buttonTextKey] : null) || + // a key like "month" + (spec.singleUnit ? buttonText[spec.singleUnit] : null); + } + + // highest to lowest priority + spec.buttonTextOverride = + queryButtonText(this.dynamicOverrides) || + queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence + spec.overrides.buttonText; // `buttonText` for view-specific options is a string + + // highest to lowest priority. mirrors buildViewSpecOptions + spec.buttonTextDefault = + queryButtonText(this.localeDefaults) || + queryButtonText(this.dirDefaults) || + spec.defaults.buttonText || // a single string. from ViewSubclass.defaults + queryButtonText(Calendar.defaults) || + (spec.duration ? this.humanizeDuration(spec.duration) : null) || // like "3 days" + requestedViewType; // fall back to given view name + } + + }); + + ;; + + Calendar.mixin({ + + el: null, + contentEl: null, + suggestedViewHeight: null, + windowResizeProxy: null, + ignoreWindowResize: 0, + + + render: function() { + if (!this.contentEl) { + this.initialRender(); + } + else if (this.elementVisible()) { + // mainly for the public API + this.calcSize(); + this.renderView(); + } + }, + + + initialRender: function() { + var _this = this; + var el = this.el; + + el.addClass('fc'); + + // event delegation for nav links + el.on('click.fc', 'a[data-goto]', function(ev) { + var anchorEl = $(this); + var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON + var date = _this.moment(gotoOptions.date); + var viewType = gotoOptions.type; + + // property like "navLinkDayClick". might be a string or a function + var customAction = _this.view.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click'); + + if (typeof customAction === 'function') { + customAction(date, ev); + } + else { + if (typeof customAction === 'string') { + viewType = customAction; + } + _this.zoomTo(date, viewType); + } + }); + + // called immediately, and upon option change + this.optionsModel.watch('applyingThemeClasses', [ '?theme' ], function(opts) { + el.toggleClass('ui-widget', opts.theme); + el.toggleClass('fc-unthemed', !opts.theme); + }); + + // called immediately, and upon option change. + // HACK: locale often affects isRTL, so we explicitly listen to that too. + this.optionsModel.watch('applyingDirClasses', [ '?isRTL', '?locale' ], function(opts) { + el.toggleClass('fc-ltr', !opts.isRTL); + el.toggleClass('fc-rtl', opts.isRTL); + }); + + this.contentEl = $("
").prependTo(el); + + this.initToolbars(); + this.renderHeader(); + this.renderFooter(); + this.renderView(this.opt('defaultView')); + + if (this.opt('handleWindowResize')) { + $(window).resize( + this.windowResizeProxy = debounce( // prevents rapid calls + this.windowResize.bind(this), + this.opt('windowResizeDelay') + ) + ); + } + }, + + + destroy: function() { + + if (this.view) { + this.view.removeElement(); + + // NOTE: don't null-out this.view in case API methods are called after destroy. + // It is still the "current" view, just not rendered. + } + + this.toolbarsManager.proxyCall('removeElement'); + this.contentEl.remove(); + this.el.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget'); + + this.el.off('.fc'); // unbind nav link handlers + + if (this.windowResizeProxy) { + $(window).unbind('resize', this.windowResizeProxy); + this.windowResizeProxy = null; + } + + GlobalEmitter.unneeded(); + }, + + + elementVisible: function() { + return this.el.is(':visible'); + }, + + + + // View Rendering + // ----------------------------------------------------------------------------------- + + + // Renders a view because of a date change, view-type change, or for the first time. + // If not given a viewType, keep the current view but render different dates. + // Accepts an optional scroll state to restore to. + renderView: function(viewType, forcedScroll) { + + this.ignoreWindowResize++; + + var needsClearView = this.view && viewType && this.view.type !== viewType; + + // if viewType is changing, remove the old view's rendering + if (needsClearView) { + this.freezeContentHeight(); // prevent a scroll jump when view element is removed + this.clearView(); + } + + // if viewType changed, or the view was never created, create a fresh view + if (!this.view && viewType) { + this.view = + this.viewsByType[viewType] || + (this.viewsByType[viewType] = this.instantiateView(viewType)); + + this.view.setElement( + $("
").appendTo(this.contentEl) + ); + this.toolbarsManager.proxyCall('activateButton', viewType); + } + + if (this.view) { + + if (forcedScroll) { + this.view.addForcedScroll(forcedScroll); + } + + if (this.elementVisible()) { + this.currentDate = this.view.setDate(this.currentDate); + } + } + + if (needsClearView) { + this.thawContentHeight(); + } + + this.ignoreWindowResize--; + }, + + + // Unrenders the current view and reflects this change in the Header. + // Unregsiters the `view`, but does not remove from viewByType hash. + clearView: function() { + this.toolbarsManager.proxyCall('deactivateButton', this.view.type); + this.view.removeElement(); + this.view = null; + }, + + + // Destroys the view, including the view object. Then, re-instantiates it and renders it. + // Maintains the same scroll state. + // TODO: maintain any other user-manipulated state. + reinitView: function() { + this.ignoreWindowResize++; + this.freezeContentHeight(); + + var viewType = this.view.type; + var scrollState = this.view.queryScroll(); + this.clearView(); + this.calcSize(); + this.renderView(viewType, scrollState); + + this.thawContentHeight(); + this.ignoreWindowResize--; + }, + + + // Resizing + // ----------------------------------------------------------------------------------- + + + getSuggestedViewHeight: function() { + if (this.suggestedViewHeight === null) { + this.calcSize(); + } + return this.suggestedViewHeight; + }, + + + isHeightAuto: function() { + return this.opt('contentHeight') === 'auto' || this.opt('height') === 'auto'; + }, + + + updateSize: function(shouldRecalc) { + if (this.elementVisible()) { + + if (shouldRecalc) { + this._calcSize(); + } + + this.ignoreWindowResize++; + this.view.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto() + this.ignoreWindowResize--; + + return true; // signal success + } + }, + + + calcSize: function() { + if (this.elementVisible()) { + this._calcSize(); + } + }, + + + _calcSize: function() { // assumes elementVisible + var contentHeightInput = this.opt('contentHeight'); + var heightInput = this.opt('height'); + + if (typeof contentHeightInput === 'number') { // exists and not 'auto' + this.suggestedViewHeight = contentHeightInput; + } + else if (typeof contentHeightInput === 'function') { // exists and is a function + this.suggestedViewHeight = contentHeightInput(); + } + else if (typeof heightInput === 'number') { // exists and not 'auto' + this.suggestedViewHeight = heightInput - this.queryToolbarsHeight(); + } + else if (typeof heightInput === 'function') { // exists and is a function + this.suggestedViewHeight = heightInput() - this.queryToolbarsHeight(); + } + else if (heightInput === 'parent') { // set to height of parent element + this.suggestedViewHeight = this.el.parent().height() - this.queryToolbarsHeight(); + } + else { + this.suggestedViewHeight = Math.round( + this.contentEl.width() / + Math.max(this.opt('aspectRatio'), .5) + ); + } + }, + + + windowResize: function(ev) { + if ( + !this.ignoreWindowResize && + ev.target === window && // so we don't process jqui "resize" events that have bubbled up + this.view.renderRange // view has already been rendered + ) { + if (this.updateSize(true)) { + this.view.publiclyTrigger('windowResize', this.el[0]); + } + } + }, + + + /* Height "Freezing" + -----------------------------------------------------------------------------*/ + + + freezeContentHeight: function() { + this.contentEl.css({ + width: '100%', + height: this.contentEl.height(), + overflow: 'hidden' + }); + }, + + + thawContentHeight: function() { + this.contentEl.css({ + width: '', + height: '', + overflow: '' + }); + } + + }); + + ;; + + Calendar.mixin({ + + header: null, + footer: null, + toolbarsManager: null, + + + initToolbars: function() { + this.header = new Toolbar(this, this.computeHeaderOptions()); + this.footer = new Toolbar(this, this.computeFooterOptions()); + this.toolbarsManager = new Iterator([ this.header, this.footer ]); + }, + + + computeHeaderOptions: function() { + return { + extraClasses: 'fc-header-toolbar', + layout: this.opt('header') + }; + }, + + + computeFooterOptions: function() { + return { + extraClasses: 'fc-footer-toolbar', + layout: this.opt('footer') + }; + }, + + + // can be called repeatedly and Header will rerender + renderHeader: function() { + var header = this.header; + + header.setToolbarOptions(this.computeHeaderOptions()); + header.render(); + + if (header.el) { + this.el.prepend(header.el); + } + }, + + + // can be called repeatedly and Footer will rerender + renderFooter: function() { + var footer = this.footer; + + footer.setToolbarOptions(this.computeFooterOptions()); + footer.render(); + + if (footer.el) { + this.el.append(footer.el); + } + }, + + + setToolbarsTitle: function(title) { + this.toolbarsManager.proxyCall('updateTitle', title); + }, + + + updateToolbarButtons: function() { + var now = this.getNow(); + var view = this.view; + var todayInfo = view.buildDateProfile(now); + var prevInfo = view.buildPrevDateProfile(this.currentDate); + var nextInfo = view.buildNextDateProfile(this.currentDate); + + this.toolbarsManager.proxyCall( + (todayInfo.isValid && !isDateWithinRange(now, view.currentRange)) ? + 'enableButton' : + 'disableButton', + 'today' + ); + + this.toolbarsManager.proxyCall( + prevInfo.isValid ? + 'enableButton' : + 'disableButton', + 'prev' + ); + + this.toolbarsManager.proxyCall( + nextInfo.isValid ? + 'enableButton' : + 'disableButton', + 'next' + ); + }, + + + queryToolbarsHeight: function() { + return this.toolbarsManager.items.reduce(function(accumulator, toolbar) { + var toolbarHeight = toolbar.el ? toolbar.el.outerHeight(true) : 0; // includes margin + return accumulator + toolbarHeight; + }, 0); + } + + }); + + ;; + + Calendar.defaults = { + + titleRangeSeparator: ' \u2013 ', // en dash + monthYearFormat: 'MMMM YYYY', // required for en. other locales rely on datepicker computable option + + defaultTimedEventDuration: '02:00:00', + defaultAllDayEventDuration: { days: 1 }, + forceEventDuration: false, + nextDayThreshold: '09:00:00', // 9am + + // display + defaultView: 'month', + aspectRatio: 1.35, + header: { + left: 'title', + center: '', + right: 'today prev,next' + }, + weekends: true, + weekNumbers: false, + + weekNumberTitle: 'W', + weekNumberCalculation: 'local', + + //editable: false, + + //nowIndicator: false, + + scrollTime: '06:00:00', + minTime: '00:00:00', + maxTime: '24:00:00', + showNonCurrentDates: true, + + // event ajax + lazyFetching: true, + startParam: 'start', + endParam: 'end', + timezoneParam: 'timezone', + + timezone: false, + + //allDayDefault: undefined, + + // locale + isRTL: false, + buttonText: { + prev: "prev", + next: "next", + prevYear: "prev year", + nextYear: "next year", + year: 'year', // TODO: locale files need to specify this + today: 'today', + month: 'month', + week: 'week', + day: 'day' + }, + + buttonIcons: { + prev: 'left-single-arrow', + next: 'right-single-arrow', + prevYear: 'left-double-arrow', + nextYear: 'right-double-arrow' + }, + + allDayText: 'all-day', + + // jquery-ui theming + theme: false, + themeButtonIcons: { + prev: 'circle-triangle-w', + next: 'circle-triangle-e', + prevYear: 'seek-prev', + nextYear: 'seek-next' + }, + + //eventResizableFromStart: false, + dragOpacity: .75, + dragRevertDuration: 500, + dragScroll: true, + + //selectable: false, + unselectAuto: true, + //selectMinDistance: 0, + + dropAccept: '*', + + eventOrder: 'title', + //eventRenderWait: null, + + eventLimit: false, + eventLimitText: 'more', + eventLimitClick: 'popover', + dayPopoverFormat: 'LL', + + handleWindowResize: true, + windowResizeDelay: 100, // milliseconds before an updateSize happens + + longPressDelay: 1000 + + }; + + + Calendar.englishDefaults = { // used by locale.js + dayPopoverFormat: 'dddd, MMMM D' + }; + + + Calendar.rtlDefaults = { // right-to-left defaults + header: { // TODO: smarter solution (first/center/last ?) + left: 'next,prev today', + center: '', + right: 'title' + }, + buttonIcons: { + prev: 'right-single-arrow', + next: 'left-single-arrow', + prevYear: 'right-double-arrow', + nextYear: 'left-double-arrow' + }, + themeButtonIcons: { + prev: 'circle-triangle-e', + next: 'circle-triangle-w', + nextYear: 'seek-prev', + prevYear: 'seek-next' + } + }; + + ;; + + var localeOptionHash = FC.locales = {}; // initialize and expose + + +// TODO: document the structure and ordering of a FullCalendar locale file + + +// Initialize jQuery UI datepicker translations while using some of the translations +// Will set this as the default locales for datepicker. + FC.datepickerLocale = function(localeCode, dpLocaleCode, dpOptions) { + + // get the FullCalendar internal option hash for this locale. create if necessary + var fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {}); + + // transfer some simple options from datepicker to fc + fcOptions.isRTL = dpOptions.isRTL; + fcOptions.weekNumberTitle = dpOptions.weekHeader; + + // compute some more complex options from datepicker + $.each(dpComputableOptions, function(name, func) { + fcOptions[name] = func(dpOptions); + }); + + // is jQuery UI Datepicker is on the page? + if ($.datepicker) { + + // Register the locale data. + // FullCalendar and MomentJS use locale codes like "pt-br" but Datepicker + // does it like "pt-BR" or if it doesn't have the locale, maybe just "pt". + // Make an alias so the locale can be referenced either way. + $.datepicker.regional[dpLocaleCode] = + $.datepicker.regional[localeCode] = // alias + dpOptions; + + // Alias 'en' to the default locale data. Do this every time. + $.datepicker.regional.en = $.datepicker.regional['']; + + // Set as Datepicker's global defaults. + $.datepicker.setDefaults(dpOptions); + } + }; + + +// Sets FullCalendar-specific translations. Will set the locales as the global default. + FC.locale = function(localeCode, newFcOptions) { + var fcOptions; + var momOptions; + + // get the FullCalendar internal option hash for this locale. create if necessary + fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {}); + + // provided new options for this locales? merge them in + if (newFcOptions) { + fcOptions = localeOptionHash[localeCode] = mergeOptions([ fcOptions, newFcOptions ]); + } + + // compute locale options that weren't defined. + // always do this. newFcOptions can be undefined when initializing from i18n file, + // so no way to tell if this is an initialization or a default-setting. + momOptions = getMomentLocaleData(localeCode); // will fall back to en + $.each(momComputableOptions, function(name, func) { + if (fcOptions[name] == null) { + fcOptions[name] = func(momOptions, fcOptions); + } + }); + + // set it as the default locale for FullCalendar + Calendar.defaults.locale = localeCode; + }; + + +// NOTE: can't guarantee any of these computations will run because not every locale has datepicker +// configs, so make sure there are English fallbacks for these in the defaults file. + var dpComputableOptions = { + + buttonText: function(dpOptions) { + return { + // the translations sometimes wrongly contain HTML entities + prev: stripHtmlEntities(dpOptions.prevText), + next: stripHtmlEntities(dpOptions.nextText), + today: stripHtmlEntities(dpOptions.currentText) + }; + }, + + // Produces format strings like "MMMM YYYY" -> "September 2014" + monthYearFormat: function(dpOptions) { + return dpOptions.showMonthAfterYear ? + 'YYYY[' + dpOptions.yearSuffix + '] MMMM' : + 'MMMM YYYY[' + dpOptions.yearSuffix + ']'; + } + + }; + + var momComputableOptions = { + + // Produces format strings like "ddd M/D" -> "Fri 9/15" + dayOfMonthFormat: function(momOptions, fcOptions) { + var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY" + + // strip the year off the edge, as well as other misc non-whitespace chars + format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, ''); + + if (fcOptions.isRTL) { + format += ' ddd'; // for RTL, add day-of-week to end + } + else { + format = 'ddd ' + format; // for LTR, add day-of-week to beginning + } + return format; + }, + + // Produces format strings like "h:mma" -> "6:00pm" + mediumTimeFormat: function(momOptions) { // can't be called `timeFormat` because collides with option + return momOptions.longDateFormat('LT') + .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand + }, + + // Produces format strings like "h(:mm)a" -> "6pm" / "6:30pm" + smallTimeFormat: function(momOptions) { + return momOptions.longDateFormat('LT') + .replace(':mm', '(:mm)') + .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales + .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand + }, + + // Produces format strings like "h(:mm)t" -> "6p" / "6:30p" + extraSmallTimeFormat: function(momOptions) { + return momOptions.longDateFormat('LT') + .replace(':mm', '(:mm)') + .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales + .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand + }, + + // Produces format strings like "ha" / "H" -> "6pm" / "18" + hourFormat: function(momOptions) { + return momOptions.longDateFormat('LT') + .replace(':mm', '') + .replace(/(\Wmm)$/, '') // like above, but for foreign locales + .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand + }, + + // Produces format strings like "h:mm" -> "6:30" (with no AM/PM) + noMeridiemTimeFormat: function(momOptions) { + return momOptions.longDateFormat('LT') + .replace(/\s*a$/i, ''); // remove trailing AM/PM + } + + }; + + +// options that should be computed off live calendar options (considers override options) +// TODO: best place for this? related to locale? +// TODO: flipping text based on isRTL is a bad idea because the CSS `direction` might want to handle it + var instanceComputableOptions = { + + // Produces format strings for results like "Mo 16" + smallDayDateFormat: function(options) { + return options.isRTL ? + 'D dd' : + 'dd D'; + }, + + // Produces format strings for results like "Wk 5" + weekFormat: function(options) { + return options.isRTL ? + 'w[ ' + options.weekNumberTitle + ']' : + '[' + options.weekNumberTitle + ' ]w'; + }, + + // Produces format strings for results like "Wk5" + smallWeekFormat: function(options) { + return options.isRTL ? + 'w[' + options.weekNumberTitle + ']' : + '[' + options.weekNumberTitle + ']w'; + } + + }; + +// TODO: make these computable properties in optionsModel + function populateInstanceComputableOptions(options) { + $.each(instanceComputableOptions, function(name, func) { + if (options[name] == null) { + options[name] = func(options); + } + }); + } + + +// Returns moment's internal locale data. If doesn't exist, returns English. + function getMomentLocaleData(localeCode) { + return moment.localeData(localeCode) || moment.localeData('en'); + } + + +// Initialize English by forcing computation of moment-derived options. +// Also, sets it as the default. + FC.locale('en', Calendar.englishDefaults); + + ;; + + FC.sourceNormalizers = []; + FC.sourceFetchers = []; + + var ajaxDefaults = { + dataType: 'json', + cache: false + }; + + var eventGUID = 1; + + + function EventManager() { // assumed to be a calendar + var t = this; + + + // exports + t.requestEvents = requestEvents; + t.reportEventChange = reportEventChange; + t.isFetchNeeded = isFetchNeeded; + t.fetchEvents = fetchEvents; + t.fetchEventSources = fetchEventSources; + t.refetchEvents = refetchEvents; + t.refetchEventSources = refetchEventSources; + t.getEventSources = getEventSources; + t.getEventSourceById = getEventSourceById; + t.addEventSource = addEventSource; + t.removeEventSource = removeEventSource; + t.removeEventSources = removeEventSources; + t.updateEvent = updateEvent; + t.updateEvents = updateEvents; + t.renderEvent = renderEvent; + t.renderEvents = renderEvents; + t.removeEvents = removeEvents; + t.clientEvents = clientEvents; + t.mutateEvent = mutateEvent; + t.normalizeEventDates = normalizeEventDates; + t.normalizeEventTimes = normalizeEventTimes; + + + // locals + var stickySource = { events: [] }; + var sources = [ stickySource ]; + var rangeStart, rangeEnd; + var pendingSourceCnt = 0; // outstanding fetch requests, max one per source + var cache = []; // holds events that have already been expanded + var prunedCache; // like cache, but only events that intersect with rangeStart/rangeEnd + + + $.each( + (t.opt('events') ? [ t.opt('events') ] : []).concat(t.opt('eventSources') || []), + function(i, sourceInput) { + var source = buildEventSource(sourceInput); + if (source) { + sources.push(source); + } + } + ); + + + + function requestEvents(start, end) { + if (!t.opt('lazyFetching') || isFetchNeeded(start, end)) { + return fetchEvents(start, end); + } + else { + return Promise.resolve(prunedCache); + } + } + + + function reportEventChange() { + prunedCache = filterEventsWithinRange(cache); + t.trigger('eventsReset', prunedCache); + } + + + function filterEventsWithinRange(events) { + var filteredEvents = []; + var i, event; + + for (i = 0; i < events.length; i++) { + event = events[i]; + + if ( + event.start.clone().stripZone() < rangeEnd && + t.getEventEnd(event).stripZone() > rangeStart + ) { + filteredEvents.push(event); + } + } + + return filteredEvents; + } + + + t.getEventCache = function() { + return cache; + }; + + + + /* Fetching + -----------------------------------------------------------------------------*/ + + + // start and end are assumed to be unzoned + function isFetchNeeded(start, end) { + return !rangeStart || // nothing has been fetched yet? + start < rangeStart || end > rangeEnd; // is part of the new range outside of the old range? + } + + + function fetchEvents(start, end) { + rangeStart = start; + rangeEnd = end; + return refetchEvents(); + } + + + // poorly named. fetches all sources with current `rangeStart` and `rangeEnd`. + function refetchEvents() { + return fetchEventSources(sources, 'reset'); + } + + + // poorly named. fetches a subset of event sources. + function refetchEventSources(matchInputs) { + return fetchEventSources(getEventSourcesByMatchArray(matchInputs)); + } + + + // expects an array of event source objects (the originals, not copies) + // `specialFetchType` is an optimization parameter that affects purging of the event cache. + function fetchEventSources(specificSources, specialFetchType) { + var i, source; + + if (specialFetchType === 'reset') { + cache = []; + } + else if (specialFetchType !== 'add') { + cache = excludeEventsBySources(cache, specificSources); + } + + for (i = 0; i < specificSources.length; i++) { + source = specificSources[i]; + + // already-pending sources have already been accounted for in pendingSourceCnt + if (source._status !== 'pending') { + pendingSourceCnt++; + } + + source._fetchId = (source._fetchId || 0) + 1; + source._status = 'pending'; + } + + for (i = 0; i < specificSources.length; i++) { + source = specificSources[i]; + tryFetchEventSource(source, source._fetchId); + } + + if (pendingSourceCnt) { + return Promise.construct(function(resolve) { + t.one('eventsReceived', resolve); // will send prunedCache + }); + } + else { // executed all synchronously, or no sources at all + return Promise.resolve(prunedCache); + } + } + + + // fetches an event source and processes its result ONLY if it is still the current fetch. + // caller is responsible for incrementing pendingSourceCnt first. + function tryFetchEventSource(source, fetchId) { + _fetchEventSource(source, function(eventInputs) { + var isArraySource = $.isArray(source.events); + var i, eventInput; + var abstractEvent; + + if ( + // is this the source's most recent fetch? + // if not, rely on an upcoming fetch of this source to decrement pendingSourceCnt + fetchId === source._fetchId && + // event source no longer valid? + source._status !== 'rejected' + ) { + source._status = 'resolved'; + + if (eventInputs) { + for (i = 0; i < eventInputs.length; i++) { + eventInput = eventInputs[i]; + + if (isArraySource) { // array sources have already been convert to Event Objects + abstractEvent = eventInput; + } + else { + abstractEvent = buildEventFromInput(eventInput, source); + } + + if (abstractEvent) { // not false (an invalid event) + cache.push.apply( // append + cache, + expandEvent(abstractEvent) // add individual expanded events to the cache + ); + } + } + } + + decrementPendingSourceCnt(); + } + }); + } + + + function rejectEventSource(source) { + var wasPending = source._status === 'pending'; + + source._status = 'rejected'; + + if (wasPending) { + decrementPendingSourceCnt(); + } + } + + + function decrementPendingSourceCnt() { + pendingSourceCnt--; + if (!pendingSourceCnt) { + reportEventChange(cache); // updates prunedCache + t.trigger('eventsReceived', prunedCache); + } + } + + + function _fetchEventSource(source, callback) { + var i; + var fetchers = FC.sourceFetchers; + var res; + + for (i=0; i= eventStart && innerSpan.end <= eventEnd; + }; + + +// Returns a list of events that the given event should be compared against when being considered for a move to +// the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar. + Calendar.prototype.getPeerEvents = function(span, event) { + var cache = this.getEventCache(); + var peerEvents = []; + var i, otherEvent; + + for (i = 0; i < cache.length; i++) { + otherEvent = cache[i]; + if ( + !event || + event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events + ) { + peerEvents.push(otherEvent); + } + } + + return peerEvents; + }; + + +// updates the "backup" properties, which are preserved in order to compute diffs later on. + function backupEventDates(event) { + event._allDay = event.allDay; + event._start = event.start.clone(); + event._end = event.end ? event.end.clone() : null; + } + + + /* Overlapping / Constraining +-----------------------------------------------------------------------------------------*/ + + +// Determines if the given event can be relocated to the given span (unzoned start/end with other misc data) + Calendar.prototype.isEventSpanAllowed = function(span, event) { + var source = event.source || {}; + var eventAllowFunc = this.opt('eventAllow'); + + var constraint = firstDefined( + event.constraint, + source.constraint, + this.opt('eventConstraint') + ); + + var overlap = firstDefined( + event.overlap, + source.overlap, + this.opt('eventOverlap') + ); + + return this.isSpanAllowed(span, constraint, overlap, event) && + (!eventAllowFunc || eventAllowFunc(span, event) !== false); + }; + + +// Determines if an external event can be relocated to the given span (unzoned start/end with other misc data) + Calendar.prototype.isExternalSpanAllowed = function(eventSpan, eventLocation, eventProps) { + var eventInput; + var event; + + // note: very similar logic is in View's reportExternalDrop + if (eventProps) { + eventInput = $.extend({}, eventProps, eventLocation); + event = this.expandEvent( + this.buildEventFromInput(eventInput) + )[0]; + } + + if (event) { + return this.isEventSpanAllowed(eventSpan, event); + } + else { // treat it as a selection + + return this.isSelectionSpanAllowed(eventSpan); + } + }; + + +// Determines the given span (unzoned start/end with other misc data) can be selected. + Calendar.prototype.isSelectionSpanAllowed = function(span) { + var selectAllowFunc = this.opt('selectAllow'); + + return this.isSpanAllowed(span, this.opt('selectConstraint'), this.opt('selectOverlap')) && + (!selectAllowFunc || selectAllowFunc(span) !== false); + }; + + +// Returns true if the given span (caused by an event drop/resize or a selection) is allowed to exist +// according to the constraint/overlap settings. +// `event` is not required if checking a selection. + Calendar.prototype.isSpanAllowed = function(span, constraint, overlap, event) { + var constraintEvents; + var anyContainment; + var peerEvents; + var i, peerEvent; + var peerOverlap; + + // the range must be fully contained by at least one of produced constraint events + if (constraint != null) { + + // not treated as an event! intermediate data structure + // TODO: use ranges in the future + constraintEvents = this.constraintToEvents(constraint); + if (constraintEvents) { // not invalid + + anyContainment = false; + for (i = 0; i < constraintEvents.length; i++) { + if (this.spanContainsSpan(constraintEvents[i], span)) { + anyContainment = true; + break; + } + } + + if (!anyContainment) { + return false; + } + } + } + + peerEvents = this.getPeerEvents(span, event); + + for (i = 0; i < peerEvents.length; i++) { + peerEvent = peerEvents[i]; + + // there needs to be an actual intersection before disallowing anything + if (this.eventIntersectsRange(peerEvent, span)) { + + // evaluate overlap for the given range and short-circuit if necessary + if (overlap === false) { + return false; + } + // if the event's overlap is a test function, pass the peer event in question as the first param + else if (typeof overlap === 'function' && !overlap(peerEvent, event)) { + return false; + } + + // if we are computing if the given range is allowable for an event, consider the other event's + // EventObject-specific or Source-specific `overlap` property + if (event) { + peerOverlap = firstDefined( + peerEvent.overlap, + (peerEvent.source || {}).overlap + // we already considered the global `eventOverlap` + ); + if (peerOverlap === false) { + return false; + } + // if the peer event's overlap is a test function, pass the subject event as the first param + if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) { + return false; + } + } + } + } + + return true; + }; + + +// Given an event input from the API, produces an array of event objects. Possible event inputs: +// 'businessHours' +// An event ID (number or string) +// An object with specific start/end dates or a recurring event (like what businessHours accepts) + Calendar.prototype.constraintToEvents = function(constraintInput) { + + if (constraintInput === 'businessHours') { + return this.getCurrentBusinessHourEvents(); + } + + if (typeof constraintInput === 'object') { + if (constraintInput.start != null) { // needs to be event-like input + return this.expandEvent(this.buildEventFromInput(constraintInput)); + } + else { + return null; // invalid + } + } + + return this.clientEvents(constraintInput); // probably an ID + }; + + +// Does the event's date range intersect with the given range? +// start/end already assumed to have stripped zones :( + Calendar.prototype.eventIntersectsRange = function(event, range) { + var eventStart = event.start.clone().stripZone(); + var eventEnd = this.getEventEnd(event).stripZone(); + + return range.start < eventEnd && range.end > eventStart; + }; + + + /* Business Hours +-----------------------------------------------------------------------------------------*/ + + var BUSINESS_HOUR_EVENT_DEFAULTS = { + id: '_fcBusinessHours', // will relate events from different calls to expandEvent + start: '09:00', + end: '17:00', + dow: [ 1, 2, 3, 4, 5 ], // monday - friday + rendering: 'inverse-background' + // classNames are defined in businessHoursSegClasses + }; + +// Return events objects for business hours within the current view. +// Abuse of our event system :( + Calendar.prototype.getCurrentBusinessHourEvents = function(wholeDay) { + return this.computeBusinessHourEvents(wholeDay, this.opt('businessHours')); + }; + +// Given a raw input value from options, return events objects for business hours within the current view. + Calendar.prototype.computeBusinessHourEvents = function(wholeDay, input) { + if (input === true) { + return this.expandBusinessHourEvents(wholeDay, [ {} ]); + } + else if ($.isPlainObject(input)) { + return this.expandBusinessHourEvents(wholeDay, [ input ]); + } + else if ($.isArray(input)) { + return this.expandBusinessHourEvents(wholeDay, input, true); + } + else { + return []; + } + }; + +// inputs expected to be an array of objects. +// if ignoreNoDow is true, will ignore entries that don't specify a day-of-week (dow) key. + Calendar.prototype.expandBusinessHourEvents = function(wholeDay, inputs, ignoreNoDow) { + var view = this.getView(); + var events = []; + var i, input; + + for (i = 0; i < inputs.length; i++) { + input = inputs[i]; + + if (ignoreNoDow && !input.dow) { + continue; + } + + // give defaults. will make a copy + input = $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, input); + + // if a whole-day series is requested, clear the start/end times + if (wholeDay) { + input.start = null; + input.end = null; + } + + events.push.apply(events, // append + this.expandEvent( + this.buildEventFromInput(input), + view.activeRange.start, + view.activeRange.end + ) + ); + } + + return events; + }; + + ;; + + /* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells. +----------------------------------------------------------------------------------------------------------------------*/ +// It is a manager for a DayGrid subcomponent, which does most of the heavy lifting. +// It is responsible for managing width/height. + + var BasicView = FC.BasicView = View.extend({ + + scroller: null, + + dayGridClass: DayGrid, // class the dayGrid will be instantiated from (overridable by subclasses) + dayGrid: null, // the main subcomponent that does most of the heavy lifting + + dayNumbersVisible: false, // display day numbers on each day cell? + colWeekNumbersVisible: false, // display week numbers along the side? + cellWeekNumbersVisible: false, // display week numbers in day cell? + + weekNumberWidth: null, // width of all the week-number cells running down the side + + headContainerEl: null, // div that hold's the dayGrid's rendered date header + headRowEl: null, // the fake row element of the day-of-week header + + + initialize: function() { + this.dayGrid = this.instantiateDayGrid(); + + this.scroller = new Scroller({ + overflowX: 'hidden', + overflowY: 'auto' + }); + }, + + + // Generates the DayGrid object this view needs. Draws from this.dayGridClass + instantiateDayGrid: function() { + // generate a subclass on the fly with BasicView-specific behavior + // TODO: cache this subclass + var subclass = this.dayGridClass.extend(basicDayGridMethods); + + return new subclass(this); + }, + + + // Computes the date range that will be rendered. + buildRenderRange: function(currentRange, currentRangeUnit) { + var renderRange = View.prototype.buildRenderRange.apply(this, arguments); + + // year and month views should be aligned with weeks. this is already done for week + if (/^(year|month)$/.test(currentRangeUnit)) { + renderRange.start.startOf('week'); + + // make end-of-week if not already + if (renderRange.end.weekday()) { + renderRange.end.add(1, 'week').startOf('week'); // exclusively move backwards + } + } + + return this.trimHiddenDays(renderRange); + }, + + + // Renders the view into `this.el`, which should already be assigned + renderDates: function() { + + this.dayGrid.breakOnWeeks = /year|month|week/.test(this.currentRangeUnit); // do before Grid::setRange + this.dayGrid.setRange(this.renderRange); + + this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible + if (this.opt('weekNumbers')) { + if (this.opt('weekNumbersWithinDays')) { + this.cellWeekNumbersVisible = true; + this.colWeekNumbersVisible = false; + } + else { + this.cellWeekNumbersVisible = false; + this.colWeekNumbersVisible = true; + }; + } + this.dayGrid.numbersVisible = this.dayNumbersVisible || + this.cellWeekNumbersVisible || this.colWeekNumbersVisible; + + this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml()); + this.renderHead(); + + this.scroller.render(); + var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container'); + var dayGridEl = $('
').appendTo(dayGridContainerEl); + this.el.find('.fc-body > tr > td').append(dayGridContainerEl); + + this.dayGrid.setElement(dayGridEl); + this.dayGrid.renderDates(this.hasRigidRows()); + }, + + + // render the day-of-week headers + renderHead: function() { + this.headContainerEl = + this.el.find('.fc-head-container') + .html(this.dayGrid.renderHeadHtml()); + this.headRowEl = this.headContainerEl.find('.fc-row'); + }, + + + // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, + // always completely kill the dayGrid's rendering. + unrenderDates: function() { + this.dayGrid.unrenderDates(); + this.dayGrid.removeElement(); + this.scroller.destroy(); + }, + + + renderBusinessHours: function() { + this.dayGrid.renderBusinessHours(); + }, + + + unrenderBusinessHours: function() { + this.dayGrid.unrenderBusinessHours(); + }, + + + // Builds the HTML skeleton for the view. + // The day-grid component will render inside of a container defined by this HTML. + renderSkeletonHtml: function() { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
'; + }, + + + // Generates an HTML attribute string for setting the width of the week number column, if it is known + weekNumberStyleAttr: function() { + if (this.weekNumberWidth !== null) { + return 'style="width:' + this.weekNumberWidth + 'px"'; + } + return ''; + }, + + + // Determines whether each row should have a constant height + hasRigidRows: function() { + var eventLimit = this.opt('eventLimit'); + return eventLimit && typeof eventLimit !== 'number'; + }, + + + /* Dimensions + ------------------------------------------------------------------------------------------------------------------*/ + + + // Refreshes the horizontal dimensions of the view + updateWidth: function() { + if (this.colWeekNumbersVisible) { + // Make sure all week number cells running down the side have the same width. + // Record the width for cells created later. + this.weekNumberWidth = matchCellWidths( + this.el.find('.fc-week-number') + ); + } + }, + + + // Adjusts the vertical dimensions of the view to the specified values + setHeight: function(totalHeight, isAuto) { + var eventLimit = this.opt('eventLimit'); + var scrollerHeight; + var scrollbarWidths; + + // reset all heights to be natural + this.scroller.clear(); + uncompensateScroll(this.headRowEl); + + this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed + + // is the event limit a constant level number? + if (eventLimit && typeof eventLimit === 'number') { + this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after + } + + // distribute the height to the rows + // (totalHeight is a "recommended" value if isAuto) + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.setGridHeight(scrollerHeight, isAuto); + + // is the event limit dynamically calculated? + if (eventLimit && typeof eventLimit !== 'number') { + this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set + } + + if (!isAuto) { // should we force dimensions of the scroll container? + + this.scroller.setHeight(scrollerHeight); + scrollbarWidths = this.scroller.getScrollbarWidths(); + + if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars? + + compensateScroll(this.headRowEl, scrollbarWidths); + + // doing the scrollbar compensation might have created text overflow which created more height. redo + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.scroller.setHeight(scrollerHeight); + } + + // guarantees the same scrollbar widths + this.scroller.lockOverflow(scrollbarWidths); + } + }, + + + // given a desired total height of the view, returns what the height of the scroller should be + computeScrollerHeight: function(totalHeight) { + return totalHeight - + subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller + }, + + + // Sets the height of just the DayGrid component in this view + setGridHeight: function(height, isAuto) { + if (isAuto) { + undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding + } + else { + distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows + } + }, + + + /* Scroll + ------------------------------------------------------------------------------------------------------------------*/ + + + computeInitialDateScroll: function() { + return { top: 0 }; + }, + + + queryDateScroll: function() { + return { top: this.scroller.getScrollTop() }; + }, + + + applyDateScroll: function(scroll) { + if (scroll.top !== undefined) { + this.scroller.setScrollTop(scroll.top); + } + }, + + + /* Hit Areas + ------------------------------------------------------------------------------------------------------------------*/ + // forward all hit-related method calls to dayGrid + + + hitsNeeded: function() { + this.dayGrid.hitsNeeded(); + }, + + + hitsNotNeeded: function() { + this.dayGrid.hitsNotNeeded(); + }, + + + prepareHits: function() { + this.dayGrid.prepareHits(); + }, + + + releaseHits: function() { + this.dayGrid.releaseHits(); + }, + + + queryHit: function(left, top) { + return this.dayGrid.queryHit(left, top); + }, + + + getHitSpan: function(hit) { + return this.dayGrid.getHitSpan(hit); + }, + + + getHitEl: function(hit) { + return this.dayGrid.getHitEl(hit); + }, + + + /* Events + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders the given events onto the view and populates the segments array + renderEvents: function(events) { + this.dayGrid.renderEvents(events); + + this.updateHeight(); // must compensate for events that overflow the row + }, + + + // Retrieves all segment objects that are rendered in the view + getEventSegs: function() { + return this.dayGrid.getEventSegs(); + }, + + + // Unrenders all event elements and clears internal segment data + unrenderEvents: function() { + this.dayGrid.unrenderEvents(); + + // we DON'T need to call updateHeight() because + // a renderEvents() call always happens after this, which will eventually call updateHeight() + }, + + + /* Dragging (for both events and external elements) + ------------------------------------------------------------------------------------------------------------------*/ + + + // A returned value of `true` signals that a mock "helper" event has been rendered. + renderDrag: function(dropLocation, seg) { + return this.dayGrid.renderDrag(dropLocation, seg); + }, + + + unrenderDrag: function() { + this.dayGrid.unrenderDrag(); + }, + + + /* Selection + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of a selection + renderSelection: function(span) { + this.dayGrid.renderSelection(span); + }, + + + // Unrenders a visual indications of a selection + unrenderSelection: function() { + this.dayGrid.unrenderSelection(); + } + + }); + + +// Methods that will customize the rendering behavior of the BasicView's dayGrid + var basicDayGridMethods = { + + + // Generates the HTML that will go before the day-of week header cells + renderHeadIntroHtml: function() { + var view = this.view; + + if (view.colWeekNumbersVisible) { + return '' + + '' + + '' + // needed for matchCellWidths + htmlEscape(view.opt('weekNumberTitle')) + + '' + + ''; + } + + return ''; + }, + + + // Generates the HTML that will go before content-skeleton cells that display the day/week numbers + renderNumberIntroHtml: function(row) { + var view = this.view; + var weekStart = this.getCellDate(row, 0); + + if (view.colWeekNumbersVisible) { + return '' + + '' + + view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths + { date: weekStart, type: 'week', forceOff: this.colCnt === 1 }, + weekStart.format('w') // inner HTML + ) + + ''; + } + + return ''; + }, + + + // Generates the HTML that goes before the day bg cells for each day-row + renderBgIntroHtml: function() { + var view = this.view; + + if (view.colWeekNumbersVisible) { + return ''; + } + + return ''; + }, + + + // Generates the HTML that goes before every other type of row generated by DayGrid. + // Affects helper-skeleton and highlight-skeleton rows. + renderIntroHtml: function() { + var view = this.view; + + if (view.colWeekNumbersVisible) { + return ''; + } + + return ''; + } + + }; + + ;; + + /* A month view with day cells running in rows (one-per-week) and columns +----------------------------------------------------------------------------------------------------------------------*/ + + var MonthView = FC.MonthView = BasicView.extend({ + + + // Computes the date range that will be rendered. + buildRenderRange: function() { + var renderRange = BasicView.prototype.buildRenderRange.apply(this, arguments); + var rowCnt; + + // ensure 6 weeks + if (this.isFixedWeeks()) { + rowCnt = Math.ceil( // could be partial weeks due to hiddenDays + renderRange.end.diff(renderRange.start, 'weeks', true) // dontRound=true + ); + renderRange.end.add(6 - rowCnt, 'weeks'); + } + + return renderRange; + }, + + + // Overrides the default BasicView behavior to have special multi-week auto-height logic + setGridHeight: function(height, isAuto) { + + // if auto, make the height of each row the height that it would be if there were 6 weeks + if (isAuto) { + height *= this.rowCnt / 6; + } + + distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows + }, + + + isFixedWeeks: function() { + return this.opt('fixedWeekCount'); + } + + }); + + ;; + var YearDayGrid = DayGrid.extend({ + + buildDaySelectListener: function() { + return this.view.dragListener; + }, + + buildSegResizeListener: function(seg, isStart) { + return this.view.buildSegResizeListener(seg, isStart); + }, + + getBoundBox: function() { + var top = this.rowCoordCache.getTopOffset(0); + var bottom = this.rowCoordCache.getBottomOffset(this.rowCnt-1); + var left = this.colCoordCache.getLeftOffset(0); + var right = this.colCoordCache.getRightOffset(this.colCnt-1); + + return { top: top, bottom: bottom, left: left, right: right }; + } + }); + + var YearView = FC.YearView = View.extend({ + + segResizeListener: null, + + dayNumbersVisible: true, // display day numbers on each day cell? + weekNumbersVisible: false, // display week numbers along the side? + + firstDay: null, + firstMonth: null, + lastMonth: null, + yearColumns: 3, + nbMonths: null, + hiddenMonths: [], + + colCnt: null, + rowCnt: null, + + dragListener: null, + + startGridId: -1, + activeGridId: -1, + + tm: null, + + dayGrids: [], // the main sub components that does most of the heavy lifting + otherMonthDays: [], + + // called once when the view is instantiated, when the user switches to the view. + // initialize member variables or do other setup tasks. + initialize: function() { + this.updateOptions(); + + var _this = this; + var view = this; + var selectionSpan; // null if invalid selection + + this.dragListener = new HitDragListener(this, { + interactionStart: function(ev) { + view.interactionStart(ev); + }, + hitOver: function(hit, isOrig, origHit) { + var origHitSpan; + var hitSpan; + + if (origHit) { // click needs to have started on a hit + + origHitSpan = _this.getSafeOrigHitSpan(origHit); + hitSpan = _this.getSafeHitSpan(hit); + + if (origHitSpan && hitSpan) { + selectionSpan = _this.computeSelection(origHitSpan, hitSpan); + } + else { + selectionSpan = null; + } + + if (selectionSpan) { + _this.renderSelection(selectionSpan); + } + else if (selectionSpan === false) { + disableCursor(); + } + } + }, + hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits + //_this.unrenderSelection(); + }, + hitDone: function() { // called after a hitOut OR before a dragEnd + enableCursor(); + }, + drag: function(dx, dy, ev) { + view.drag(dx, dy, ev); + }, + interactionEnd: function(ev, isCancelled) { + if (!isCancelled) { + // the selection will already have been rendered. just report it + view.reportSelection(selectionSpan, ev); + } + } + }); + }, + + updateOptions: function() { + this.rtl = this.opt('isRTL'); + if (this.rtl) { + this.dis = -1; + this.dit = this.colCnt - 1; + } else { + this.dis = 1; + this.dit = 0; + } + + this.currentRangeUnit = 'year'; + + this.firstDay = parseInt(this.opt('firstDay'), 10) || 1; + this.firstMonth = parseInt(this.opt('firstMonth'), 10) || 0; + + this.lastMonth = this.opt('lastMonth') || this.firstMonth+12; + this.hiddenMonths = this.opt('hiddenMonths') || []; + this.yearColumns = parseInt(this.opt('yearColumns'), 10) || this.yearColumns; //ex: '2x6', '3x4', '4x3' + this.weekNumbersVisible = this.opt('weekNumbers'); + this.tm = this.opt('theme') ? 'ui' : 'fc'; + this.nbMonths = this.lastMonth - this.firstMonth; + this.lastMonth = this.lastMonth % 12; + this.isBootstrap = this.opt('bootstrap'); + }, + + handleSegMouseout:function(seg, ev) { + if (this.activeGridId != -1) { + return this.dayGrids[this.activeGridId].handleSegMouseout(seg, ev); + } + }, + + segResizeStart: function(seg, ev) { + if (this.activeGridId != -1) { + return this.dayGrids[this.activeGridId].segResizeStart(seg, ev); + } + }, + + segResizeStop: function(seg, ev) { + for (var i = 0; i < this.dayGrids.length; i++) { + this.dayGrids[i].segResizeStop(seg, ev); + } + }, + + isEventLocationAllowed: function(resizeLocation, ev) { + if (this.activeGridId != -1) { + return this.dayGrids[this.activeGridId].isEventLocationAllowed(resizeLocation, ev); + } + }, + + buildSegResizeListener: function(seg, isStart) { + var _this = this; + var view = this; + var calendar = view.calendar; + var el = seg.el; + var event = seg.event; + var eventEnd = calendar.getEventEnd(event); + var isDragging; + var resizeLocation; // zoned event date properties. falsy if invalid resize + + // Tracks mouse movement over the *grid's* coordinate map + this.segResizeListener = new HitDragListener(view, { + scroll: view.opt('dragScroll'), + subjectEl: el, + interactionStart: function(ev) { + view.interactionStart(ev); + isDragging = false; + }, + dragStart: function(ev) { + isDragging = true; + _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported + _this.segResizeStart(seg, ev); + }, + drag: function(dx, dy, ev) { + view.drag(dx, dy, ev); + }, + hitOver: function(hit, isOrig, origHit) { + var isAllowed = true; + var origHitSpan = _this.getSafeStartHitSpan(origHit); + var hitSpan = _this.getSafeHitSpan(hit); + + if (origHitSpan && hitSpan) { + resizeLocation = isStart ? + _this.computeEventStartResize(origHitSpan, hitSpan, event) : + _this.computeEventEndResize(origHitSpan, hitSpan, event); + + isAllowed = resizeLocation && _this.isEventLocationAllowed(resizeLocation, event); + } + else { + isAllowed = false; + } + + if (!isAllowed) { + resizeLocation = null; + disableCursor(); + } + else { + if ( + resizeLocation.start.isSame(event.start.clone().stripZone()) && + resizeLocation.end.isSame(eventEnd.clone().stripZone()) + ) { + // no change. (FYI, event dates might have zones) + resizeLocation = null; + } + } + + if (resizeLocation) { + view.hideEvent(event); + _this.renderEventResize(resizeLocation, seg); + } + }, + hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits + //resizeLocation = null; + view.showEvent(event); // for when out-of-bounds. show original + }, + hitDone: function() { // resets the rendering to show the original event + _this.unrenderEventResize(); + enableCursor(); + }, + interactionEnd: function(ev) { + if (isDragging) { + _this.segResizeStop(seg, ev); + } + + if (resizeLocation) { // valid date to resize to? + // no need to re-show original, will rerender all anyways. esp important if eventRenderWait + view.reportSegResize(seg, resizeLocation, _this.largeUnit, el, ev); + } + else { + view.showEvent(event); + } + _this.segResizeListener = null; + } + }); + + return this.segResizeListener; + }, + + /* Dragging (for both events and external elements) + ------------------------------------------------------------------------------------------------------------------*/ + + // A returned value of `true` signals that a mock "helper" event has been rendered. + renderDrag: function(dropLocation, seg) { + for (var i = 0; i < this.dayGrids.length; i++) { + var dayGrid = this.dayGrids[i]; + dayGrid.renderSelection(dropLocation, seg); + } + }, + + unrenderDrag: function() { + for (var i = 0; i < this.dayGrids.length; i++) { + var dayGrid = this.dayGrids[i]; + dayGrid.unrenderSelection(); + } + }, + + renderEventResize: function(range, seg) { + for (var i = 0; i < this.dayGrids.length; i++) { + var dayGrid = this.dayGrids[i]; + dayGrid.unrenderEventResize(); + dayGrid.renderEventResize(range, seg); + } + }, + + unrenderEventResize: function() { + for (var i = 0; i < this.dayGrids.length; i++) { + var dayGrid = this.dayGrids[i]; + dayGrid.unrenderEventResize(); + } + }, + + computeEventEndResize: function(span0, span1, event) { + var activeId = this.activeGridId; + var activeRes = this.dayGrids[activeId].computeEventEndResize(span0, span1, event); + return activeRes; + }, + + computeEventStartResize: function(span0, span1, event) { + var activeId = this.activeGridId; + var activeRes = this.dayGrids[activeId].computeEventStartResize(span0, span1, event); + return activeRes; + }, + + daysInMonth: function(year, month) { + return FC.moment([ year, month, 0 ]).date(); + }, + + interactionStart: function(ev) { + var x = getEvX(ev); + var y = getEvY(ev); + + var gridId = this.getGridId(x, y); + this.startGridId = gridId; + this.activeGridId = gridId; + }, + + getGridId: function(x, y) { + for (var i=0; i boundBox.left && x < boundBox.right && y > boundBox.top && y < boundBox.bottom) { + return i; + } + } + + return -1; + }, + + drag: function(dx, dy, ev) { + var x = getEvX(ev); + var y = getEvY(ev); + var i; + + var gridId = this.getGridId(x, y); + + var activeId = this.activeGridId; + + if (gridId != -1 && gridId != activeId) { + if (gridId > activeId) { + for (i = activeId; i <= gridId; i++) { + this.dayGrids[i].unrenderSelection(); + } + } + else { + for (i = gridId; i <= activeId; i++) { + this.dayGrids[i].unrenderSelection(); + } + } + } + }, + + tableByOffset: function(offset) { + return $(this.subTables[offset]); + }, + + isFixedWeeks: function() { + return this.opt('fixedWeekCount'); + }, + + selectEvent: function(event) { + }, + + // Determines whether each row should have a constant height + hasRigidRows: function() { + var eventLimit = this.opt('eventLimit'); + return eventLimit && typeof eventLimit !== 'number'; + }, + + dateInMonth: function(date, mi) { + return (date.month() == (mi%12)); + }, + + // Set css extra classes like fc-other-month and fill otherMonthDays + updateCells: function() { + var _this = this; + + this.subTables.find('.fc-week:first').addClass('fc-first'); + this.subTables.find('.fc-week:last').addClass('fc-last'); + this.subTables.find('.fc-bg').find('td .fc-day:last').addClass('fc-last'); + + this.subTables.each(function(i, _sub) { + if (!_this.curYear) { _this.curYear = _this.renderRange.start; } + + var d = _this.curYear.clone(); + var mi = (i + _this.renderRange.start.month()) % 12; + + d = _this.dayGrids[i].start; + + var lastDateShown = 0; + + $(_sub).find('.fc-bg').find('td .fc-day:first').addClass('fc-first'); + + _this.otherMonthDays[mi] = [ 0, 0, 0, 0 ]; + $(_sub).find('.fc-content-skeleton tr').each(function(r, _tr) { + + if (r === 0 && _this.dateInMonth(d, mi)) { + // in current month, but hidden (weekends) at start + _this.otherMonthDays[mi][2] = d.date() - 1; + } + $(_tr).find('td').not('.fc-week-number').each(function(ii, _cell) { + + var cell = $(_cell); + + d = _this.dayGrids[i].dayDates[ii + r*_this.colCnt]; + + if (!_this.dateInMonth(d, mi)) { + cell.addClass('fc-other-month'); + if (d.month() == (mi+11)%12) { + // prev month + _this.otherMonthDays[mi][0]++; + cell.addClass('fc-prev-month'); + } else { + // next month + _this.otherMonthDays[mi][1]++; + cell.addClass('fc-next-month'); + } + } else { + cell.removeClass('fc-other-month'); + lastDateShown = d; + } + }); + }); + + var endDaysHidden = _this.daysInMonth(_this.curYear.year(), mi+1) - lastDateShown; + // in current month, but hidden (weekends) at end + _this.otherMonthDays[mi][3] = endDaysHidden; + }); + }, + + setHeight: function(totalHeight, isAuto) { + var eventLimit = this.opt('eventLimit'); + var scrollerHeight = totalHeight; + var i; + + for (i = 0; i < this.dayGrids.length; i++) { + var dayGrid = this.dayGrids[i]; + + dayGrid.removeSegPopover(); // kill the "more" popover if displayed + + // is the event limit a constant level number? + if (eventLimit && typeof eventLimit === 'number') { + dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after + } + this.setGridHeight(dayGrid, scrollerHeight, isAuto); + // is the event limit dynamically calculated? + if (eventLimit && typeof eventLimit !== 'number') { + dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set + } + } + }, + + // Sets the height of just the DayGrid component in this view + setGridHeight: function(grid, height, isAuto) { + if (isAuto) { + undistributeHeight(grid.rowEls); // let the rows be their natural height with no expanding + } + else { + distributeHeight(grid.rowEls, height, true); // true = compensate for height-hogging rows + } + }, + + /* Hit Areas + ------------------------------------------------------------------------------------------------------------------*/ + // forward all hit-related method calls to dayGrid + hitsNeeded: function() { + var i; + for (i = 0; i < this.dayGrids.length; i++) { + this.dayGrids[i].hitsNeeded(); + } + }, + hitsNotNeeded: function() { + var i; + for (i = 0; i < this.dayGrids.length; i++) { + this.dayGrids[i].hitsNotNeeded(); + } + }, + prepareHits: function() { + var i; + for (i = 0; i < this.dayGrids.length; i++) { + this.dayGrids[i].prepareHits(); + } + }, + releaseHits: function() { + for (var i = 0; i < this.dayGrids.length; i++) { + this.dayGrids[i].releaseHits(); + } + }, + queryHit: function(left, top) { + var gridId = this.getGridId(left, top); + + if (gridId != -1) { + this.activeGridId = gridId; + return this.dayGrids[gridId].queryHit(left, top); + } + }, + getHitSpan: function(hit) { + var hits = [], i; + for (i = 0; i < this.dayGrids.length; i++) { + var res = this.dayGrids[i].getHitSpan(hit); + if (res) { + hits = hits.concat(res); + } + } + return hits; + }, + getHitEl: function(hit) { + var hits = [], i; + for (i = 0; i < this.dayGrids.length; i++) { + var res = this.dayGrids[i].getHitEl(hit); + if (res) { + hits = hits.concat(res); + } + } + return hits; + }, + //for resize event + getSafeStartHitSpan: function(hit) { + return this.dayGrids[this.startGridId].getSafeHitSpan(hit); + }, + //for select + getSafeOrigHitSpan: function(hit) { + var startId = this.startGridId; + var activeId = this.activeGridId; + + if (startId != -1) { + if (startId == activeId) { + //same + return this.dayGrids[startId].getSafeHitSpan(hit); + } + else { + if (activeId != -1) { + var dayGrid = this.dayGrids[activeId]; + if (activeId > startId) { + return dayGrid.getSafeHitSpan({ row:0, col: 0 }); + } + else if (activeId < startId) { + return dayGrid.getSafeHitSpan({ row: dayGrid.rowCnt-1, col: dayGrid.colCnt-1 }); + } + return dayGrid.getSafeHitSpan(hit); + } + //draging outside of grids + } + } + }, + getSafeHitSpan: function(hit) { + var startId = this.startGridId; + var activeId = this.activeGridId; + + if (startId != -1) { + if (startId == activeId) { + //same + return this.dayGrids[startId].getSafeHitSpan(hit); + } + else { + if (activeId != -1) { + return this.dayGrids[activeId].getSafeHitSpan(hit); + } + //draging outside of grids + } + } + }, + + computeSelection: function(span0, span1) { + var startId = this.startGridId; + var activeId = this.activeGridId; + var first, last, s0, s1, dayGrid; + + if (startId == activeId) { + return this.dayGrids[startId].computeSelection(span0, span1); + } + else { + if (startId > activeId) { + first = this.dayGrids[activeId].computeSelection(span0, span1); + + dayGrid = this.dayGrids[startId]; + s0 = dayGrid.getSafeHitSpan(this.dragListener.origHit); + s1 = dayGrid.getSafeHitSpan({ row: 0, col: 0 }); + last = dayGrid.computeSelection(s0, s1); + + first.end = last.end; + return first; + } + else { + //startId < activeId + first = this.dayGrids[activeId].computeSelection(span0, span1); + + dayGrid = this.dayGrids[startId]; + s0 = dayGrid.getSafeHitSpan({ row: dayGrid.rowCnt-1, col: dayGrid.colCnt-1 }); + s1 = dayGrid.getSafeHitSpan(this.dragListener.origHit); + last = dayGrid.computeSelection(s0, s1); + first.start = last.start; + return first; + } + } + }, + + + /* Events + ------------------------------------------------------------------------------------------------------------------*/ + // Retrieves all segment objects that are rendered in the view + getEventSegs: function() { + var eventSeg = [], i; + for (i = 0; i < this.dayGrids.length; i++) { + var res = this.dayGrids[i].getEventSegs(); + if (res) { + eventSeg = eventSeg.concat(res); + } + } + return eventSeg; + }, + + // Renders the given events onto the view and populates the segments array + renderEvents: function(events) { + var i; + for (i = 0; i < this.dayGrids.length; i++) { + this.dayGrids[i].renderEvents(events); + } + + this.updateHeight(); // must compensate for events that overflow the row + }, + + // Unrenders all event elements and clears internal segment data + unrenderEvents: function() { + var i; + for (i = 0; i < this.dayGrids.length; i++) { + this.dayGrids[i].unrenderEvents(); + } + + // we DON'T need to call updateHeight() because + // a renderEvents() call always happens after this, which will eventually call updateHeight() + }, + + /* Selection + ------------------------------------------------------------------------------------------------------------------*/ + // Renders a visual indication of a selection + renderSelection: function(span) { + var startId = this.startGridId; + var activeId = this.activeGridId; + var i, dayGrid, span0, span1, hit, selectionSpan; + + if (startId != -1) { + if (startId == activeId) { + if (activeId != -1) { + this.dayGrids[startId].unrenderSelection(); + this.dayGrids[startId].renderSelection(span); + } + } + else { + if (activeId != -1) { + if (startId < activeId) { + for (i = startId; i < activeId; i++) { + dayGrid = this.dayGrids[i]; + + if (i != startId) { + span0 = dayGrid.getSafeHitSpan({ row: 0, col: 0 }); + span1 = dayGrid.getSafeHitSpan({ row: dayGrid.rowCnt-1, col: dayGrid.colCnt-1 }); + } + else{ + span0 = dayGrid.getSafeHitSpan(this.dragListener.origHit); + span1 = dayGrid.getSafeHitSpan({ row: dayGrid.rowCnt-1, col: dayGrid.colCnt-1 }); + } + selectionSpan = dayGrid.computeSelection(span0, span1); + + dayGrid.unrenderSelection(); + dayGrid.renderSelection(selectionSpan); + } + } + else { + for (i = startId; i > activeId; i--) { + dayGrid = this.dayGrids[i]; + hit = { row: 0, col: 0 }; + + if (i != startId) { + span0 = dayGrid.getSafeHitSpan({ row: dayGrid.rowCnt-1, col: dayGrid.colCnt-1 }); + span1 = dayGrid.getSafeHitSpan({ row: 0, col: 0 }); + } + else { + span0 = dayGrid.getSafeHitSpan(this.dragListener.origHit); + span1 = dayGrid.getSafeHitSpan({ row: 0, col: 0 }); + } + + selectionSpan = dayGrid.computeSelection(span0, span1); + dayGrid.unrenderSelection(); + dayGrid.renderSelection(selectionSpan); + } + } + this.dayGrids[activeId].unrenderSelection(); + this.dayGrids[activeId].renderSelection(span); + } + } + } + }, + + unrenderSelection: function() { + var startId = this.startGridId; + var activeId = this.activeGridId; + var i; + + if (activeId > startId) { + for (i = startId; i <= activeId; i++) { + this.dayGrids[i].unrenderSelection(); + } + } + else { + for (i = activeId; i <= startId; i++) { + this.dayGrids[i].unrenderSelection(); + } + } + }, + + reportSelection: function(span, ev) { + View.prototype.reportSelection.call(this, span, ev); + this.unrenderSelection(); + }, + + buildRenderRange: function() { + var renderRange = View.prototype.buildRenderRange.apply(this, arguments); + + renderRange.start.startOf('year'); + renderRange.end.endOf('year'); + + return this.trimHiddenDays(renderRange); + }, + + // responsible for displaying the skeleton of the view within the already-defined + // this.el, a jQuery element. + render: function() { + var startMonth = Math.floor(this.renderRange.start.month() / this.nbMonths) * this.nbMonths; + + if (!startMonth && this.firstMonth > 0 && !this.opt('lastMonth')) { + // school + startMonth = (this.firstMonth + startMonth) % 12; + } + + this.intervalStart = FC.moment([this.intervalStart.year(), startMonth, 1]); + this.intervalEnd = this.intervalStart.clone().add(this.nbMonths, 'months').add(-15, 'minutes'); + + this.start = this.intervalStart.clone(); + this.start = this.skipHiddenDays(this.start); // move past the first week if no visible days + this.start.startOf('week'); + this.start = this.skipHiddenDays(this.start); // move past the first invisible days of the week + + this.end = this.intervalEnd.clone(); + this.end = this.skipHiddenDays(this.end, -1, true); // move in from the last week if no visible days + this.end.add((7 - this.end.weekday()) % 7, 'days'); // move to end of week if not already + this.end = this.skipHiddenDays(this.end, -1, true); // move in from the last invisible days of the week + + var monthsPerRow = parseInt(this.opt('yearColumns'), 10); + + var weekCols = this.opt('weekends') ? 7 : 5; + + this.renderYear(monthsPerRow, weekCols, true); + }, + + renderYear: function(yearColumns, colCnt, showNumbers) { + this.colCnt = colCnt; + var firstTime = !this.table; + + if (!firstTime) { + this.unrenderEvents(); + this.table.remove(); + } + this.buildSkeleton(this.yearColumns, showNumbers); + + this.buildDayGrids(); + this.updateCells(); + }, + + isBootstrap: true, + + // Build the year layout + buildSkeleton: function(monthsPerRow, showNumbers) { + var i, n, y, h = 0, monthsRow = 0; + var miYear = this.intervalStart.year(); + var s, headerClass = this.tm + "-widget-header"; + var weekNames = []; + + // init days based on 2013-12 (1st is Sunday) + for (n=0; n<7; n++) { + weekNames[n] = FC.moment([ 2013, 11, 1+n ]).format('ddd'); + } + + if (this.isBootstrap) { + s = '
'; + } + else { + s = ''; + s += ''; + } + + var colSize = 12/monthsPerRow; + + for (n=0; n 12) { + monthName = monthName + ' ' + y; + } + + // new month line + if ((n%monthsPerRow)===0 && n > 0 && !hiddenMonth) { + monthsRow++; + if (this.isBootstrap) { + s += ''; + s += '
'; + } + else { + s+='
'+ + ''+ + ''; + } + } + + if ((n%monthsPerRow) < monthsPerRow && (n%monthsPerRow) > 0 && !hiddenMonth) { + if (this.isBootstrap) { + s +='
'; + } + else { + s +=''; + } + } + + if (this.isBootstrap) { + s +='
'; + } + else { + s +='
'; + } + + if (hiddenMonth) { + h++; + } + } + + if (this.isBootstrap) { + s += ''; + } + else { + s += ''; + s += '
'; + } + + s +=''; + + s +='
'; + + s +=''; //fc-year-month-header + s +=''; + + s += this.headIntroHtml(); + + for (i = this.firstDay; i < (this.colCnt+this.firstDay); i++) { + s += ''; + } + + s += ''; + s += '
'+ // width="'+(Math.round(100/this.colCnt)||10)+'%" + weekNames[i%7] + '
'; // fc-year-month-header + + s += '
'; // fc-row + + s += '
'; + s += '
'; // fc-day-grid fc-day-grid-container + + s += ''; + + if (this.isBootstrap) { + s += ''; // fc-year-monthly-td + } + else { + s += '
'; + } + + this.table = $(s).appendTo(this.el); + + this.bodyRows = this.table.find('.fc-day-grid .fc-week'); + this.bodyCells = this.bodyRows.find('td .fc-day'); + this.bodyFirstCells = this.bodyCells.filter(':first-child'); + + this.subTables = this.table.find('.fc-year-monthly-td'); + + + this.head = this.table.find('thead'); + this.head.find('tr.fc-year-week-days th.fc-year-weekly-head:first').addClass('fc-first'); + this.head.find('tr.fc-year-week-days th.fc-year-weekly-head:last').addClass('fc-last'); + + this.table.find('.fc-year-monthly-name a').click(this.calendar, function(ev) { + ev.data.changeView('month'); + ev.data.gotoDate([$(this).attr('data-year'), $(this).attr('data-month'), 1]); + }); + + + this.dayBind(this.bodyCells); + }, + + // Create month grids + buildDayGrids: function() { + var view = this; + var nums = []; + for (var i=0; i' + + '' + // needed for matchCellWidths + htmlEscape(this.opt('weekNumberTitle')) + + '' + + ''; + } else { + return ''; + } + }, + + // Day clicking and binding + dayBind: function(days) { + var _this = this; + days.click(function(ev) { + var self = _this; + self.trigger('dayClick', + self, + FC.moment(this.getAttribute("data-date")), + ev + ); + + }); + days.mousedown(this.daySelectionMousedown); + } + }); + + ;; + + fcViews.basic = { + 'class': BasicView + }; + + fcViews.basicDay = { + type: 'basic', + duration: { days: 1 } + }; + + fcViews.basicWeek = { + type: 'basic', + duration: { weeks: 1 } + }; + + fcViews.month = { + 'class': MonthView, + duration: { months: 1 }, // important for prev/next + defaults: { + fixedWeekCount: true + } + }; + + fcViews.year = { + 'class': YearView, + duration: { year: 1 }, + defaults: { + fixedWeekCount: true + } + }; + + ;; + + /* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically. +----------------------------------------------------------------------------------------------------------------------*/ +// Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on). +// Responsible for managing width/height. + + var AgendaView = FC.AgendaView = View.extend({ + + scroller: null, + + timeGridClass: TimeGrid, // class used to instantiate the timeGrid. subclasses can override + timeGrid: null, // the main time-grid subcomponent of this view + + dayGridClass: DayGrid, // class used to instantiate the dayGrid. subclasses can override + dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null + + axisWidth: null, // the width of the time axis running down the side + + headContainerEl: null, // div that hold's the timeGrid's rendered date header + noScrollRowEls: null, // set of fake row elements that must compensate when scroller has scrollbars + + // when the time-grid isn't tall enough to occupy the given height, we render an
underneath + bottomRuleEl: null, + + // indicates that minTime/maxTime affects rendering + usesMinMaxTime: true, + + + initialize: function() { + this.timeGrid = this.instantiateTimeGrid(); + + if (this.opt('allDaySlot')) { // should we display the "all-day" area? + this.dayGrid = this.instantiateDayGrid(); // the all-day subcomponent of this view + } + + this.scroller = new Scroller({ + overflowX: 'hidden', + overflowY: 'auto' + }); + }, + + + // Instantiates the TimeGrid object this view needs. Draws from this.timeGridClass + instantiateTimeGrid: function() { + var subclass = this.timeGridClass.extend(agendaTimeGridMethods); + + return new subclass(this); + }, + + + // Instantiates the DayGrid object this view might need. Draws from this.dayGridClass + instantiateDayGrid: function() { + var subclass = this.dayGridClass.extend(agendaDayGridMethods); + + return new subclass(this); + }, + + + /* Rendering + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders the view into `this.el`, which has already been assigned + renderDates: function() { + + this.timeGrid.setRange(this.renderRange); + + if (this.dayGrid) { + this.dayGrid.setRange(this.renderRange); + } + + this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml()); + this.renderHead(); + + this.scroller.render(); + var timeGridWrapEl = this.scroller.el.addClass('fc-time-grid-container'); + var timeGridEl = $('
').appendTo(timeGridWrapEl); + this.el.find('.fc-body > tr > td').append(timeGridWrapEl); + + this.timeGrid.setElement(timeGridEl); + this.timeGrid.renderDates(); + + // the
that sometimes displays under the time-grid + this.bottomRuleEl = $('
') + .appendTo(this.timeGrid.el); // inject it into the time-grid + + if (this.dayGrid) { + this.dayGrid.setElement(this.el.find('.fc-day-grid')); + this.dayGrid.renderDates(); + + // have the day-grid extend it's coordinate area over the
dividing the two grids + this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight(); + } + + this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller + }, + + + // render the day-of-week headers + renderHead: function() { + this.headContainerEl = + this.el.find('.fc-head-container') + .html(this.timeGrid.renderHeadHtml()); + }, + + + // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, + // always completely kill each grid's rendering. + unrenderDates: function() { + this.timeGrid.unrenderDates(); + this.timeGrid.removeElement(); + + if (this.dayGrid) { + this.dayGrid.unrenderDates(); + this.dayGrid.removeElement(); + } + + this.scroller.destroy(); + }, + + + // Builds the HTML skeleton for the view. + // The day-grid and time-grid components will render inside containers defined by this HTML. + renderSkeletonHtml: function() { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + (this.dayGrid ? + '
' + + '
' : + '' + ) + + '
'; + }, + + + // Generates an HTML attribute string for setting the width of the axis, if it is known + axisStyleAttr: function() { + if (this.axisWidth !== null) { + return 'style="width:' + this.axisWidth + 'px"'; + } + return ''; + }, + + + /* Business Hours + ------------------------------------------------------------------------------------------------------------------*/ + + + renderBusinessHours: function() { + this.timeGrid.renderBusinessHours(); + + if (this.dayGrid) { + this.dayGrid.renderBusinessHours(); + } + }, + + + unrenderBusinessHours: function() { + this.timeGrid.unrenderBusinessHours(); + + if (this.dayGrid) { + this.dayGrid.unrenderBusinessHours(); + } + }, + + + /* Now Indicator + ------------------------------------------------------------------------------------------------------------------*/ + + + getNowIndicatorUnit: function() { + return this.timeGrid.getNowIndicatorUnit(); + }, + + + renderNowIndicator: function(date) { + this.timeGrid.renderNowIndicator(date); + }, + + + unrenderNowIndicator: function() { + this.timeGrid.unrenderNowIndicator(); + }, + + + /* Dimensions + ------------------------------------------------------------------------------------------------------------------*/ + + + updateSize: function(isResize) { + this.timeGrid.updateSize(isResize); + + View.prototype.updateSize.call(this, isResize); // call the super-method + }, + + + // Refreshes the horizontal dimensions of the view + updateWidth: function() { + // make all axis cells line up, and record the width so newly created axis cells will have it + this.axisWidth = matchCellWidths(this.el.find('.fc-axis')); + }, + + + // Adjusts the vertical dimensions of the view to the specified values + setHeight: function(totalHeight, isAuto) { + var eventLimit; + var scrollerHeight; + var scrollbarWidths; + + // reset all dimensions back to the original state + this.bottomRuleEl.hide(); // .show() will be called later if this
is necessary + this.scroller.clear(); // sets height to 'auto' and clears overflow + uncompensateScroll(this.noScrollRowEls); + + // limit number of events in the all-day area + if (this.dayGrid) { + this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed + + eventLimit = this.opt('eventLimit'); + if (eventLimit && typeof eventLimit !== 'number') { + eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number + } + if (eventLimit) { + this.dayGrid.limitRows(eventLimit); + } + } + + if (!isAuto) { // should we force dimensions of the scroll container? + + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.scroller.setHeight(scrollerHeight); + scrollbarWidths = this.scroller.getScrollbarWidths(); + + if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars? + + // make the all-day and header rows lines up + compensateScroll(this.noScrollRowEls, scrollbarWidths); + + // the scrollbar compensation might have changed text flow, which might affect height, so recalculate + // and reapply the desired height to the scroller. + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.scroller.setHeight(scrollerHeight); + } + + // guarantees the same scrollbar widths + this.scroller.lockOverflow(scrollbarWidths); + + // if there's any space below the slats, show the horizontal rule. + // this won't cause any new overflow, because lockOverflow already called. + if (this.timeGrid.getTotalSlatHeight() < scrollerHeight) { + this.bottomRuleEl.show(); + } + } + }, + + + // given a desired total height of the view, returns what the height of the scroller should be + computeScrollerHeight: function(totalHeight) { + return totalHeight - + subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller + }, + + + /* Scroll + ------------------------------------------------------------------------------------------------------------------*/ + + + // Computes the initial pre-configured scroll state prior to allowing the user to change it + computeInitialDateScroll: function() { + var scrollTime = moment.duration(this.opt('scrollTime')); + var top = this.timeGrid.computeTimeTop(scrollTime); + + // zoom can give weird floating-point values. rather scroll a little bit further + top = Math.ceil(top); + + if (top) { + top++; // to overcome top border that slots beyond the first have. looks better + } + + return { top: top }; + }, + + + queryDateScroll: function() { + return { top: this.scroller.getScrollTop() }; + }, + + + applyDateScroll: function(scroll) { + if (scroll.top !== undefined) { + this.scroller.setScrollTop(scroll.top); + } + }, + + + /* Hit Areas + ------------------------------------------------------------------------------------------------------------------*/ + // forward all hit-related method calls to the grids (dayGrid might not be defined) + + + hitsNeeded: function() { + this.timeGrid.hitsNeeded(); + if (this.dayGrid) { + this.dayGrid.hitsNeeded(); + } + }, + + + hitsNotNeeded: function() { + this.timeGrid.hitsNotNeeded(); + if (this.dayGrid) { + this.dayGrid.hitsNotNeeded(); + } + }, + + + prepareHits: function() { + this.timeGrid.prepareHits(); + if (this.dayGrid) { + this.dayGrid.prepareHits(); + } + }, + + + releaseHits: function() { + this.timeGrid.releaseHits(); + if (this.dayGrid) { + this.dayGrid.releaseHits(); + } + }, + + + queryHit: function(left, top) { + var hit = this.timeGrid.queryHit(left, top); + + if (!hit && this.dayGrid) { + hit = this.dayGrid.queryHit(left, top); + } + + return hit; + }, + + + getHitSpan: function(hit) { + // TODO: hit.component is set as a hack to identify where the hit came from + return hit.component.getHitSpan(hit); + }, + + + getHitEl: function(hit) { + // TODO: hit.component is set as a hack to identify where the hit came from + return hit.component.getHitEl(hit); + }, + + + /* Events + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders events onto the view and populates the View's segment array + renderEvents: function(events) { + var dayEvents = []; + var timedEvents = []; + var daySegs = []; + var timedSegs; + var i; + + // separate the events into all-day and timed + for (i = 0; i < events.length; i++) { + if (events[i].allDay) { + dayEvents.push(events[i]); + } + else { + timedEvents.push(events[i]); + } + } + + // render the events in the subcomponents + timedSegs = this.timeGrid.renderEvents(timedEvents); + if (this.dayGrid) { + daySegs = this.dayGrid.renderEvents(dayEvents); + } + + // the all-day area is flexible and might have a lot of events, so shift the height + this.updateHeight(); + }, + + + // Retrieves all segment objects that are rendered in the view + getEventSegs: function() { + return this.timeGrid.getEventSegs().concat( + this.dayGrid ? this.dayGrid.getEventSegs() : [] + ); + }, + + + // Unrenders all event elements and clears internal segment data + unrenderEvents: function() { + + // unrender the events in the subcomponents + this.timeGrid.unrenderEvents(); + if (this.dayGrid) { + this.dayGrid.unrenderEvents(); + } + + // we DON'T need to call updateHeight() because + // a renderEvents() call always happens after this, which will eventually call updateHeight() + }, + + + /* Dragging (for events and external elements) + ------------------------------------------------------------------------------------------------------------------*/ + + + // A returned value of `true` signals that a mock "helper" event has been rendered. + renderDrag: function(dropLocation, seg) { + if (dropLocation.start.hasTime()) { + return this.timeGrid.renderDrag(dropLocation, seg); + } + else if (this.dayGrid) { + return this.dayGrid.renderDrag(dropLocation, seg); + } + }, + + + unrenderDrag: function() { + this.timeGrid.unrenderDrag(); + if (this.dayGrid) { + this.dayGrid.unrenderDrag(); + } + }, + + + /* Selection + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of a selection + renderSelection: function(span) { + if (span.start.hasTime() || span.end.hasTime()) { + this.timeGrid.renderSelection(span); + } + else if (this.dayGrid) { + this.dayGrid.renderSelection(span); + } + }, + + + // Unrenders a visual indications of a selection + unrenderSelection: function() { + this.timeGrid.unrenderSelection(); + if (this.dayGrid) { + this.dayGrid.unrenderSelection(); + } + } + + }); + + +// Methods that will customize the rendering behavior of the AgendaView's timeGrid +// TODO: move into TimeGrid + var agendaTimeGridMethods = { + + + // Generates the HTML that will go before the day-of week header cells + renderHeadIntroHtml: function() { + var view = this.view; + var weekText; + + if (view.opt('weekNumbers')) { + weekText = this.start.format(view.opt('smallWeekFormat')); + + return '' + + '' + + view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths + { date: this.start, type: 'week', forceOff: this.colCnt > 1 }, + htmlEscape(weekText) // inner HTML + ) + + ''; + } + else { + return ''; + } + }, + + + // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column. + renderBgIntroHtml: function() { + var view = this.view; + + return ''; + }, + + + // Generates the HTML that goes before all other types of cells. + // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. + renderIntroHtml: function() { + var view = this.view; + + return ''; + } + + }; + + +// Methods that will customize the rendering behavior of the AgendaView's dayGrid + var agendaDayGridMethods = { + + + // Generates the HTML that goes before the all-day cells + renderBgIntroHtml: function() { + var view = this.view; + + return '' + + '' + + '' + // needed for matchCellWidths + view.getAllDayHtml() + + '' + + ''; + }, + + + // Generates the HTML that goes before all other types of cells. + // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. + renderIntroHtml: function() { + var view = this.view; + + return ''; + } + + }; + + ;; + + var AGENDA_ALL_DAY_EVENT_LIMIT = 5; + +// potential nice values for the slot-duration and interval-duration +// from largest to smallest + var AGENDA_STOCK_SUB_DURATIONS = [ + { hours: 1 }, + { minutes: 30 }, + { minutes: 15 }, + { seconds: 30 }, + { seconds: 15 } + ]; + + fcViews.agenda = { + 'class': AgendaView, + defaults: { + allDaySlot: true, + slotDuration: '00:30:00', + slotEventOverlap: true // a bad name. confused with overlap/constraint system + } + }; + + fcViews.agendaDay = { + type: 'agenda', + duration: { days: 1 } + }; + + fcViews.agendaWeek = { + type: 'agenda', + duration: { weeks: 1 } + }; + ;; + + /* +Responsible for the scroller, and forwarding event-related actions into the "grid" +*/ + var ListView = View.extend({ + + grid: null, + scroller: null, + + initialize: function() { + this.grid = new ListViewGrid(this); + this.scroller = new Scroller({ + overflowX: 'hidden', + overflowY: 'auto' + }); + }, + + renderSkeleton: function() { + this.el.addClass( + 'fc-list-view ' + + this.widgetContentClass + ); + + this.scroller.render(); + this.scroller.el.appendTo(this.el); + + this.grid.setElement(this.scroller.scrollEl); + }, + + unrenderSkeleton: function() { + this.scroller.destroy(); // will remove the Grid too + }, + + setHeight: function(totalHeight, isAuto) { + this.scroller.setHeight(this.computeScrollerHeight(totalHeight)); + }, + + computeScrollerHeight: function(totalHeight) { + return totalHeight - + subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller + }, + + renderDates: function() { + this.grid.setRange(this.renderRange); // needs to process range-related options + }, + + renderEvents: function(events) { + this.grid.renderEvents(events); + }, + + unrenderEvents: function() { + this.grid.unrenderEvents(); + }, + + isEventResizable: function(event) { + return false; + }, + + isEventDraggable: function(event) { + return false; + } + + }); + + /* +Responsible for event rendering and user-interaction. +Its "el" is the inner-content of the above view's scroller. +*/ + var ListViewGrid = Grid.extend({ + + segSelector: '.fc-list-item', // which elements accept event actions + hasDayInteractions: false, // no day selection or day clicking + + // slices by day + spanToSegs: function(span) { + var view = this.view; + var dayStart = view.renderRange.start.clone().time(0); // timed, so segs get times! + var dayIndex = 0; + var seg; + var segs = []; + + while (dayStart < view.renderRange.end) { + + seg = intersectRanges(span, { + start: dayStart, + end: dayStart.clone().add(1, 'day') + }); + + if (seg) { + seg.dayIndex = dayIndex; + segs.push(seg); + } + + dayStart.add(1, 'day'); + dayIndex++; + + // detect when span won't go fully into the next day, + // and mutate the latest seg to the be the end. + if ( + seg && !seg.isEnd && span.end.hasTime() && + span.end < dayStart.clone().add(this.view.nextDayThreshold) + ) { + seg.end = span.end.clone(); + seg.isEnd = true; + break; + } + } + + return segs; + }, + + // like "4:00am" + computeEventTimeFormat: function() { + return this.view.opt('mediumTimeFormat'); + }, + + // for events with a url, the whole should be clickable, + // but it's impossible to wrap with an tag. simulate this. + handleSegClick: function(seg, ev) { + var url; + + Grid.prototype.handleSegClick.apply(this, arguments); // super. might prevent the default action + + // not clicking on or within an with an href + if (!$(ev.target).closest('a[href]').length) { + url = seg.event.url; + if (url && !ev.isDefaultPrevented()) { // jsEvent not cancelled in handler + window.location.href = url; // simulate link click + } + } + }, + + // returns list of foreground segs that were actually rendered + renderFgSegs: function(segs) { + segs = this.renderFgSegEls(segs); // might filter away hidden events + + if (!segs.length) { + this.renderEmptyMessage(); + } + else { + this.renderSegList(segs); + } + + return segs; + }, + + renderEmptyMessage: function() { + this.el.html( + '
' + // TODO: try less wraps + '
' + + '
' + + htmlEscape(this.view.opt('noEventsMessage')) + + '
' + + '
' + + '
' + ); + }, + + // render the event segments in the view + renderSegList: function(allSegs) { + var segsByDay = this.groupSegsByDay(allSegs); // sparse array + var dayIndex; + var daySegs; + var i; + var tableEl = $('
'); + var tbodyEl = tableEl.find('tbody'); + + for (dayIndex = 0; dayIndex < segsByDay.length; dayIndex++) { + daySegs = segsByDay[dayIndex]; + if (daySegs) { // sparse array, so might be undefined + + // append a day header + tbodyEl.append(this.dayHeaderHtml( + this.view.renderRange.start.clone().add(dayIndex, 'days') + )); + + this.sortEventSegs(daySegs); + + for (i = 0; i < daySegs.length; i++) { + tbodyEl.append(daySegs[i].el); // append event row + } + } + } + + this.el.empty().append(tableEl); + }, + + // Returns a sparse array of arrays, segs grouped by their dayIndex + groupSegsByDay: function(segs) { + var segsByDay = []; // sparse array + var i, seg; + + for (i = 0; i < segs.length; i++) { + seg = segs[i]; + (segsByDay[seg.dayIndex] || (segsByDay[seg.dayIndex] = [])) + .push(seg); + } + + return segsByDay; + }, + + // generates the HTML for the day headers that live amongst the event rows + dayHeaderHtml: function(dayDate) { + var view = this.view; + var mainFormat = view.opt('listDayFormat'); + var altFormat = view.opt('listDayAltFormat'); + + return '' + + '' + + (mainFormat ? + view.buildGotoAnchorHtml( + dayDate, + { 'class': 'fc-list-heading-main' }, + htmlEscape(dayDate.format(mainFormat)) // inner HTML + ) : + '') + + (altFormat ? + view.buildGotoAnchorHtml( + dayDate, + { 'class': 'fc-list-heading-alt' }, + htmlEscape(dayDate.format(altFormat)) // inner HTML + ) : + '') + + '' + + ''; + }, + + // generates the HTML for a single event row + fgSegHtml: function(seg) { + var view = this.view; + var classes = [ 'fc-list-item' ].concat(this.getSegCustomClasses(seg)); + var bgColor = this.getSegBackgroundColor(seg); + var event = seg.event; + var url = event.url; + var timeHtml; + + if (event.allDay) { + timeHtml = view.getAllDayHtml(); + } + else if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day + if (seg.isStart || seg.isEnd) { // outer segment that probably lasts part of the day + timeHtml = htmlEscape(this.getEventTimeText(seg)); + } + else { // inner segment that lasts the whole day + timeHtml = view.getAllDayHtml(); + } + } + else { + // Display the normal time text for the *event's* times + timeHtml = htmlEscape(this.getEventTimeText(event)); + } + + if (url) { + classes.push('fc-has-url'); + } + + return '' + + (this.displayEventTime ? + '' + + (timeHtml || '') + + '' : + '') + + '' + + '' + + '' + + '' + + '' + + htmlEscape(seg.event.title || '') + + '
' + + '' + + ''; + } + + }); + + ;; + + fcViews.list = { + 'class': ListView, + buttonTextKey: 'list', // what to lookup in locale files + defaults: { + buttonText: 'list', // text to display for English + listDayFormat: 'LL', // like "January 1, 2016" + noEventsMessage: 'No events to display' + } + }; + + fcViews.listDay = { + type: 'list', + duration: { days: 1 }, + defaults: { + listDayFormat: 'dddd' // day-of-week is all we need. full date is probably in header + } + }; + + fcViews.listWeek = { + type: 'list', + duration: { weeks: 1 }, + defaults: { + listDayFormat: 'dddd', // day-of-week is more important + listDayAltFormat: 'LL' + } + }; + + fcViews.listMonth = { + type: 'list', + duration: { month: 1 }, + defaults: { + listDayAltFormat: 'dddd' // day-of-week is nice-to-have + } + }; + + fcViews.listYear = { + type: 'list', + duration: { year: 1 }, + defaults: { + listDayAltFormat: 'dddd' // day-of-week is nice-to-have + } + }; + + ;; + + return FC; // export for Node/CommonJS +}); \ No newline at end of file diff --git a/js/fullcalendar_view.js b/js/fullcalendar_view.js index 6483df2..73d6538 100644 --- a/js/fullcalendar_view.js +++ b/js/fullcalendar_view.js @@ -21,6 +21,7 @@ center: "title", right: drupalSettings.rightButtons }, + yearColumns: drupalSettings.yearColumns, titleFormat: drupalSettings.titleFormat, defaultDate: drupalSettings.defaultDate, firstDay: drupalSettings.firstDay, diff --git a/src/Plugin/views/style/FullCalendarDisplay.php b/src/Plugin/views/style/FullCalendarDisplay.php index d66691e..3ba32ed 100644 --- a/src/Plugin/views/style/FullCalendarDisplay.php +++ b/src/Plugin/views/style/FullCalendarDisplay.php @@ -83,9 +83,9 @@ class FullCalendarDisplay extends StylePluginBase { $options['right_buttons'] = [ 'default' => [ - 'agendaWeek' => 'agendaWeek', - 'agendaDay' => 'agendaDay', - 'listYear' => 'listYear', + 'year' => 'year', + 'month' => 'month', + 'basicWeek' => 'basicWeek', ], ]; $options['default_view'] = ['default' => 'month']; @@ -188,9 +188,10 @@ class FullCalendarDisplay extends StylePluginBase { '#type' => 'checkboxes', '#fieldset' => 'display', '#options' => [ - 'agendaWeek' => $this->t('Week'), - 'agendaDay' => $this->t('Day'), - 'listYear' => $this->t('List'), + 'year' => $this->t('Year'), + 'month' => $this->t('Month'), + 'basicWeek' => $this->t('Week'), + 'basicDay' => $this->t('Day'), ], '#default_value' => (empty($this->options['right_buttons'])) ? [] : $this->options['right_buttons'], '#title' => $this->t('Right side buttons'), @@ -201,10 +202,10 @@ class FullCalendarDisplay extends StylePluginBase { '#type' => 'radios', '#fieldset' => 'display', '#options' => [ + 'year' => $this->t('Year'), 'month' => $this->t('Month'), - 'agendaWeek' => $this->t('Week'), + 'basicWeek' => $this->t('Week'), 'agendaDay' => $this->t('Day'), - 'listYear' => $this->t('List'), ], '#default_value' => (empty($this->options['default_view'])) ? 'month' : $this->options['default_view'], '#title' => $this->t('Default view'), @@ -233,6 +234,22 @@ class FullCalendarDisplay extends StylePluginBase { '#title' => $this->t('Day/Week are links'), '#description' => $this->t('If this option is selected, day/week names will be linked to navigation views.'), ]; + // Year columns. + $form['yearColumns'] = [ + '#fieldset' => 'display', + '#type' => 'number', + '#title' => $this->t('Number of months in a row'), + '#min' => 1, + '#max' => 12, + '#step' => 1, + '#size' => 2, + '#default_value' => ($this->options['yearColumns']) ?: 4, + '#states' => [ + 'visible' => [ + ':input[name="style_options[right_buttons][year]"]' => ['checked' => TRUE], + ], + ], + ]; // Time format $form['timeFormat'] = [ '#fieldset' => 'display', @@ -400,7 +417,7 @@ class FullCalendarDisplay extends StylePluginBase { $tax_fields[$field_name] = $lable; } } - + $moduleHandler = \Drupal::service('module_handler'); if ($moduleHandler->moduleExists('taxonomy')) { // Field name of event taxonomy. @@ -549,7 +566,7 @@ class FullCalendarDisplay extends StylePluginBase { if (isset($options['business_end'])) { $options['business_end'] = $options['business_end']->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT ); } - + // Sanitize user input. $options['timeFormat'] = Xss::filter($options['timeFormat']);