CalendarComponent.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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. this.view.popScroll()
  88. }
  89. renderToolbars(viewSpec: ViewSpec, dateProfile: DateProfile, dateProfileGenerator: DateProfileGenerator, title: string) {
  90. let headerLayout = this.opt('header')
  91. let footerLayout = this.opt('footer')
  92. let now = this.calendar.getNow()
  93. let todayInfo = dateProfileGenerator.build(now)
  94. let prevInfo = dateProfileGenerator.buildPrev(dateProfile)
  95. let nextInfo = dateProfileGenerator.buildNext(dateProfile)
  96. let toolbarProps = {
  97. title,
  98. activeButton: viewSpec.type,
  99. isTodayEnabled: todayInfo.isValid && !rangeContainsMarker(dateProfile.currentRange, now),
  100. isPrevEnabled: prevInfo.isValid,
  101. isNextEnabled: nextInfo.isValid
  102. }
  103. if (headerLayout) {
  104. if (!this.header) {
  105. this.header = new Toolbar(this.context, 'fc-header-toolbar')
  106. prependToElement(this.el, this.header.el)
  107. }
  108. this.header.receiveProps(
  109. assignTo({ layout: headerLayout }, toolbarProps)
  110. )
  111. } else if (this.header) {
  112. this.header.destroy()
  113. this.header = null
  114. }
  115. if (footerLayout) {
  116. if (!this.footer) {
  117. this.footer = new Toolbar(this.context, 'fc-footer-toolbar')
  118. appendToElement(this.el, this.footer.el)
  119. }
  120. this.footer.receiveProps(
  121. assignTo({ layout: footerLayout }, toolbarProps)
  122. )
  123. } else if (this.footer) {
  124. this.footer.destroy()
  125. this.footer = null
  126. }
  127. }
  128. renderView(props: CalendarComponentProps, title: string) {
  129. let { view } = this
  130. let { viewSpec, dateProfileGenerator } = props
  131. if (!view || view.viewSpec !== viewSpec) {
  132. if (view) {
  133. view.destroy()
  134. }
  135. view = this.view = new viewSpec['class'](
  136. {
  137. calendar: this.calendar,
  138. dateEnv: this.dateEnv,
  139. theme: this.theme,
  140. options: viewSpec.options
  141. },
  142. viewSpec,
  143. dateProfileGenerator,
  144. this.contentEl
  145. )
  146. }
  147. view.title = title // for the API
  148. view.receiveProps({
  149. dateProfile: props.dateProfile,
  150. businessHours: this.parseBusinessHours(viewSpec.options.businessHours),
  151. eventStore: props.eventStore,
  152. eventUis: props.eventUis,
  153. dateSelection: props.dateSelection,
  154. eventSelection: props.eventSelection,
  155. eventDrag: props.eventDrag,
  156. eventResize: props.eventResize
  157. })
  158. }
  159. // Sizing
  160. // -----------------------------------------------------------------------------------------------------------------
  161. updateRootSize(isResize = false) {
  162. if (isResize || this.isHeightAuto == null) {
  163. this.computeHeightVars()
  164. }
  165. this.updateSize(this.viewHeight, this.isHeightAuto, isResize)
  166. }
  167. updateSize(totalHeight, isAuto, isResize) {
  168. super.updateSize(totalHeight, isAuto, isResize)
  169. this.view.updateSize(this.viewHeight, this.isHeightAuto, isResize)
  170. }
  171. computeHeightVars() {
  172. let { calendar } = this // yuck. need to handle dynamic options
  173. let heightInput = calendar.opt('height')
  174. let contentHeightInput = calendar.opt('contentHeight')
  175. this.isHeightAuto = heightInput === 'auto' || contentHeightInput === 'auto'
  176. if (typeof contentHeightInput === 'number') { // exists and not 'auto'
  177. this.viewHeight = contentHeightInput
  178. } else if (typeof contentHeightInput === 'function') { // exists and is a function
  179. this.viewHeight = contentHeightInput()
  180. } else if (typeof heightInput === 'number') { // exists and not 'auto'
  181. this.viewHeight = heightInput - this.queryToolbarsHeight()
  182. } else if (typeof heightInput === 'function') { // exists and is a function
  183. this.viewHeight = heightInput() - this.queryToolbarsHeight()
  184. } else if (heightInput === 'parent') { // set to height of parent element
  185. this.viewHeight = (this.el.parentNode as HTMLElement).offsetHeight - this.queryToolbarsHeight()
  186. } else {
  187. this.viewHeight = Math.round(
  188. this.contentEl.offsetWidth /
  189. Math.max(this.opt('aspectRatio'), .5)
  190. )
  191. }
  192. }
  193. queryToolbarsHeight() {
  194. let height = 0
  195. if (this.header) {
  196. height += computeHeightAndMargins(this.header.el)
  197. }
  198. if (this.footer) {
  199. height += computeHeightAndMargins(this.footer.el)
  200. }
  201. return height
  202. }
  203. // Height "Freezing"
  204. // -----------------------------------------------------------------------------------------------------------------
  205. freezeContentHeight() {
  206. applyStyle(this.contentEl, {
  207. width: '100%',
  208. height: this.contentEl.offsetHeight,
  209. overflow: 'hidden'
  210. })
  211. }
  212. thawContentHeight() {
  213. applyStyle(this.contentEl, {
  214. width: '',
  215. height: '',
  216. overflow: ''
  217. })
  218. }
  219. }
  220. // Title and Date Formatting
  221. // -----------------------------------------------------------------------------------------------------------------
  222. // Computes what the title at the top of the calendar should be for this view
  223. function computeTitle(dateProfile, viewOptions) {
  224. let range: DateRange
  225. // for views that span a large unit of time, show the proper interval, ignoring stray days before and after
  226. if (/^(year|month)$/.test(dateProfile.currentRangeUnit)) {
  227. range = dateProfile.currentRange
  228. } else { // for day units or smaller, use the actual day range
  229. range = dateProfile.activeRange
  230. }
  231. return this.dateEnv.formatRange(
  232. range.start,
  233. range.end,
  234. createFormatter(
  235. viewOptions.titleFormat || computeTitleFormat(dateProfile),
  236. viewOptions.titleRangeSeparator
  237. ),
  238. { isEndExclusive: dateProfile.isRangeAllDay }
  239. )
  240. }
  241. // Generates the format string that should be used to generate the title for the current date range.
  242. // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`.
  243. function computeTitleFormat(dateProfile) {
  244. let currentRangeUnit = dateProfile.currentRangeUnit
  245. if (currentRangeUnit === 'year') {
  246. return { year: 'numeric' }
  247. } else if (currentRangeUnit === 'month') {
  248. return { year: 'numeric', month: 'long' } // like "September 2014"
  249. } else {
  250. let days = diffWholeDays(
  251. dateProfile.currentRange.start,
  252. dateProfile.currentRange.end
  253. )
  254. if (days !== null && days > 1) {
  255. // multi-day range. shorter, like "Sep 9 - 10 2014"
  256. return { year: 'numeric', month: 'short', day: 'numeric' }
  257. } else {
  258. // one day. longer, like "September 9 2014"
  259. return { year: 'numeric', month: 'long', day: 'numeric' }
  260. }
  261. }
  262. }