CalendarComponent.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import Component, { ComponentContext } from './component/Component'
  2. import { ViewSpec } from './structs/view-spec'
  3. import View from './View'
  4. import Toolbar from './Toolbar'
  5. import DateProfileGenerator, { DateProfile } from './DateProfileGenerator'
  6. import { prependToElement, createElement, removeElement, appendToElement, applyStyle } from './util/dom-manip'
  7. import { rangeContainsMarker, DateRange } from './datelib/date-range';
  8. import { assignTo } from './util/object';
  9. import { EventStore } from './structs/event-store'
  10. import { EventUiHash } from './component/event-rendering'
  11. import { DateSpan } from './structs/date-span'
  12. import { EventInteractionUiState } from './interactions/event-interaction-state'
  13. import { BusinessHoursInput, parseBusinessHours } from './structs/business-hours'
  14. import reselector from './util/reselector'
  15. import { computeHeightAndMargins } from './util/dom-geom'
  16. import { createFormatter } from './datelib/formatting'
  17. import { diffWholeDays } from './datelib/marker'
  18. export interface CalendarComponentProps {
  19. viewSpec: ViewSpec
  20. dateProfile: DateProfile | null // for the current view
  21. dateProfileGenerator: DateProfileGenerator // for the current view
  22. eventStore: EventStore
  23. eventUis: EventUiHash
  24. dateSelection: DateSpan | null
  25. eventSelection: string
  26. eventDrag: EventInteractionUiState | null
  27. eventResize: EventInteractionUiState | null
  28. }
  29. export default class CalendarComponent extends Component<CalendarComponentProps> {
  30. view: View
  31. header: Toolbar
  32. footer: Toolbar
  33. computeTitle: (dateProfile, viewOptions) => string
  34. parseBusinessHours: (input: BusinessHoursInput) => EventStore
  35. el: HTMLElement
  36. contentEl: HTMLElement
  37. isHeightAuto: boolean
  38. viewHeight: number
  39. constructor(context: ComponentContext, el: HTMLElement) {
  40. super(context)
  41. this.el = el
  42. prependToElement(
  43. el,
  44. this.contentEl = createElement('div', { className: 'fc-view-container' })
  45. )
  46. this.toggleElClassNames(true)
  47. this.computeTitle = reselector(computeTitle)
  48. this.parseBusinessHours = reselector((input) => {
  49. return parseBusinessHours(input, this.calendar)
  50. })
  51. }
  52. destroy() {
  53. if (this.header) {
  54. this.header.destroy()
  55. }
  56. if (this.footer) {
  57. this.footer.destroy()
  58. }
  59. if (this.view) {
  60. this.view.destroy()
  61. }
  62. removeElement(this.contentEl)
  63. this.toggleElClassNames(false)
  64. super.destroy()
  65. }
  66. toggleElClassNames(bool: boolean) {
  67. let classList = this.el.classList
  68. let dirClassName = 'fc-' + this.opt('dir')
  69. let themeClassName = this.theme.getClass('widget')
  70. if (bool) {
  71. classList.add('fc')
  72. classList.add(dirClassName)
  73. classList.add(themeClassName)
  74. } else {
  75. classList.remove('fc')
  76. classList.remove(dirClassName)
  77. classList.remove(themeClassName)
  78. }
  79. }
  80. render(props: CalendarComponentProps) {
  81. this.freezeContentHeight()
  82. let title = this.computeTitle(props.dateProfile, props.viewSpec.options)
  83. this.subrender('renderToolbars', [ props.viewSpec, props.dateProfile, props.dateProfileGenerator, title ])
  84. this.renderView(props, title)
  85. this.updateRootSize()
  86. this.thawContentHeight()
  87. }
  88. renderToolbars(viewSpec: ViewSpec, dateProfile: DateProfile, dateProfileGenerator: DateProfileGenerator, title: string) {
  89. let headerLayout = this.opt('header')
  90. let footerLayout = this.opt('footer')
  91. let now = this.calendar.getNow()
  92. let todayInfo = dateProfileGenerator.build(now)
  93. let prevInfo = dateProfileGenerator.buildPrev(dateProfile)
  94. let nextInfo = dateProfileGenerator.buildNext(dateProfile)
  95. let toolbarProps = {
  96. title,
  97. activeButton: viewSpec.type,
  98. isTodayEnabled: todayInfo.isValid && !rangeContainsMarker(dateProfile.currentRange, now),
  99. isPrevEnabled: prevInfo.isValid,
  100. isNextEnabled: nextInfo.isValid
  101. }
  102. if (headerLayout) {
  103. if (!this.header) {
  104. this.header = new Toolbar(this.context, 'fc-header-toolbar')
  105. prependToElement(this.el, this.header.el)
  106. }
  107. this.header.receiveProps(
  108. assignTo({ layout: headerLayout }, toolbarProps)
  109. )
  110. } else if (this.header) {
  111. this.header.destroy()
  112. this.header = null
  113. }
  114. if (footerLayout) {
  115. if (!this.footer) {
  116. this.footer = new Toolbar(this.context, 'fc-footer-toolbar')
  117. appendToElement(this.el, this.footer.el)
  118. }
  119. this.footer.receiveProps(
  120. assignTo({ layout: footerLayout }, toolbarProps)
  121. )
  122. } else if (this.footer) {
  123. this.footer.destroy()
  124. this.footer = null
  125. }
  126. }
  127. renderView(props: CalendarComponentProps, title: string) {
  128. let { view } = this
  129. let { viewSpec, dateProfileGenerator } = props
  130. if (!view || view.viewSpec !== viewSpec) {
  131. if (view) {
  132. view.destroy()
  133. }
  134. view = this.view = new viewSpec['class'](
  135. {
  136. calendar: this.calendar,
  137. dateEnv: this.dateEnv,
  138. theme: this.theme,
  139. options: viewSpec.options
  140. },
  141. viewSpec,
  142. dateProfileGenerator,
  143. this.contentEl
  144. )
  145. } else {
  146. view.addScroll(view.queryScroll())
  147. }
  148. view.title = title // for the API
  149. view.receiveProps({
  150. dateProfile: props.dateProfile,
  151. businessHours: this.parseBusinessHours(viewSpec.options.businessHours),
  152. eventStore: props.eventStore,
  153. eventUis: props.eventUis,
  154. dateSelection: props.dateSelection,
  155. eventSelection: props.eventSelection,
  156. eventDrag: props.eventDrag,
  157. eventResize: props.eventResize
  158. })
  159. }
  160. // Sizing
  161. // -----------------------------------------------------------------------------------------------------------------
  162. updateRootSize(isResize = false) {
  163. let { view } = this
  164. if (isResize) {
  165. view.addScroll(view.queryScroll())
  166. }
  167. if (isResize || this.isHeightAuto == null) {
  168. this.computeHeightVars()
  169. }
  170. this.updateSize(this.viewHeight, this.isHeightAuto, isResize)
  171. view.popScroll()
  172. }
  173. updateSize(totalHeight, isAuto, isResize) {
  174. super.updateSize(totalHeight, isAuto, isResize)
  175. this.view.updateSize(this.viewHeight, this.isHeightAuto, isResize)
  176. }
  177. computeHeightVars() {
  178. let { calendar } = this // yuck. need to handle dynamic options
  179. let heightInput = calendar.opt('height')
  180. let contentHeightInput = calendar.opt('contentHeight')
  181. this.isHeightAuto = heightInput === 'auto' || contentHeightInput === 'auto'
  182. if (typeof contentHeightInput === 'number') { // exists and not 'auto'
  183. this.viewHeight = contentHeightInput
  184. } else if (typeof contentHeightInput === 'function') { // exists and is a function
  185. this.viewHeight = contentHeightInput()
  186. } else if (typeof heightInput === 'number') { // exists and not 'auto'
  187. this.viewHeight = heightInput - this.queryToolbarsHeight()
  188. } else if (typeof heightInput === 'function') { // exists and is a function
  189. this.viewHeight = heightInput() - this.queryToolbarsHeight()
  190. } else if (heightInput === 'parent') { // set to height of parent element
  191. this.viewHeight = (this.el.parentNode as HTMLElement).offsetHeight - this.queryToolbarsHeight()
  192. } else {
  193. this.viewHeight = Math.round(
  194. this.contentEl.offsetWidth /
  195. Math.max(this.opt('aspectRatio'), .5)
  196. )
  197. }
  198. }
  199. queryToolbarsHeight() {
  200. let height = 0
  201. if (this.header) {
  202. height += computeHeightAndMargins(this.header.el)
  203. }
  204. if (this.footer) {
  205. height += computeHeightAndMargins(this.footer.el)
  206. }
  207. return height
  208. }
  209. // Height "Freezing"
  210. // -----------------------------------------------------------------------------------------------------------------
  211. freezeContentHeight() {
  212. applyStyle(this.contentEl, {
  213. width: '100%',
  214. height: this.contentEl.offsetHeight,
  215. overflow: 'hidden'
  216. })
  217. }
  218. thawContentHeight() {
  219. applyStyle(this.contentEl, {
  220. width: '',
  221. height: '',
  222. overflow: ''
  223. })
  224. }
  225. }
  226. // Title and Date Formatting
  227. // -----------------------------------------------------------------------------------------------------------------
  228. // Computes what the title at the top of the calendar should be for this view
  229. function computeTitle(dateProfile, viewOptions) {
  230. let range: DateRange
  231. // for views that span a large unit of time, show the proper interval, ignoring stray days before and after
  232. if (/^(year|month)$/.test(dateProfile.currentRangeUnit)) {
  233. range = dateProfile.currentRange
  234. } else { // for day units or smaller, use the actual day range
  235. range = dateProfile.activeRange
  236. }
  237. return this.dateEnv.formatRange(
  238. range.start,
  239. range.end,
  240. createFormatter(
  241. viewOptions.titleFormat || computeTitleFormat(dateProfile),
  242. viewOptions.titleRangeSeparator
  243. ),
  244. { isEndExclusive: dateProfile.isRangeAllDay }
  245. )
  246. }
  247. // Generates the format string that should be used to generate the title for the current date range.
  248. // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`.
  249. function computeTitleFormat(dateProfile) {
  250. let currentRangeUnit = dateProfile.currentRangeUnit
  251. if (currentRangeUnit === 'year') {
  252. return { year: 'numeric' }
  253. } else if (currentRangeUnit === 'month') {
  254. return { year: 'numeric', month: 'long' } // like "September 2014"
  255. } else {
  256. let days = diffWholeDays(
  257. dateProfile.currentRange.start,
  258. dateProfile.currentRange.end
  259. )
  260. if (days !== null && days > 1) {
  261. // multi-day range. shorter, like "Sep 9 - 10 2014"
  262. return { year: 'numeric', month: 'short', day: 'numeric' }
  263. } else {
  264. // one day. longer, like "September 9 2014"
  265. return { year: 'numeric', month: 'long', day: 'numeric' }
  266. }
  267. }
  268. }