import * as $ from 'jquery'
import { htmlEscape } from '../util'
import CoordCache from '../common/CoordCache'
import Popover from '../common/Popover'
import UnzonedRange from '../models/UnzonedRange'
import ComponentFootprint from '../models/ComponentFootprint'
import EventFootprint from '../models/event/EventFootprint'
import BusinessHourRenderer from '../component/renderers/BusinessHourRenderer'
import StandardInteractionsMixin from '../component/interactions/StandardInteractionsMixin'
import InteractiveDateComponent from '../component/InteractiveDateComponent'
import { default as DayTableMixin, DayTableInterface } from '../component/DayTableMixin'
import DayGridEventRenderer from './DayGridEventRenderer'
import DayGridHelperRenderer from './DayGridHelperRenderer'
import DayGridFillRenderer from './DayGridFillRenderer'
/* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week.
----------------------------------------------------------------------------------------------------------------------*/
export default class DayGrid extends InteractiveDateComponent {
rowCnt: DayTableInterface['rowCnt']
colCnt: DayTableInterface['colCnt']
daysPerRow: DayTableInterface['daysPerRow']
sliceRangeByRow: DayTableInterface['sliceRangeByRow']
updateDayTable: DayTableInterface['updateDayTable']
renderHeadHtml: DayTableInterface['renderHeadHtml']
getCellDate: DayTableInterface['getCellDate']
renderBgTrHtml: DayTableInterface['renderBgTrHtml']
renderIntroHtml: DayTableInterface['renderIntroHtml']
getCellRange: DayTableInterface['getCellRange']
sliceRangeByDay: DayTableInterface['sliceRangeByDay']
view: any // TODO: make more general and/or remove
helperRenderer: any
cellWeekNumbersVisible: boolean = false // display week numbers in day cell?
bottomCoordPadding: number = 0 // hack for extending the hit area for the last row of the coordinate grid
headContainerEl: any // div that hold's the date header
rowEls: any // set of fake row elements
cellEls: any // set of whole-day elements comprising the row's background
rowCoordCache: any
colCoordCache: any
// isRigid determines whether the individual rows should ignore the contents and be a constant height.
// Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient.
isRigid: boolean = false
hasAllDayBusinessHours: boolean = true
segPopover: any // the Popover that holds events that can't fit in a cell. null when not visible
popoverSegs: any // an array of segment objects that the segPopover holds. null when not visible
constructor(view) { // view is required, unlike superclass
super(view)
}
// Slices up the given span (unzoned start/end with other misc data) into an array of segments
componentFootprintToSegs(componentFootprint) {
let segs = this.sliceRangeByRow(componentFootprint.unzonedRange)
let i
let seg
for (i = 0; i < segs.length; i++) {
seg = segs[i]
if (this.isRTL) {
seg.leftCol = this.daysPerRow - 1 - seg.lastRowDayIndex
seg.rightCol = this.daysPerRow - 1 - seg.firstRowDayIndex
} else {
seg.leftCol = seg.firstRowDayIndex
seg.rightCol = seg.lastRowDayIndex
}
}
return segs
}
/* Date Rendering
------------------------------------------------------------------------------------------------------------------*/
renderDates(dateProfile) {
this.dateProfile = dateProfile
this.updateDayTable()
this.renderGrid()
}
unrenderDates() {
this.removeSegPopover()
}
// Renders the rows and columns into the component's `this.el`, which should already be assigned.
renderGrid() {
let view = this.view
let rowCnt = this.rowCnt
let colCnt = this.colCnt
let html = ''
let row
let col
if (this.headContainerEl) {
this.headContainerEl.html(this.renderHeadHtml())
}
for (row = 0; row < rowCnt; row++) {
html += this.renderDayRowHtml(row, this.isRigid)
}
this.el.html(html)
this.rowEls = this.el.find('.fc-row')
this.cellEls = this.el.find('.fc-day, .fc-disabled-day')
this.rowCoordCache = new CoordCache({
els: this.rowEls,
isVertical: true
})
this.colCoordCache = new CoordCache({
els: this.cellEls.slice(0, this.colCnt), // only the first row
isHorizontal: true
})
// trigger dayRender with each cell's element
for (row = 0; row < rowCnt; row++) {
for (col = 0; col < colCnt; col++) {
this.publiclyTrigger('dayRender', {
context: view,
args: [
this.getCellDate(row, col),
this.getCellEl(row, col),
view
]
})
}
}
}
// Generates the HTML for a single row, which is a div that wraps a table.
// `row` is the row number.
renderDayRowHtml(row, isRigid) {
let theme = this.view.calendar.theme
let classes = [ 'fc-row', 'fc-week', theme.getClass('dayRow') ]
if (isRigid) {
classes.push('fc-rigid')
}
return '' +
'
' +
'
' +
'
' +
this.renderBgTrHtml(row) +
'
' +
'
' +
'
' +
'
' +
(this.getIsNumbersVisible() ?
'' +
this.renderNumberTrHtml(row) +
'' :
''
) +
'
' +
'
' +
'
'
}
getIsNumbersVisible() {
return this.getIsDayNumbersVisible() || this.cellWeekNumbersVisible
}
getIsDayNumbersVisible() {
return this.rowCnt > 1
}
/* Grid Number Rendering
------------------------------------------------------------------------------------------------------------------*/
renderNumberTrHtml(row) {
return '' +
'' +
(this.isRTL ? '' : this.renderNumberIntroHtml(row)) +
this.renderNumberCellsHtml(row) +
(this.isRTL ? this.renderNumberIntroHtml(row) : '') +
'
'
}
renderNumberIntroHtml(row) {
return this.renderIntroHtml()
}
renderNumberCellsHtml(row) {
let htmls = []
let col
let date
for (col = 0; col < this.colCnt; col++) {
date = this.getCellDate(row, col)
htmls.push(this.renderNumberCellHtml(date))
}
return htmls.join('')
}
// Generates the HTML for the s of the "number" row in the DayGrid's content skeleton.
// The number row will only exist if either day numbers or week numbers are turned on.
renderNumberCellHtml(date) {
let view = this.view
let html = ''
let isDateValid = this.dateProfile.activeUnzonedRange.containsDate(date) // TODO: called too frequently. cache somehow.
let isDayNumberVisible = this.getIsDayNumbersVisible() && isDateValid
let classes
let weekCalcFirstDoW
if (!isDayNumberVisible && !this.cellWeekNumbersVisible) {
// no numbers in day cell (week number must be along the side)
return ' | | ' // will create an empty space above events :(
}
classes = this.getDayClasses(date)
classes.unshift('fc-day-top')
if (this.cellWeekNumbersVisible) {
// To determine the day of week number change under ISO, we cannot
// rely on moment.js methods such as firstDayOfWeek() or weekday(),
// because they rely on the locale's dow (possibly overridden by
// our firstDay option), which may not be Monday. We cannot change
// dow, because that would affect the calendar start day as well.
if (date._locale._fullCalendar_weekCalc === 'ISO') {
weekCalcFirstDoW = 1 // Monday by ISO 8601 definition
} else {
weekCalcFirstDoW = date._locale.firstDayOfWeek()
}
}
html += ''
if (this.cellWeekNumbersVisible && (date.day() === weekCalcFirstDoW)) {
html += view.buildGotoAnchorHtml(
{ date: date, type: 'week' },
{ 'class': 'fc-week-number' },
date.format('w') // inner HTML
)
}
if (isDayNumberVisible) {
html += view.buildGotoAnchorHtml(
date,
{ 'class': 'fc-day-number' },
date.format('D') // inner HTML
)
}
html += ' | '
return html
}
/* Hit System
------------------------------------------------------------------------------------------------------------------*/
prepareHits() {
this.colCoordCache.build()
this.rowCoordCache.build()
this.rowCoordCache.bottoms[this.rowCnt - 1] += this.bottomCoordPadding // hack
}
releaseHits() {
this.colCoordCache.clear()
this.rowCoordCache.clear()
}
queryHit(leftOffset, topOffset) {
if (this.colCoordCache.isLeftInBounds(leftOffset) && this.rowCoordCache.isTopInBounds(topOffset)) {
let col = this.colCoordCache.getHorizontalIndex(leftOffset)
let row = this.rowCoordCache.getVerticalIndex(topOffset)
if (row != null && col != null) {
return this.getCellHit(row, col)
}
}
}
getHitFootprint(hit) {
let range = this.getCellRange(hit.row, hit.col)
return new ComponentFootprint(
new UnzonedRange(range.start, range.end),
true // all-day?
)
}
getHitEl(hit) {
return this.getCellEl(hit.row, hit.col)
}
/* Cell System
------------------------------------------------------------------------------------------------------------------*/
// FYI: the first column is the leftmost column, regardless of date
getCellHit(row, col): any {
return {
row: row,
col: col,
component: this, // needed unfortunately :(
left: this.colCoordCache.getLeftOffset(col),
right: this.colCoordCache.getRightOffset(col),
top: this.rowCoordCache.getTopOffset(row),
bottom: this.rowCoordCache.getBottomOffset(row)
}
}
getCellEl(row, col) {
return this.cellEls.eq(row * this.colCnt + col)
}
/* Event Rendering
------------------------------------------------------------------------------------------------------------------*/
// Unrenders all events currently rendered on the grid
executeEventUnrender() {
this.removeSegPopover() // removes the "more.." events popover
super.executeEventUnrender()
}
// Retrieves all rendered segment objects currently rendered on the grid
getOwnEventSegs() {
// append the segments from the "more..." popover
return super.getOwnEventSegs().concat(this.popoverSegs || [])
}
/* Event Drag Visualization
------------------------------------------------------------------------------------------------------------------*/
// Renders a visual indication of an event or external element being dragged.
// `eventLocation` has zoned start and end (optional)
renderDrag(eventFootprints, seg, isTouch) {
let i
for (i = 0; i < eventFootprints.length; i++) {
this.renderHighlight(eventFootprints[i].componentFootprint)
}
// render drags from OTHER components as helpers
if (eventFootprints.length && seg && seg.component !== this) {
this.helperRenderer.renderEventDraggingFootprints(eventFootprints, seg, isTouch)
return true // signal helpers rendered
}
}
// Unrenders any visual indication of a hovering event
unrenderDrag() {
this.unrenderHighlight()
this.helperRenderer.unrender()
}
/* Event Resize Visualization
------------------------------------------------------------------------------------------------------------------*/
// Renders a visual indication of an event being resized
renderEventResize(eventFootprints, seg, isTouch) {
let i
for (i = 0; i < eventFootprints.length; i++) {
this.renderHighlight(eventFootprints[i].componentFootprint)
}
this.helperRenderer.renderEventResizingFootprints(eventFootprints, seg, isTouch)
}
// Unrenders a visual indication of an event being resized
unrenderEventResize() {
this.unrenderHighlight()
this.helperRenderer.unrender()
}
/* More+ Link Popover
------------------------------------------------------------------------------------------------------------------*/
removeSegPopover() {
if (this.segPopover) {
this.segPopover.hide() // in handler, will call segPopover's removeElement
}
}
// Limits the number of "levels" (vertically stacking layers of events) for each row of the grid.
// `levelLimit` can be false (don't limit), a number, or true (should be computed).
limitRows(levelLimit) {
let rowStructs = this.eventRenderer.rowStructs || []
let row // row #
let rowLevelLimit
for (row = 0; row < rowStructs.length; row++) {
this.unlimitRow(row)
if (!levelLimit) {
rowLevelLimit = false
} else if (typeof levelLimit === 'number') {
rowLevelLimit = levelLimit
} else {
rowLevelLimit = this.computeRowLevelLimit(row)
}
if (rowLevelLimit !== false) {
this.limitRow(row, rowLevelLimit)
}
}
}
// Computes the number of levels a row will accomodate without going outside its bounds.
// Assumes the row is "rigid" (maintains a constant height regardless of what is inside).
// `row` is the row number.
computeRowLevelLimit(row): (number | false) {
let rowEl = this.rowEls.eq(row) // the containing "fake" row div
let rowHeight = rowEl.height() // TODO: cache somehow?
let trEls = this.eventRenderer.rowStructs[row].tbodyEl.children()
let i
let trEl
let trHeight
function iterInnerHeights(i, childNode) {
trHeight = Math.max(trHeight, $(childNode).outerHeight())
}
// Reveal one level at a time and stop when we find one out of bounds
for (i = 0; i < trEls.length; i++) {
trEl = trEls.eq(i).removeClass('fc-limited') // reset to original state (reveal)
// with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell,
// so instead, find the tallest inner content element.
trHeight = 0
trEl.find('> td > :first-child').each(iterInnerHeights)
if (trEl.position().top + trHeight > rowHeight) {
return i
}
}
return false // should not limit at all
}
// Limits the given grid row to the maximum number of levels and injects "more" links if necessary.
// `row` is the row number.
// `levelLimit` is a number for the maximum (inclusive) number of levels allowed.
limitRow(row, levelLimit) {
let rowStruct = this.eventRenderer.rowStructs[row]
let moreNodes = [] // array of "more" links and | DOM nodes
let col = 0 // col #, left-to-right (not chronologically)
let levelSegs // array of segment objects in the last allowable level, ordered left-to-right
let cellMatrix // a matrix (by level, then column) of all | jQuery elements in the row
let limitedNodes // array of temporarily hidden level |
and segment | DOM nodes
let i
let seg
let segsBelow // array of segment objects below `seg` in the current `col`
let totalSegsBelow // total number of segments below `seg` in any of the columns `seg` occupies
let colSegsBelow // array of segment arrays, below seg, one for each column (offset from segs's first column)
let td
let rowspan
let segMoreNodes // array of "more" | cells that will stand-in for the current seg's cell
let j
let moreTd
let moreWrap
let moreLink
// Iterates through empty level cells and places "more" links inside if need be
let emptyCellsUntil = (endCol) => { // goes from current `col` to `endCol`
while (col < endCol) {
segsBelow = this.getCellSegs(row, col, levelLimit)
if (segsBelow.length) {
td = cellMatrix[levelLimit - 1][col]
moreLink = this.renderMoreLink(row, col, segsBelow)
moreWrap = $('').append(moreLink)
td.append(moreWrap)
moreNodes.push(moreWrap[0])
}
col++
}
}
if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit?
levelSegs = rowStruct.segLevels[levelLimit - 1]
cellMatrix = rowStruct.cellMatrix
limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level |
elements past the limit
.addClass('fc-limited').get() // hide elements and get a simple DOM-nodes array
// iterate though segments in the last allowable level
for (i = 0; i < levelSegs.length; i++) {
seg = levelSegs[i]
emptyCellsUntil(seg.leftCol) // process empty cells before the segment
// determine *all* segments below `seg` that occupy the same columns
colSegsBelow = []
totalSegsBelow = 0
while (col <= seg.rightCol) {
segsBelow = this.getCellSegs(row, col, levelLimit)
colSegsBelow.push(segsBelow)
totalSegsBelow += segsBelow.length
col++
}
if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links?
td = cellMatrix[levelLimit - 1][seg.leftCol] // the segment's parent cell
rowspan = td.attr('rowspan') || 1
segMoreNodes = []
// make a replacement | for each column the segment occupies. will be one for each colspan
for (j = 0; j < colSegsBelow.length; j++) {
moreTd = $(' | | ').attr('rowspan', rowspan)
segsBelow = colSegsBelow[j]
moreLink = this.renderMoreLink(
row,
seg.leftCol + j,
[ seg ].concat(segsBelow) // count seg as hidden too
)
moreWrap = $('').append(moreLink)
moreTd.append(moreWrap)
segMoreNodes.push(moreTd[0])
moreNodes.push(moreTd[0])
}
td.addClass('fc-limited').after($(segMoreNodes)) // hide original and inject replacements
limitedNodes.push(td[0])
}
}
emptyCellsUntil(this.colCnt) // finish off the level
rowStruct.moreEls = $(moreNodes) // for easy undoing later
rowStruct.limitedEls = $(limitedNodes) // for easy undoing later
}
}
// Reveals all levels and removes all "more"-related elements for a grid's row.
// `row` is a row number.
unlimitRow(row) {
let rowStruct = this.eventRenderer.rowStructs[row]
if (rowStruct.moreEls) {
rowStruct.moreEls.remove()
rowStruct.moreEls = null
}
if (rowStruct.limitedEls) {
rowStruct.limitedEls.removeClass('fc-limited')
rowStruct.limitedEls = null
}
}
// Renders an element that represents hidden event element for a cell.
// Responsible for attaching click handler as well.
renderMoreLink(row, col, hiddenSegs) {
let view = this.view
return $('')
.text(
this.getMoreLinkText(hiddenSegs.length)
)
.on('click', (ev) => {
let clickOption = this.opt('eventLimitClick')
let date = this.getCellDate(row, col)
let moreEl = $(ev.currentTarget)
let dayEl = this.getCellEl(row, col)
let allSegs = this.getCellSegs(row, col)
// rescope the segments to be within the cell's date
let reslicedAllSegs = this.resliceDaySegs(allSegs, date)
let reslicedHiddenSegs = this.resliceDaySegs(hiddenSegs, date)
if (typeof clickOption === 'function') {
// the returned value can be an atomic option
clickOption = this.publiclyTrigger('eventLimitClick', {
context: view,
args: [
{
date: date.clone(),
dayEl: dayEl,
moreEl: moreEl,
segs: reslicedAllSegs,
hiddenSegs: reslicedHiddenSegs
},
ev,
view
]
})
}
if (clickOption === 'popover') {
this.showSegPopover(row, col, moreEl, reslicedAllSegs)
} else if (typeof clickOption === 'string') { // a view name
view.calendar.zoomTo(date, clickOption)
}
})
}
// Reveals the popover that displays all events within a cell
showSegPopover(row, col, moreLink, segs) {
let view = this.view
let moreWrap = moreLink.parent() // the wrapper around the
let topEl // the element we want to match the top coordinate of
let options
if (this.rowCnt === 1) {
topEl = view.el // will cause the popover to cover any sort of header
} else {
topEl = this.rowEls.eq(row) // will align with top of row
}
options = {
className: 'fc-more-popover ' + view.calendar.theme.getClass('popover'),
content: this.renderSegPopoverContent(row, col, segs),
parentEl: view.el, // attach to root of view. guarantees outside of scrollbars.
top: topEl.offset().top,
autoHide: true, // when the user clicks elsewhere, hide the popover
viewportConstrain: this.opt('popoverViewportConstrain'),
hide: () => {
// kill everything when the popover is hidden
// notify events to be removed
if (this.popoverSegs) {
this.triggerBeforeEventSegsDestroyed(this.popoverSegs)
}
this.segPopover.removeElement()
this.segPopover = null
this.popoverSegs = null
}
}
// Determine horizontal coordinate.
// We use the moreWrap instead of the to avoid border confusion.
if (this.isRTL) {
options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1 // +1 to be over cell border
} else {
options.left = moreWrap.offset().left - 1 // -1 to be over cell border
}
this.segPopover = new Popover(options)
this.segPopover.show()
// the popover doesn't live within the grid's container element, and thus won't get the event
// delegated-handlers for free. attach event-related handlers to the popover.
this.bindAllSegHandlersToEl(this.segPopover.el)
this.triggerAfterEventSegsRendered(segs)
}
// Builds the inner DOM contents of the segment popover
renderSegPopoverContent(row, col, segs) {
let view = this.view
let theme = view.calendar.theme
let title = this.getCellDate(row, col).format(this.opt('dayPopoverFormat'))
let content = $(
'' +
''
)
let segContainer = content.find('.fc-event-container')
let i
// render each seg's `el` and only return the visible segs
segs = this.eventRenderer.renderFgSegEls(segs, true) // disableResizing=true
this.popoverSegs = segs
for (i = 0; i < segs.length; i++) {
// because segments in the popover are not part of a grid coordinate system, provide a hint to any
// grids that want to do drag-n-drop about which cell it came from
this.hitsNeeded()
segs[i].hit = this.getCellHit(row, col)
this.hitsNotNeeded()
segContainer.append(segs[i].el)
}
return content
}
// Given the events within an array of segment objects, reslice them to be in a single day
resliceDaySegs(segs, dayDate) {
let dayStart = dayDate.clone()
let dayEnd = dayStart.clone().add(1, 'days')
let dayRange = new UnzonedRange(dayStart, dayEnd)
let newSegs = []
let i
let seg
let slicedRange
for (i = 0; i < segs.length; i++) {
seg = segs[i]
slicedRange = seg.footprint.componentFootprint.unzonedRange.intersect(dayRange)
if (slicedRange) {
newSegs.push(
$.extend({}, seg, {
footprint: new EventFootprint(
new ComponentFootprint(
slicedRange,
seg.footprint.componentFootprint.isAllDay
),
seg.footprint.eventDef,
seg.footprint.eventInstance
),
isStart: seg.isStart && slicedRange.isStart,
isEnd: seg.isEnd && slicedRange.isEnd
})
)
}
}
// force an order because eventsToSegs doesn't guarantee one
// TODO: research if still needed
this.eventRenderer.sortEventSegs(newSegs)
return newSegs
}
// Generates the text that should be inside a "more" link, given the number of events it represents
getMoreLinkText(num) {
let opt = this.opt('eventLimitText')
if (typeof opt === 'function') {
return opt(num)
} else {
return '+' + num + ' ' + opt
}
}
// Returns segments within a given cell.
// If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs.
getCellSegs(row, col, startLevel?) {
let segMatrix = this.eventRenderer.rowStructs[row].segMatrix
let level = startLevel || 0
let segs = []
let seg
while (level < segMatrix.length) {
seg = segMatrix[level][col]
if (seg) {
segs.push(seg)
}
level++
}
return segs
}
}
DayGrid.prototype.eventRendererClass = DayGridEventRenderer
DayGrid.prototype.businessHourRendererClass = BusinessHourRenderer
DayGrid.prototype.helperRendererClass = DayGridHelperRenderer
DayGrid.prototype.fillRendererClass = DayGridFillRenderer
StandardInteractionsMixin.mixInto(DayGrid)
DayTableMixin.mixInto(DayGrid)
| |