diff options
Diffstat (limited to 'comm/calendar/base/content/calendar-multiday-view.js')
-rw-r--r-- | comm/calendar/base/content/calendar-multiday-view.js | 3512 |
1 files changed, 3512 insertions, 0 deletions
diff --git a/comm/calendar/base/content/calendar-multiday-view.js b/comm/calendar/base/content/calendar-multiday-view.js new file mode 100644 index 0000000000..041c8ae335 --- /dev/null +++ b/comm/calendar/base/content/calendar-multiday-view.js @@ -0,0 +1,3512 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* import-globals-from widgets/mouseoverPreviews.js */ +/* import-globals-from calendar-ui-utils.js */ + +/* global calendarNavigationBar, currentView, gCurrentMode, getSelectedCalendar, + invokeEventDragSession, MozElements, MozXULElement, timeIndicator */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + const MINUTES_IN_DAY = 24 * 60; + + /** + * Get the nearest or next snap point for the given minute. The set of snap + * points is given by `n * snapInterval`, where `n` is some integer. + * + * @param {number} minute - The minute to snap. + * @param {number} snapInterval - The integer number of minutes between snap + * points. + * @param {"nearest","forward","backward"} [direction="nearest"] - Where to + * find the snap point. "nearest" will return the closest snap point, + * "forward" will return the closest snap point that is greater (and not + * equal), and "backward" will return the closest snap point that is lower + * (and not equal). + * + * @returns {number} - The nearest snap point. + */ + function snapMinute(minute, snapInterval, direction = "nearest") { + switch (direction) { + case "forward": + return Math.floor((minute + snapInterval) / snapInterval) * snapInterval; + case "backward": + return Math.ceil((minute - snapInterval) / snapInterval) * snapInterval; + case "nearest": + return Math.round(minute / snapInterval) * snapInterval; + default: + throw new RangeError(`"${direction}" is not one of the allowed values for the direction`); + } + } + + /** + * Determine whether the given event item can be edited by the user. + * + * @param {calItemBase} eventItem - The event item. + * + * @returns {boolean} - Whether the given event can be edited by the user. + */ + function canEditEventItem(eventItem) { + return ( + cal.acl.isCalendarWritable(eventItem.calendar) && + cal.acl.userCanModifyItem(eventItem) && + !( + eventItem.calendar instanceof Ci.calISchedulingSupport && + eventItem.calendar.isInvitation(eventItem) + ) && + eventItem.calendar.getProperty("capabilities.events.supported") !== false + ); + } + + /** + * The MozCalendarEventColumn widget used for displaying event boxes in one column per day. + * It is used to make the week view layout in the calendar. It manages the layout of the + * events given via add/deleteEvent. + */ + class MozCalendarEventColumn extends MozXULElement { + static get inheritedAttributes() { + return { + ".multiday-events-list": "context", + ".timeIndicator": "orient", + }; + } + + /** + * The background hour box elements this event column owns, ordered and + * indexed by their starting hour. + * + * @type {Element[]} + */ + hourBoxes = []; + + /** + * The date of the day this event column represents. + * + * @type {calIDateTime} + */ + date; + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + this.appendChild( + MozXULElement.parseXULToFragment(` + <stack class="multiday-column-box-stack" flex="1"> + <html:div class="multiday-hour-box-container"></html:div> + <html:ol class="multiday-events-list"></html:ol> + <box class="timeIndicator" hidden="true"/> + <box class="fgdragcontainer" flex="1"> + <box class="fgdragspacer"> + <spacer flex="1"/> + <label class="fgdragbox-label fgdragbox-startlabel"/> + </box> + <box class="fgdragbox"/> + <label class="fgdragbox-label fgdragbox-endlabel"/> + </box> + </stack> + <calendar-event-box hidden="true"/> + `) + ); + this.hourBoxContainer = this.querySelector(".multiday-hour-box-container"); + for (let hour = 0; hour < 24; hour++) { + let hourBox = document.createElement("div"); + hourBox.classList.add("multiday-hour-box"); + this.hourBoxContainer.appendChild(hourBox); + this.hourBoxes.push(hourBox); + } + + this.eventsListElement = this.querySelector(".multiday-events-list"); + + this.addEventListener("dblclick", event => { + if (event.button != 0) { + return; + } + + if (this.calendarView.controller) { + event.stopPropagation(); + this.calendarView.controller.createNewEvent(null, this.getMouseDateTime(event), null); + } + }); + + this.addEventListener("click", event => { + if (event.button != 0 || event.ctrlKey || event.metaKey) { + return; + } + this.calendarView.setSelectedItems([]); + this.focus(); + }); + + // Mouse down handler, in empty event column regions. Starts sweeping out a new event. + this.addEventListener("mousedown", event => { + // Select this column. + this.calendarView.selectedDay = this.date; + + // If the selected calendar is readOnly, we don't want any sweeping. + let calendar = getSelectedCalendar(); + if ( + !cal.acl.isCalendarWritable(calendar) || + calendar.getProperty("capabilities.events.supported") === false + ) { + return; + } + + if (event.button == 2) { + // Set a selected datetime for the context menu. + this.calendarView.selectedDateTime = this.getMouseDateTime(event); + return; + } + // Only start sweeping out an event if the left button was clicked. + if (event.button != 0) { + return; + } + + this.mDragState = { + origColumn: this, + dragType: "new", + mouseMinuteOffset: 0, + offset: null, + shadows: null, + limitStartMin: null, + limitEndMin: null, + jumpedColumns: 0, + }; + + // Snap interval: 15 minutes or 1 minute if modifier key is pressed. + this.mDragState.origMin = snapMinute( + this.getMouseMinute(event), + event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey ? 1 : 15 + ); + + if (this.getAttribute("orient") == "vertical") { + this.mDragState.origLoc = event.clientY; + this.mDragState.limitEndMin = this.mDragState.origMin; + this.mDragState.limitStartMin = this.mDragState.origMin; + this.fgboxes.dragspacer.setAttribute( + "height", + this.mDragState.origMin * this.pixelsPerMinute + ); + } else { + this.mDragState.origLoc = event.clientX; + this.fgboxes.dragspacer.setAttribute( + "width", + this.mDragState.origMin * this.pixelsPerMinute + ); + } + + document.calendarEventColumnDragging = this; + + window.addEventListener("mousemove", this.onEventSweepMouseMove); + window.addEventListener("mouseup", this.onEventSweepMouseUp); + window.addEventListener("keypress", this.onEventSweepKeypress); + }); + + /** + * An internal collection of data for events. + * + * @typedef {object} EventData + * @property {calItemBase} eventItem - The event item. + * @property {Element} element - The displayed event in this column. + * @property {boolean} selected - Whether the event is selected. + * @property {boolean} needsUpdate - True whilst the eventItem has changed + * and we are still pending updating the 'element' property. + */ + /** + * Event data for all the events displayed in this column. + * + * @type {Map<string, EventData} - A map from an event item's hashId to + * its data. + */ + this.eventDataMap = new Map(); + + this.mCalendarView = null; + + this.mDragState = null; + + this.mLayoutBatchCount = 0; + + // Since we'll often be getting many events in rapid succession, this + // timer helps ensure that we don't re-compute the event map too many + // times in a short interval, and therefore improves performance. + this.mEventMapTimeout = null; + + // Whether the next added event should be created in the editing state. + this.newEventNeedsEditing = false; + // The hashId of the event we should set to editing in the next relayout. + this.eventToEdit = null; + + this.mSelected = false; + + this.mFgboxes = null; + + this.initializeAttributeInheritance(); + } + + /** + * The number of pixels that a one minute duration should occupy in the + * column. + * + * @type {number} + */ + set pixelsPerMinute(val) { + this._pixelsPerMinute = val; + this.relayout(); + } + + get pixelsPerMinute() { + return this._pixelsPerMinute; + } + + set calendarView(val) { + this.mCalendarView = val; + } + + get calendarView() { + return this.mCalendarView; + } + + get fgboxes() { + if (this.mFgboxes == null) { + this.mFgboxes = { + box: this.querySelector(".fgdragcontainer"), + dragbox: this.querySelector(".fgdragbox"), + dragspacer: this.querySelector(".fgdragspacer"), + startlabel: this.querySelector(".fgdragbox-startlabel"), + endlabel: this.querySelector(".fgdragbox-endlabel"), + }; + } + return this.mFgboxes; + } + + get timeIndicatorBox() { + return this.querySelector(".timeIndicator"); + } + + get events() { + return this.methods; + } + + /** + * Set whether the calendar-event-box element for the given event item + * should be displayed as selected or unselected. + * + * @param {calItemBase} eventItem - The event item. + * @param {boolean} select - Whether to show the corresponding event element + * as selected. + */ + selectEvent(eventItem, select) { + let data = this.eventDataMap.get(eventItem.hashId); + if (!data) { + return; + } + data.selected = select; + if (data.element) { + // There is a small window between an event item being added and it + // actually having an element. If it doesn't have an element yet, it + // will be selected on its creation instead. + data.element.selected = select; + } + } + + /** + * Return the displayed calendar-event-box element for the given event item. + * + * @param {calItemBase} eventItem - The event item. + * + * @returns {Element} - The corresponding element, or undefined if none. + */ + findElementForEventItem(eventItem) { + return this.eventDataMap.get(eventItem.hashId)?.element; + } + + /** + * Return all the event items that are displayed in this columns. + * + * @returns {calItemBase[]} - An array of all the displayed event items. + */ + getAllEventItems() { + return Array.from(this.eventDataMap.values(), data => data.eventItem); + } + + startLayoutBatchChange() { + this.mLayoutBatchCount++; + } + + endLayoutBatchChange() { + this.mLayoutBatchCount--; + if (this.mLayoutBatchCount == 0) { + this.relayout(); + } + } + + setAttribute(attr, val) { + // this should be done using lookupMethod(), see bug 286629 + let ret = super.setAttribute(attr, val); + + if (attr == "orient" && this.getAttribute("orient") != val) { + this.relayout(); + } + + return ret; + } + + /** + * Create or update a displayed calendar-event-box element for the given + * event item. + * + * @param {calItemBase} eventItem - The event item to create or update an + * element for. + */ + addEvent(eventItem) { + let eventData = this.eventDataMap.get(eventItem.hashId); + if (!eventData) { + // New event with no pre-existing data. + eventData = { selected: false }; + this.eventDataMap.set(eventItem.hashId, eventData); + } + eventData.needsUpdate = true; + + // We set the eventItem property here, the rest will be updated in + // relayout(). + // NOTE: If we already have an event with the given hashId, then the + // eventData.element will still refer to the previous display of the event + // until we call relayout(). + eventData.eventItem = eventItem; + + if (this.mEventMapTimeout) { + clearTimeout(this.mEventMapTimeout); + } + + if (this.newEventNeedsEditing) { + this.eventToEdit = eventItem.hashId; + this.newEventNeedsEditing = false; + } + + this.mEventMapTimeout = setTimeout(() => this.relayout(), 5); + } + + /** + * Remove the displayed calendar-event-box element for the given event item + * from this column + * + * @param {calItemBase} eventItem - The event item to remove the element of. + */ + deleteEvent(eventItem) { + if (this.eventDataMap.delete(eventItem.hashId)) { + this.relayout(); + } + } + + _clearElements() { + while (this.eventsListElement.hasChildNodes()) { + this.eventsListElement.lastChild.remove(); + } + } + + /** + * Clear the column of all events. + */ + clear() { + this._clearElements(); + this.eventDataMap.clear(); + } + + relayout() { + if (this.mLayoutBatchCount > 0) { + return; + } + this._clearElements(); + + let orient = this.getAttribute("orient"); + + let configBox = this.querySelector("calendar-event-box"); + configBox.removeAttribute("hidden"); + let minSize = configBox.getOptimalMinSize(orient); + configBox.setAttribute("hidden", "true"); + // The minimum event duration in minutes that would give at least the + // desired minSize in the layout. + let minDuration = Math.ceil(minSize / this.pixelsPerMinute); + + let dayPx = `${MINUTES_IN_DAY * this.pixelsPerMinute}px`; + if (orient == "vertical") { + this.hourBoxContainer.style.height = dayPx; + this.hourBoxContainer.style.width = null; + } else { + this.hourBoxContainer.style.width = dayPx; + this.hourBoxContainer.style.height = null; + } + + // 'fgbox' is used for dragging events. + this.fgboxes.box.setAttribute("orient", orient); + this.querySelector(".fgdragspacer").setAttribute("orient", orient); + + for (let eventData of this.eventDataMap.values()) { + if (!eventData.needsUpdate) { + continue; + } + eventData.needsUpdate = false; + // Create a new wrapper. + let eventElement = document.createElement("li"); + eventElement.classList.add("multiday-event-listitem"); + // Set up the event box. + let eventBox = document.createXULElement("calendar-event-box"); + eventElement.appendChild(eventBox); + + // Trigger connectedCallback + this.eventsListElement.appendChild(eventElement); + + eventBox.setAttribute( + "context", + this.getAttribute("item-context") || this.getAttribute("context") + ); + + eventBox.calendarView = this.calendarView; + eventBox.occurrence = eventData.eventItem; + eventBox.parentColumn = this; + // An event item can technically be 'selected' between a call to + // addEvent and this method (because of the setTimeout). E.g. clicking + // the event in the unifinder tree will select the item through + // selectEvent. If the element wasn't yet created in that method, we set + // the selected status here as well. + // + // Similarly, if an event has the same hashId, we maintain its + // selection. + // NOTE: In this latter case we are relying on the fact that + // eventData.element.selected is never out of sync with + // eventData.selected. + eventBox.selected = eventData.selected; + eventData.element = eventBox; + + // Remove the element to be added again later. + eventElement.remove(); + } + + let eventLayoutList = this.computeEventLayoutInfo(minDuration); + + for (let eventInfo of eventLayoutList) { + // Note that we store the calendar-event-box in the eventInfo, so we + // grab its parent to get the wrapper list item. + // NOTE: This may be a newly created element or a non-updated element + // that was removed from the eventsListElement in _clearElements. We + // still hold a reference to it, so we can re-add it in the new ordering + // and change its dimensions. + let eventElement = eventInfo.element.parentNode; + // FIXME: offset and length should be in % of parent's dimension, so we + // can avoid pixelsPerMinute. + let offset = `${eventInfo.start * this.pixelsPerMinute}px`; + let length = `${(eventInfo.end - eventInfo.start) * this.pixelsPerMinute}px`; + let secondaryOffset = `${eventInfo.secondaryOffset * 100}%`; + let secondaryLength = `${eventInfo.secondaryLength * 100}%`; + if (orient == "vertical") { + eventElement.style.height = length; + eventElement.style.width = secondaryLength; + eventElement.style.insetBlockStart = offset; + eventElement.style.insetInlineStart = secondaryOffset; + } else { + eventElement.style.width = length; + eventElement.style.height = secondaryLength; + eventElement.style.insetInlineStart = offset; + eventElement.style.insetBlockStart = secondaryOffset; + } + this.eventsListElement.appendChild(eventElement); + } + + let boxToEdit = this.eventDataMap.get(this.eventToEdit)?.element; + if (boxToEdit) { + boxToEdit.startEditing(); + } + this.eventToEdit = null; + } + + /** + * Layout information for displaying an event in the calendar column. The + * calendar column has two dimensions: a primary-dimension, in minutes, + * that runs from the start of the day to the end of the day; and a + * secondary-dimension which runs from 0 to 1. This object describes how + * an event can be placed on these axes. + * + * @typedef {object} EventLayoutInfo + * @property {MozCalendarEventBox} element - The displayed event. + * @property {number} start - The number of minutes from the start of this + * column's day to when the event should start. + * @property {number} end - The number of minutes from the start of this + * column's day to when the event ends. + * @property {number} secondaryOffset - The position of the event on the + * secondary axis (between 0 and 1). + * @property {number} secondaryLength - The length of the event on the + * secondary axis (between 0 and 1). + */ + /** + * Get an ordered list of events and their layout information. The list is + * ordered relative to the event's layout. + * + * @param {number} minDuration - The minimum number of minutes that an event + * should be *shown* to last. This should be large enough to ensure that + * events are readable in the layout. + * + * @returns {EventLayoutInfo[]} - An ordered list of event layout + * information. + */ + computeEventLayoutInfo(minDuration) { + if (!this.eventDataMap.size) { + return []; + } + + function sortByStart(aEventInfo, bEventInfo) { + // If you pass in tasks without both entry and due dates, I will + // kill you. + let startComparison = aEventInfo.startDate.compare(bEventInfo.startDate); + if (startComparison == 0) { + // If the items start at the same time, return the longer one + // first. + return bEventInfo.endDate.compare(aEventInfo.endDate); + } + return startComparison; + } + + // Construct the ordered list of EventLayoutInfo objects that we will + // eventually return. + // To begin, we construct the objects with a 'startDate' and 'endDate' + // properties, as opposed to using minutes from the start of the day + // because we want to sort the events relative to their absolute start + // times. + let eventList = Array.from(this.eventDataMap.values(), eventData => { + let element = eventData.element; + let { startDate, endDate, startMinute, endMinute } = element.updateRelativeStartEndDates( + this.date + ); + // If there is no startDate, we use the element's endDate for both the + // start and the end times. Similarly if there is no endDate. Such items + // will automatically have the minimum duration. + if (!startDate) { + startDate = endDate; + startMinute = endMinute; + } else if (!endDate) { + endDate = startDate; + endMinute = startMinute; + } + // Any events that start or end on a different day are clipped to the + // start/end minutes of this day instead. + let start = Math.max(startMinute, 0); + // NOTE: The end can overflow the end of the day due to the minDuration. + let end = Math.max(start + minDuration, Math.min(endMinute, MINUTES_IN_DAY)); + return { element, startDate, endDate, start, end }; + }); + eventList.sort(sortByStart); + + // Some Events in the calendar column will overlap in time. When they do, + // we want them to share the horizontal space (assuming the column is + // vertical). + // + // To do this, we split the events into Blocks, each of which contains a + // variable number of Columns, each of which contain non-overlapping + // Events. + // + // Note that the end time of one event is equal to the start time of + // another, we consider them non-overlapping. + // + // We choose each Block to form a continuous block of time in the + // calendar column. Specifically, two Events are in the same Block if and + // only if there exists some sequence of pairwise overlapping Events that + // includes them both. This ensures that no Block will overlap another + // Block, and each contains the least number of Events possible. + // + // Each Column will share the same horizontal width, and will be placed + // adjacent to each other. + // + // Note that each Block may have a different number of Columns, and then + // may not share a common factor, so the Columns may not line up in the + // view. + + // All the event Blocks in this calendar column, ordered by their start + // time. Each Block will be an array of Columns, which will in turn be an + // array of Events. + let allEventBlocks = []; + // The current Block. + let blockColumns = []; + let blockEnd = eventList[0].end; + + for (let eventInfo of eventList) { + let start = eventInfo.start; + if (blockColumns.length && start >= blockEnd) { + // There is a gap between this Event and the end of the Block. We also + // know from the ordering of eventList that all other Events start at + // the same time or later. So there are no more Events that can be + // added to this Block. So we finish it and start a new one. + allEventBlocks.push(blockColumns); + blockColumns = []; + } + + if (eventInfo.end > blockEnd) { + blockEnd = eventInfo.end; + } + + // Find the earliest Column that the Event fits in. + let foundCol = false; + for (let column of blockColumns) { + // We know from the ordering of eventList that all Events already in a + // Column have a start time that is equal to or earlier than this + // Event's start time. Therefore, in order for this Event to not + // overlap anything else in this Column, it must have a start time + // that is later than or equal to the end time of the last Event in + // this column. + let colEnd = column[column.length - 1].end; + if (start >= colEnd) { + // It fits in this Column, so we push it to the end (preserving the + // eventList ordering within the Column). + column.push(eventInfo); + foundCol = true; + break; + } + } + + if (!foundCol) { + // This Event doesn't fit in any column, so we create a new one. + blockColumns.push([eventInfo]); + } + } + if (blockColumns.length) { + allEventBlocks.push(blockColumns); + } + + for (let blockColumns of allEventBlocks) { + let totalCols = blockColumns.length; + for (let colIndex = 0; colIndex < totalCols; colIndex++) { + for (let eventInfo of blockColumns[colIndex]) { + if (eventInfo.processed) { + // Already processed this Event in an earlier Column. + continue; + } + let { start, end } = eventInfo; + let colSpan = 1; + // Currently, the Event is only contained in one Column. We want to + // first try and stretch it across several continuous columns. + // For this Event, we go through each later Column one by one and + // see if there is a gap in it that it can fit in. + // Note, we only look forward in the Columns because we already know + // that we did not fit in the previous Columns. + for ( + let neighbourColIndex = colIndex + 1; + neighbourColIndex < totalCols; + neighbourColIndex++ + ) { + let neighbourColumn = blockColumns[neighbourColIndex]; + // Test if this Event overlaps any of the other Events in the + // neighbouring Column. + let overlapsCol = false; + let indexInCol; + for (indexInCol = 0; indexInCol < neighbourColumn.length; indexInCol++) { + let otherEventInfo = neighbourColumn[indexInCol]; + if (end <= otherEventInfo.start) { + // The end of this Event is before or equal to the start of + // the other Event, so it cannot overlap. + // Moreover, the rest of the Events in this neighbouring + // Column have a later or equal start time, so we know that + // this Event cannot overlap any of them. So we can break + // early. + // We also know that indexInCol now points to the *first* + // Event in this neighbouring Column that starts after this + // Event. + break; + } else if (start < otherEventInfo.end) { + // The end of this Event is after the start of the other + // Event, and the start of this Event is before the end of + // the other Event. So they must overlap. + overlapsCol = true; + break; + } + } + if (overlapsCol) { + // An Event must span continuously across Columns, so we must + // break. + break; + } + colSpan++; + // Add this Event to the Column. Note that indexInCol points to + // the *first* other Event that is later than this Event, or + // points to the end of the Column. So we place ourselves there to + // preserve the ordering. + neighbourColumn.splice(indexInCol, 0, eventInfo); + } + eventInfo.processed = true; + eventInfo.secondaryOffset = colIndex / totalCols; + eventInfo.secondaryLength = colSpan / totalCols; + } + } + } + return eventList; + } + + /** + * Get information about which columns, relative to this column, are + * covered by the given time interval. + * + * @param {number} start - The starting time of the interval, in minutes + * from the start of this column's day. Should be negative for times on + * previous days. This must be on this column's day or earlier. + * @param {number} end - The ending time of the interval, in minutes from + * the start of this column's day. This can go beyond the end of this day. + * This must be greater than 'start' and on this column's day or later. + * + * @returns {object} - Data determining which columns are covered by the + * interval. Each column that is in the given range is covered from the + * start of the day to the end, apart from the first and last columns. + * @property {number} shadows - The number of columns that have some cover. + * @property {number} offset - The number of columns before this column that + * have some cover. For example, if 'start' is the day before, this is 1. + * @property {number} startMin - The starting time of the time interval, in + * minutes relative to the start of the first column's day. + * @property {number} endMin - The ending time of the time interval, in + * minutes relative to the start of the last column's day. + */ + getShadowElements(start, end) { + let shadows = 1; + let offset = 0; + let startMin; + if (start < 0) { + offset = Math.ceil(Math.abs(start) / MINUTES_IN_DAY); + shadows += offset; + let remainder = Math.abs(start) % MINUTES_IN_DAY; + startMin = remainder ? MINUTES_IN_DAY - remainder : 0; + } else { + startMin = start; + } + shadows += Math.floor(end / MINUTES_IN_DAY); + return { shadows, offset, startMin, endMin: end % MINUTES_IN_DAY }; + } + + /** + * Clear a dragging sequence that is owned by this column. + */ + clearDragging() { + for (let col of this.calendarView.getEventColumns()) { + col.fgboxes.dragbox.removeAttribute("dragging"); + col.fgboxes.box.removeAttribute("dragging"); + // We remove the height and width attributes as well. + // In particular, this means we won't accidentally preserve the height + // attribute if we switch to the rotated view, or the width if we + // switch back. + col.fgboxes.dragbox.removeAttribute("width"); + col.fgboxes.dragbox.removeAttribute("height"); + col.fgboxes.dragspacer.removeAttribute("width"); + col.fgboxes.dragspacer.removeAttribute("height"); + } + + window.removeEventListener("mousemove", this.onEventSweepMouseMove); + window.removeEventListener("mouseup", this.onEventSweepMouseUp); + window.removeEventListener("keypress", this.onEventSweepKeypress); + document.calendarEventColumnDragging = null; + this.mDragState = null; + } + + /** + * Update the shown drag state of all event columns in the same view using + * the mDragState of the current column. + */ + updateColumnShadows() { + let startStr; + // Tasks without Entry or Due date have a string as first label + // instead of the time. + let item = this.mDragState.dragOccurrence; + if (item?.isTodo()) { + if (!item.dueDate) { + startStr = cal.l10n.getCalString("dragLabelTasksWithOnlyEntryDate"); + } else if (!item.entryDate) { + startStr = cal.l10n.getCalString("dragLabelTasksWithOnlyDueDate"); + } + } + + let { startMin, endMin, offset, shadows } = this.mDragState; + let jsTime = new Date(); + let formatter = cal.dtz.formatter; + if (!startStr) { + jsTime.setHours(0, startMin, 0); + startStr = formatter.formatTime(cal.dtz.jsDateToDateTime(jsTime, cal.dtz.floating)); + } + jsTime.setHours(0, endMin, 0); + let endStr = formatter.formatTime(cal.dtz.jsDateToDateTime(jsTime, cal.dtz.floating)); + + let allColumns = this.calendarView.getEventColumns(); + let thisIndex = allColumns.indexOf(this); + // NOTE: startIndex and endIndex be before or after the start and end of + // the week, respectively, if the event spans multiple days. + let startIndex = thisIndex - offset; + let endIndex = startIndex + shadows - 1; + + // All columns have the same orient and pixels per minutes. + let sizeProp = this.getAttribute("orient") == "vertical" ? "height" : "width"; + let pixPerMin = this.pixelsPerMinute; + + for (let i = 0; i < allColumns.length; i++) { + let fgboxes = allColumns[i].fgboxes; + if (i == startIndex) { + fgboxes.dragbox.setAttribute("dragging", "true"); + fgboxes.box.setAttribute("dragging", "true"); + fgboxes.dragspacer.style[sizeProp] = `${startMin * pixPerMin}px`; + fgboxes.dragbox.style[sizeProp] = `${ + ((i == endIndex ? endMin : MINUTES_IN_DAY) - startMin) * pixPerMin + }px`; + fgboxes.startlabel.value = startStr; + fgboxes.endlabel.value = i == endIndex ? endStr : ""; + } else if (i == endIndex) { + fgboxes.dragbox.setAttribute("dragging", "true"); + fgboxes.box.setAttribute("dragging", "true"); + fgboxes.dragspacer.style[sizeProp] = "0"; + fgboxes.dragbox.style[sizeProp] = `${endMin * pixPerMin}px`; + fgboxes.startlabel.value = ""; + fgboxes.endlabel.value = endStr; + } else if (i > startIndex && i < endIndex) { + fgboxes.dragbox.setAttribute("dragging", "true"); + fgboxes.box.setAttribute("dragging", "true"); + fgboxes.dragspacer.style[sizeProp] = "0"; + fgboxes.dragbox.style[sizeProp] = `${MINUTES_IN_DAY * pixPerMin}px`; + fgboxes.startlabel.value = ""; + fgboxes.endlabel.value = ""; + } else { + fgboxes.dragbox.removeAttribute("dragging"); + fgboxes.box.removeAttribute("dragging"); + } + } + } + + onEventSweepKeypress(event) { + let col = document.calendarEventColumnDragging; + if (col && event.key == "Escape") { + col.clearDragging(); + } + } + + // Event sweep handlers. + onEventSweepMouseMove(event) { + let col = document.calendarEventColumnDragging; + if (!col) { + return; + } + + let dragState = col.mDragState; + + // FIXME: Use mouseenter and mouseleave to detect column changes since + // they fire when scrolling changes the mouse target, but mousemove does + // not. + let newcol = col.calendarView.findEventColumnThatContains(event.target); + // If we leave the view, then stop our internal sweeping and start a + // real drag session. Someday we need to fix the sweep to soely be a + // drag session, no sweeping. + if (dragState.dragType == "move" && !newcol) { + // Remove the drag state. + col.clearDragging(); + + let item = dragState.dragOccurrence; + + // The multiday view currently exhibits a less than optimal strategy + // in terms of item selection. items don't get automatically selected + // when clicked and dragged, as to differentiate inline editing from + // the act of selecting an event. but the application internal drop + // targets will ask for selected items in order to pull the data from + // the packets. that's why we need to make sure at least the currently + // dragged event is contained in the set of selected items. + let selectedItems = this.getSelectedItems(); + if (!selectedItems.some(aItem => aItem.hashId == item.hashId)) { + col.calendarView.setSelectedItems([event.ctrlKey ? item.parentItem : item]); + } + // NOTE: Dragging to the allday header will fail (bug 1675056). + invokeEventDragSession(dragState.dragOccurrence, col); + return; + } + + // Snap interval: 15 minutes or 1 minute if modifier key is pressed. + dragState.snapIntMin = + event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey ? 1 : 15; + + // Check if we need to jump a column. + if (newcol && newcol != col) { + // Find how many columns we are jumping by subtracting the dates. + let dur = newcol.date.subtractDate(col.date); + let jumpedColumns = dur.isNegative ? -dur.days : dur.days; + if (dragState.dragType == "modify-start") { + // Prevent dragging the start date after the end date in a new column. + let limitEndMin = dragState.limitEndMin - MINUTES_IN_DAY * jumpedColumns; + if (limitEndMin < 0) { + return; + } + dragState.limitEndMin = limitEndMin; + } else if (dragState.dragType == "modify-end") { + let limitStartMin = dragState.limitStartMin - MINUTES_IN_DAY * jumpedColumns; + // Prevent dragging the end date before the start date in a new column. + if (limitStartMin > MINUTES_IN_DAY) { + return; + } + dragState.limitStartMin = limitStartMin; + } else if (dragState.dragType == "new") { + dragState.limitEndMin -= MINUTES_IN_DAY * jumpedColumns; + dragState.limitStartMin -= MINUTES_IN_DAY * jumpedColumns; + dragState.jumpedColumns += jumpedColumns; + } + + // Move drag state to the new column. + col.mDragState = null; + newcol.mDragState = dragState; + document.calendarEventColumnDragging = newcol; + // The same event handlers are still valid, + // because they use document.calendarEventColumnDragging. + } + + col.updateDragPosition(event.clientX, event.clientY); + } + + /** + * Update the drag position to point to the given client position. + * + * Note, this method will not switch the drag state between columns. + * + * @param {number} clientX - The x position. + * @param {number} clientY - The y position. + */ + updateDragPosition(clientX, clientY) { + let col = document.calendarEventColumnDragging; + if (!col) { + return; + } + // If we scroll, we call this method again using the same mouse positions. + // NOTE: if the magic scroll makes the mouse move over a different column, + // this won't be updated until another mousemove. + this.calendarView.setupMagicScroll(clientX, clientY, () => + this.updateDragPosition(clientX, clientY) + ); + + let dragState = col.mDragState; + + let mouseMinute = this.getMouseMinute({ clientX, clientY }); + if (mouseMinute < 0) { + mouseMinute = 0; + } else if (mouseMinute > MINUTES_IN_DAY) { + mouseMinute = MINUTES_IN_DAY; + } + let snappedMouseMinute = snapMinute( + mouseMinute - dragState.mouseMinuteOffset, + dragState.snapIntMin + ); + + let deltamin = snappedMouseMinute - dragState.origMin; + + let shadowElements; + if (dragState.dragType == "new") { + // Extend deltamin in a linear way over the columns. + deltamin += MINUTES_IN_DAY * dragState.jumpedColumns; + if (deltamin < 0) { + // Create a new event modifying the start. End time is fixed. + shadowElements = { + shadows: 1 - dragState.jumpedColumns, + offset: 0, + startMin: snappedMouseMinute, + endMin: dragState.origMin, + }; + } else { + // Create a new event modifying the end. Start time is fixed. + shadowElements = { + shadows: dragState.jumpedColumns + 1, + offset: dragState.jumpedColumns, + startMin: dragState.origMin, + endMin: snappedMouseMinute, + }; + } + dragState.startMin = shadowElements.startMin; + dragState.endMin = shadowElements.endMin; + } else if (dragState.dragType == "move") { + // If we're moving, we modify startMin and endMin of the shadow. + shadowElements = col.getShadowElements( + dragState.origMinStart + deltamin, + dragState.origMinEnd + deltamin + ); + dragState.startMin = shadowElements.startMin; + dragState.endMin = shadowElements.endMin; + // Keep track of the last start position because it will help to + // build the event at the end of the drag session. + dragState.lastStart = dragState.origMinStart + deltamin; + } else if (dragState.dragType == "modify-start") { + // If we're modifying the start, the end time is fixed. + shadowElements = col.getShadowElements(dragState.origMin + deltamin, dragState.limitEndMin); + dragState.startMin = shadowElements.startMin; + dragState.endMin = shadowElements.endMin; + + // But we need to not go past the end; if we hit + // the end, then we'll clamp to the previous snap interval minute. + if (dragState.startMin >= dragState.limitEndMin) { + dragState.startMin = snapMinute(dragState.limitEndMin, dragState.snapIntMin, "backward"); + } + } else if (dragState.dragType == "modify-end") { + // If we're modifying the end, the start time is fixed. + shadowElements = col.getShadowElements( + dragState.limitStartMin, + dragState.origMin + deltamin + ); + dragState.startMin = shadowElements.startMin; + dragState.endMin = shadowElements.endMin; + + // But we need to not go past the start; if we hit + // the start, then we'll clamp to the next snap interval minute. + if (dragState.endMin <= dragState.limitStartMin) { + dragState.endMin = snapMinute(dragState.limitStartMin, dragState.snapIntMin, "forward"); + } + } + dragState.offset = shadowElements.offset; + dragState.shadows = shadowElements.shadows; + + // Now we can update the shadow boxes position and size. + col.updateColumnShadows(); + } + + onEventSweepMouseUp(event) { + let col = document.calendarEventColumnDragging; + if (!col) { + return; + } + + let dragState = col.mDragState; + + col.clearDragging(); + col.calendarView.clearMagicScroll(); + + // If the user didn't sweep out at least a few pixels, ignore + // unless we're in a different column. + if (dragState.origColumn == col) { + let position = col.getAttribute("orient") == "vertical" ? event.clientY : event.clientX; + if (Math.abs(position - dragState.origLoc) < 3) { + return; + } + } + + let newStart; + let newEnd; + let startTZ; + let endTZ; + let dragDay = col.date; + if (dragState.dragType != "new") { + let oldStart = + dragState.dragOccurrence.startDate || + dragState.dragOccurrence.entryDate || + dragState.dragOccurrence.dueDate; + let oldEnd = + dragState.dragOccurrence.endDate || + dragState.dragOccurrence.dueDate || + dragState.dragOccurrence.entryDate; + newStart = oldStart.clone(); + newEnd = oldEnd.clone(); + + // Our views are pegged to the default timezone. If the event + // isn't also in the timezone, we're going to need to do some + // tweaking. We could just do this for every event but + // getInTimezone is slow, so it's much better to only do this + // when the timezones actually differ from the view's. + if (col.date.timezone != newStart.timezone || col.date.timezone != newEnd.timezone) { + startTZ = newStart.timezone; + endTZ = newEnd.timezone; + newStart = newStart.getInTimezone(col.date.timezone); + newEnd = newEnd.getInTimezone(col.date.timezone); + } + } + + if (dragState.dragType == "modify-start") { + newStart.resetTo( + dragDay.year, + dragDay.month, + dragDay.day, + 0, + dragState.startMin, + 0, + newStart.timezone + ); + } else if (dragState.dragType == "modify-end") { + newEnd.resetTo( + dragDay.year, + dragDay.month, + dragDay.day, + 0, + dragState.endMin, + 0, + newEnd.timezone + ); + } else if (dragState.dragType == "new") { + let startDay = dragState.origColumn.date; + let draggedForward = dragDay.compare(startDay) > 0; + newStart = draggedForward ? startDay.clone() : dragDay.clone(); + newEnd = draggedForward ? dragDay.clone() : startDay.clone(); + newStart.isDate = false; + newEnd.isDate = false; + newStart.resetTo( + newStart.year, + newStart.month, + newStart.day, + 0, + dragState.startMin, + 0, + newStart.timezone + ); + newEnd.resetTo( + newEnd.year, + newEnd.month, + newEnd.day, + 0, + dragState.endMin, + 0, + newEnd.timezone + ); + + // Edit the event title on the first of the new event's occurrences + // FIXME: This newEventNeedsEditing flag is read and unset in addEvent, + // but this is only called after some delay: after the event creation + // transaction completes. So there is a race between this creation and + // other actions that call addEvent. + // Bug 1710985 would be a way to address this: i.e. at this point we + // immediately create an element that the user can type a title into + // without creating a calendar item until they submit the title. Then + // we won't need any special flag for addEvent. + if (draggedForward) { + dragState.origColumn.newEventNeedsEditing = true; + } else { + col.newEventNeedsEditing = true; + } + } else if (dragState.dragType == "move") { + // Figure out the new date-times of the event by adding the duration + // of the total movement (days and minutes) to the old dates. + let duration = dragDay.subtractDate(dragState.origColumn.date); + let minutes = dragState.lastStart - dragState.realStart; + + // Since both boxDate and beginMove are dates (note datetimes), + // subtractDate will only give us a non-zero number of hours on + // DST changes. While strictly speaking, subtractDate's behavior + // is correct, we need to move the event a discrete number of + // days here. There is no need for normalization here, since + // addDuration does the job for us. Also note, the duration used + // here is only used to move over multiple days. Moving on the + // same day uses the minutes from the dragState. + if (duration.hours == 23) { + // Entering DST. + duration.hours++; + } else if (duration.hours == 1) { + // Leaving DST. + duration.hours--; + } + + if (duration.isNegative) { + // Adding negative minutes to a negative duration makes the + // duration more positive, but we want more negative, and + // vice versa. + minutes *= -1; + } + duration.minutes = minutes; + duration.normalize(); + + newStart.addDuration(duration); + newEnd.addDuration(duration); + } + + // If we tweaked tzs, put times back in their original ones. + if (startTZ) { + newStart = newStart.getInTimezone(startTZ); + } + if (endTZ) { + newEnd = newEnd.getInTimezone(endTZ); + } + + if (dragState.dragType == "new") { + // We won't pass a calendar, since the display calendar is the + // composite anyway. createNewEvent() will use the selected + // calendar. + col.calendarView.controller.createNewEvent(null, newStart, newEnd); + } else if ( + dragState.dragType == "move" || + dragState.dragType == "modify-start" || + dragState.dragType == "modify-end" + ) { + col.calendarView.controller.modifyOccurrence(dragState.dragOccurrence, newStart, newEnd); + } + } + + /** + * Start modifying an item through a mouse motion. + * + * @param {calItemBase} eventItem - The event item to start modifying. + * @param {"start"|"end"|"middle"} where - Whether to modify the starting + * time, ending time, or moving the entire event (modify the start and + * end, but preserve the duration). + * @param {object} position - The mouse position of the event that + * initialized* the motion. + * @param {number} position.clientX - The client x position. + * @param {number} position.clientY - The client y position. + * @param {number} position.offsetStartMinute - The minute offset of the + * mouse relative to the event item's starting time edge. + * @param {number} [snapIntMin=15] - The snapping interval to apply to the + * mouse position, in minutes. + */ + startSweepingToModifyEvent(eventItem, where, position, snapIntMin = 15) { + if (!canEditEventItem(eventItem)) { + return; + } + + this.mDragState = { + origColumn: this, + dragOccurrence: eventItem, + mouseMinuteOffset: 0, + offset: null, + shadows: null, + limitStartMin: null, + lastStart: 0, + jumpedColumns: 0, + }; + + if (this.getAttribute("orient") == "vertical") { + this.mDragState.origLoc = position.clientY; + } else { + this.mDragState.origLoc = position.clientX; + } + + let stdate = eventItem.startDate || eventItem.entryDate || eventItem.dueDate; + let enddate = eventItem.endDate || eventItem.dueDate || eventItem.entryDate; + + // Get the start and end times in minutes, relative to the start of the + // day. This may be negative or exceed the length of the day if the event + // spans more than one day. + let realStart = Math.floor(stdate.subtractDate(this.date).inSeconds / 60); + let realEnd = Math.floor(enddate.subtractDate(this.date).inSeconds / 60); + + if (where == "start") { + this.mDragState.dragType = "modify-start"; + // We have to use "realEnd" as fixed end value. + this.mDragState.limitEndMin = realEnd; + + // Snap start. + // Since we are modifying the start, we know the event starts on this + // day, so realStart is not negative. + this.mDragState.origMin = snapMinute(realStart, snapIntMin); + + // Show the shadows and drag labels when clicking on gripbars. + let shadowElements = this.getShadowElements( + this.mDragState.origMin, + this.mDragState.limitEndMin + ); + this.mDragState.startMin = shadowElements.startMin; + this.mDragState.endMin = shadowElements.endMin; + this.mDragState.shadows = shadowElements.shadows; + this.mDragState.offset = shadowElements.offset; + this.updateColumnShadows(); + } else if (where == "end") { + this.mDragState.dragType = "modify-end"; + // We have to use "realStart" as fixed end value. + this.mDragState.limitStartMin = realStart; + + // Snap end. + // Since we are modifying the end, we know the event end on this day, + // so realEnd is before midnight on this day. + this.mDragState.origMin = snapMinute(realEnd, snapIntMin); + + // Show the shadows and drag labels when clicking on gripbars. + let shadowElements = this.getShadowElements( + this.mDragState.limitStartMin, + this.mDragState.origMin + ); + this.mDragState.startMin = shadowElements.startMin; + this.mDragState.endMin = shadowElements.endMin; + this.mDragState.shadows = shadowElements.shadows; + this.mDragState.offset = shadowElements.offset; + this.updateColumnShadows(); + } else if (where == "middle") { + this.mDragState.dragType = "move"; + // In a move, origMin will be the start minute of the element where + // the drag occurs. Along with mouseMinuteOffset, it allows to track the + // shadow position. origMinStart and origMinEnd allow to figure out + // the real shadow size. + this.mDragState.mouseMinuteOffset = position.offsetStartMinute; + // We use origMin to get the number of minutes since the start of *this* + // day, which is 0 if realStart is negative. + this.mDragState.origMin = Math.max(0, snapMinute(realStart, snapIntMin)); + // We snap to the start and add the real duration to find the end. + this.mDragState.origMinStart = snapMinute(realStart, snapIntMin); + this.mDragState.origMinEnd = realEnd + this.mDragState.origMinStart - realStart; + // Keep also track of the real Start, it will be used at the end + // of the drag session to calculate the new start and end datetimes. + this.mDragState.realStart = realStart; + + let shadowElements = this.getShadowElements( + this.mDragState.origMinStart, + this.mDragState.origMinEnd + ); + this.mDragState.shadows = shadowElements.shadows; + this.mDragState.offset = shadowElements.offset; + // Do not show the shadow yet. + } else { + // Invalid grabbed element. + } + + document.calendarEventColumnDragging = this; + + window.addEventListener("mousemove", this.onEventSweepMouseMove); + window.addEventListener("mouseup", this.onEventSweepMouseUp); + window.addEventListener("keypress", this.onEventSweepKeypress); + } + + /** + * Set the hours when the day starts and ends. + * + * @param {number} dayStartHour - Hour at which the day starts. + * @param {number} dayEndHour - Hour at which the day ends. + */ + setDayStartEndHours(dayStartHour, dayEndHour) { + if (dayStartHour < 0 || dayStartHour > dayEndHour || dayEndHour > 24) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + for (let [hour, hourBox] of this.hourBoxes.entries()) { + hourBox.classList.toggle( + "multiday-hour-box-off-time", + hour < dayStartHour || hour >= dayEndHour + ); + } + } + + /** + * Get the minute since the starting edge of the given element that a mouse + * event points to. + * + * @param {{clientX: number, clientY: number}} mouseEvent - The pointer + * position in the viewport. + * @param {Element} [element] - The element to use the starting edge of as + * reference. Defaults to using the starting edge of the column itself, + * such that the returned minute is the number of minutes since the start + * of the day. + * + * @returns {number} - The number of minutes since the starting edge of + * 'element' that this event points to. + */ + getMouseMinute(mouseEvent, element = this) { + let rect = element.getBoundingClientRect(); + let pos; + if (this.getAttribute("orient") == "vertical") { + pos = mouseEvent.clientY - rect.top; + } else if (document.dir == "rtl") { + pos = rect.right - mouseEvent.clientX; + } else { + pos = mouseEvent.clientX - rect.left; + } + return pos / this.pixelsPerMinute; + } + + /** + * Get the datetime that the mouse event points to, snapped to the nearest + * 15 minutes. + * + * @param {MouseEvent} mouseEvent - The pointer event. + * + * @returns {calDateTime} - A new datetime that the mouseEvent points to. + */ + getMouseDateTime(mouseEvent) { + let clickMinute = this.getMouseMinute(mouseEvent); + let newStart = this.date.clone(); + newStart.isDate = false; + newStart.hour = 0; + // Round to nearest 15 minutes. + newStart.minute = snapMinute(clickMinute, 15); + return newStart; + } + } + + customElements.define("calendar-event-column", MozCalendarEventColumn); + + /** + * Implements the Drag and Drop class for the Calendar Header Container. + * + * @augments {MozElements.CalendarDnDContainer} + */ + class CalendarHeaderContainer extends MozElements.CalendarDnDContainer { + /** + * The date of the day this header represents. + * + * @type {calIDateTime} + */ + date; + + constructor() { + super(); + this.addEventListener("dblclick", this.onDblClick); + this.addEventListener("mousedown", this.onMouseDown); + this.addEventListener("click", this.onClick); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + // this.hasConnected is set to true in super.connectedCallback. + super.connectedCallback(); + + // Map from an event item's hashId to its calendar-editable-item. + this.eventElements = new Map(); + + this.eventsListElement = document.createElement("ol"); + this.eventsListElement.classList.add("allday-events-list"); + this.appendChild(this.eventsListElement); + } + + /** + * Return the displayed calendar-editable-item element for the given event + * item. + * + * @param {calItemBase} eventItem - The event item. + * + * @returns {Element} - The corresponding element, or undefined if none. + */ + findElementForEventItem(eventItem) { + return this.eventElements.get(eventItem.hashId); + } + + /** + * Return all the event items that are displayed in this columns. + * + * @returns {calItemBase[]} - An array of all the displayed event items. + */ + getAllEventItems() { + return Array.from(this.eventElements.values(), element => element.occurrence); + } + + /** + * Create or update a displayed calendar-editable-item element for the given + * event item. + * + * @param {calItemBase} eventItem - The event item to create or update an + * element for. + */ + addEvent(eventItem) { + let existing = this.eventElements.get(eventItem.hashId); + if (existing) { + // Remove the wrapper list item. We'll insert a replacement below. + existing.parentNode.remove(); + } + + let itemBox = document.createXULElement("calendar-editable-item"); + let listItemWrapper = document.createElement("li"); + listItemWrapper.classList.add("allday-event-listitem"); + listItemWrapper.appendChild(itemBox); + cal.data.binaryInsertNode( + this.eventsListElement, + listItemWrapper, + eventItem, + cal.view.compareItems, + false, + wrapper => wrapper.firstChild.occurrence + ); + + itemBox.calendarView = this.calendarView; + itemBox.occurrence = eventItem; + itemBox.setAttribute( + "context", + this.calendarView.getAttribute("item-context") || this.calendarView.getAttribute("context") + ); + + if (eventItem.hashId in this.calendarView.mFlashingEvents) { + itemBox.setAttribute("flashing", "true"); + } + + this.eventElements.set(eventItem.hashId, itemBox); + + itemBox.parentBox = this; + } + + /** + * Remove the displayed calendar-editable-item element for the given event + * item from this column + * + * @param {calItemBase} eventItem - The event item to remove the element of. + */ + deleteEvent(eventItem) { + let current = this.eventElements.get(eventItem.hashId); + if (current) { + // Need to remove the wrapper list item. + current.parentNode.remove(); + this.eventElements.delete(eventItem.hashId); + } + } + + /** + * Clear the header of all events. + */ + clear() { + this.eventElements.clear(); + while (this.eventsListElement.hasChildNodes()) { + this.eventsListElement.lastChild.remove(); + } + } + + /** + * Set whether to show a drop shadow in the event list. + * + * @param {boolean} on - True to show the drop shadow, otherwise hides the + * drop shadow. + */ + setDropShadow(on) { + // NOTE: Adding or removing drop shadows may change our size, but we won't + // let the calendar view know about these since they are temporary and we + // don't want the view to be re-adjusting on every hover. + let existing = this.eventsListElement.querySelector(".dropshadow"); + if (on) { + if (!existing) { + // Insert an empty list item. + let dropshadow = document.createElement("li"); + dropshadow.classList.add("dropshadow", "allday-event-listitem"); + this.eventsListElement.insertBefore(dropshadow, this.eventsListElement.firstElementChild); + } + } else if (existing) { + existing.remove(); + } + } + + onDropItem(aItem) { + let newItem = cal.item.moveToDate(aItem, this.date); + newItem = cal.item.setToAllDay(newItem, true); + return newItem; + } + + /** + * Set whether the calendar-editable-item element for the given event item + * should be displayed as selected or unselected. + * + * @param {calItemBase} eventItem - The event item. + * @param {boolean} select - Whether to show the corresponding event element + * as selected. + */ + selectEvent(eventItem, select) { + let element = this.eventElements.get(eventItem.hashId); + if (!element) { + return; + } + element.selected = select; + } + + onDblClick(event) { + if (event.button == 0) { + this.calendarView.controller.createNewEvent(null, this.date, null, true); + } + } + + onMouseDown(event) { + this.calendarView.selectedDay = this.date; + } + + onClick(event) { + if (event.button == 0) { + if (!(event.ctrlKey || event.metaKey)) { + this.calendarView.setSelectedItems([]); + } + } + if (event.button == 2) { + let newStart = this.calendarView.selectedDay.clone(); + newStart.isDate = true; + this.calendarView.selectedDateTime = newStart; + event.stopPropagation(); + } + } + + /** + * Determine whether the given wheel event is above a scrollable area and + * matches the scroll direction. + * + * @param {WheelEvent} - The wheel event. + * + * @returns {boolean} - True if this event is above a scrollable area and + * matches its scroll direction. + */ + wheelOnScrollableArea(event) { + let scrollArea = this.eventsListElement; + return ( + event.deltaY && + scrollArea.contains(event.target) && + scrollArea.scrollHeight != scrollArea.clientHeight + ); + } + } + customElements.define("calendar-header-container", CalendarHeaderContainer); + + /** + * The MozCalendarMonthDayBoxItem widget is used as event item in the + * Day and Week views of the calendar. It displays the event name, + * alarm icon and the category type color. It also displays the gripbar + * components on hovering over the event. It is used to change the event + * timings. + * + * @augments {MozElements.MozCalendarEditableItem} + */ + class MozCalendarEventBox extends MozElements.MozCalendarEditableItem { + static get inheritedAttributes() { + return { + ".alarm-icons-box": "flashing", + }; + } + constructor() { + super(); + this.addEventListener("mousedown", event => { + if (event.button != 0) { + return; + } + + event.stopPropagation(); + + if (this.mEditing) { + return; + } + + this.parentColumn.calendarView.selectedDay = this.parentColumn.date; + + this.mouseDownPosition = { + clientX: event.clientX, + clientY: event.clientY, + // We calculate the offsetStartMinute here because the clientX and + // clientY coordinates might become 'stale' by the time we actually + // call startItemDrag. E.g. if we scroll the view. + offsetStartMinute: this.parentColumn.getMouseMinute( + event, + // We use the listitem wrapper, since that is positioned relative to + // the event's start time. + this.closest(".multiday-event-listitem") + ), + }; + + let side; + if (this.startGripbar.contains(event.target)) { + side = "start"; + } else if (this.endGripbar.contains(event.target)) { + side = "end"; + } + + if (side) { + this.calendarView.setSelectedItems([ + event.ctrlKey ? this.mOccurrence.parentItem : this.mOccurrence, + ]); + + // Start edge resize drag + this.parentColumn.startSweepingToModifyEvent( + this.mOccurrence, + side, + this.mouseDownPosition, + event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey ? 1 : 15 + ); + } else { + // May be click or drag, + // So wait for mousemove (or mouseout if fast) to start item move drag. + this.mInMouseDown = true; + } + }); + + this.addEventListener("mousemove", event => { + if (!this.mInMouseDown) { + return; + } + + let deltaX = Math.abs(event.clientX - this.mouseDownPosition.clientX); + let deltaY = Math.abs(event.clientY - this.mouseDownPosition.clientY); + // More than a 3 pixel move? + const movedMoreThan3Pixels = deltaX * deltaX + deltaY * deltaY > 9; + if (movedMoreThan3Pixels && this.parentColumn) { + this.startItemDrag(); + } + }); + + this.addEventListener("mouseout", event => { + if (!this.mEditing && this.mInMouseDown && this.parentColumn) { + this.startItemDrag(); + } + }); + + this.addEventListener("mouseup", event => { + if (!this.mEditing) { + this.mInMouseDown = false; + } + }); + + this.addEventListener("mouseover", event => { + if (this.calendarView && this.calendarView.controller) { + event.stopPropagation(); + onMouseOverItem(event); + } + }); + + this.addEventListener("mouseenter", event => { + // Update the event-readonly class to determine whether to show the + // gripbars, which are otherwise shown on hover. + this.classList.toggle("event-readonly", !canEditEventItem(this.occurrence)); + }); + + // We have two event listeners for dragstart. This event listener is for the capturing phase + // where we are setting up the document.monthDragEvent which will be used in the event listener + // in the bubbling phase which is set up in the calendar-editable-item. + this.addEventListener( + "dragstart", + event => { + document.monthDragEvent = this; + }, + true + ); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + this.appendChild( + MozXULElement.parseXULToFragment(` + <!-- NOTE: The following div is the same markup as EditableItem. --> + <html:div class="calendar-item-container"> + <html:div class="calendar-item-flex"> + <html:img class="item-type-icon" alt="" /> + <html:div class="event-name-label"></html:div> + <html:input class="plain event-name-input" + hidden="hidden" + placeholder='${cal.l10n.getCalString("newEvent")}'/> + <html:div class="alarm-icons-box"></html:div> + <html:img class="item-classification-icon" /> + <html:img class="item-recurrence-icon" /> + </html:div> + <html:div class="location-desc"></html:div> + <html:div class="calendar-category-box"></html:div> + </html:div> + `) + ); + + this.startGripbar = this.createGripbar("start"); + this.endGripbar = this.createGripbar("end"); + this.appendChild(this.startGripbar); + this.appendChild(this.endGripbar); + + this.classList.add("calendar-color-box"); + + this.style.pointerEvents = "auto"; + this.setAttribute("tooltip", "itemTooltip"); + + this.addEventNameTextboxListener(); + this.initializeAttributeInheritance(); + } + + /** + * Create one of the box's gripbars that can be dragged to resize the event. + * + * @param {"start"|"end"} side - The side the gripbar controls. + * + * @returns {Element} - A newly created gripbar. + */ + createGripbar(side) { + let gripbar = document.createElement("div"); + gripbar.classList.add(side == "start" ? "gripbar-start" : "gripbar-end"); + let img = document.createElement("img"); + img.setAttribute("src", "chrome://calendar/skin/shared/event-grippy.png"); + /* Make sure the img doesn't interfere with dragging the gripbar to + * resize. */ + img.setAttribute("draggable", "false"); + img.setAttribute("alt", ""); + gripbar.appendChild(img); + return gripbar; + } + + /** + * Update and retrieve the event's start and end dates relative to the given + * day. This updates the gripbars. + * + * @param {calIDateTime} day - The day that this event is shown on. + * + * @returns {object} - The start and end time information. + * @property {calIDateTime|undefined} startDate - The start date-time of the + * event in the timezone of the given day. Or the entry date-time for + * tasks, if they have one. + * @property {calIDateTime|undefined} endDate - The end date-time of the + * event in the timezone of the given day. Or the due date-time for + * tasks, if they have one. + * @property {number} startMinute - The number of minutes since the start of + * the given day that the event starts. + * @property {number} endMinute - The number of minutes since the end of the + * given day that the event ends. + */ + updateRelativeStartEndDates(day) { + let item = this.occurrence; + + // Get closed bounds for the day. I.e. inclusive of midnight the next day. + let closedDayStart = day.clone(); + closedDayStart.isDate = false; + let closedDayEnd = day.clone(); + closedDayEnd.day++; + closedDayEnd.isDate = false; + + function relativeTime(date) { + if (!date) { + return null; + } + date = date.getInTimezone(day.timezone); + return { + date, + minute: date.subtractDate(closedDayStart).inSeconds / 60, + withinClosedDay: date.compare(closedDayStart) >= 0 && date.compare(closedDayEnd) <= 0, + }; + } + + let start; + let end; + if (item.isEvent()) { + start = relativeTime(item.startDate); + end = relativeTime(item.endDate); + } else { + start = relativeTime(item.entryDate); + end = relativeTime(item.dueDate); + } + + this.startGripbar.hidden = !(end && start?.withinClosedDay); + this.endGripbar.hidden = !(start && end?.withinClosedDay); + + return { + startDate: start?.date, + endDate: end?.date, + startMinute: start?.minute, + endMinute: end?.minute, + }; + } + + getOptimalMinSize(orient) { + let label = this.querySelector(".event-name-label"); + if (orient == "vertical") { + let minHeight = + getOptimalMinimumHeight(label) + + getSummarizedStyleValues(label.parentNode, ["padding-bottom", "padding-top"]) + + getSummarizedStyleValues(this, ["border-bottom-width", "border-top-width"]); + this.style.minHeight = minHeight + "px"; + this.style.minWidth = "1px"; + return minHeight; + } + label.style.minWidth = "2em"; + let minWidth = getOptimalMinimumWidth(this.eventNameLabel); + this.style.minWidth = minWidth + "px"; + this.style.minHeight = "1px"; + return minWidth; + } + + startItemDrag() { + if (this.editingTimer) { + clearTimeout(this.editingTimer); + this.editingTimer = null; + } + + this.calendarView.setSelectedItems([this.mOccurrence]); + + this.mEditing = false; + + this.parentColumn.startSweepingToModifyEvent( + this.mOccurrence, + "middle", + this.mouseDownPosition + ); + this.mInMouseDown = false; + } + } + + customElements.define("calendar-event-box", MozCalendarEventBox); + + /** + * Abstract class used for the day and week calendar view elements. (Not month or multiweek.) + * + * @implements {calICalendarView} + * @augments {MozElements.CalendarBaseView} + * @abstract + */ + class CalendarMultidayBaseView extends MozElements.CalendarBaseView { + // mDateList will always be sorted before being set. + mDateList = null; + + /** + * A column in the view representing a particular date. + * + * @typedef {object} DayColumn + * @property {calIDateTime} date - The day's date. + * @property {Element} container - The container that holds the other + * elements. + * @property {Element} headingContainer - The day heading. This holds both + * the short and long headings, with only one being visible at any given + * time. + * @property {Element} longHeading - The day heading that uses the full + * day of the week. For example, "Monday". + * @property {Element} shortHeading - The day heading that uses an + * abbreviation for the day of the week. For example, "Mon". + * @property {number} longHeadingContentAreaWidth - The content area width + * of the headingContainer when the long heading is shown. + * @property {Element} column - A calendar-event-column where regular + * (not "all day") events appear. + * @property {Element} header - A calendar-header-container where allday + * events appear. + */ + /** + * An ordered list of the shown day columns. + * + * @type {DayColumn[]} + */ + dayColumns = []; + + /** + * Whether the number of headings, or the heading dates have changed, and + * the view still needs to be adjusted accordingly. + * + * @type {boolean} + */ + headingDatesChanged = true; + /** + * Whether the view has been rotated and the view still needs to be fully + * adjusted. + * + * @type {boolean} + */ + rotationChanged = true; + + mSelectedDayCol = null; + mSelectedDay = null; + + /** + * The hour that a 'day' starts. Any time before this is considered + * off-time. + * + * @type {number} + */ + dayStartHour = 0; + /** + * The hour that a 'day' ends. Any time equal to or after this is + * considered off-time. + * + * @type {number} + */ + dayEndHour = 0; + + /** + * How many hours to show in the scrollable area. + * + * @type {number} + */ + visibleHours = 9; + + /** + * The number of pixels that a one minute duration should occupy in the + * view. + * + * @type {number} + */ + pixelsPerMinute; + + /** + * The timebar hour box elements in this view, ordered and indexed by their + * starting hour. + * + * @type {Element[]} + */ + hourBoxes = []; + + mClickedTime = null; + + mTimeIndicatorInterval = 15; + mTimeIndicatorMinutes = 0; + + mModeHandler = null; + scrollMinute = 0; + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + super.connectedCallback(); + + // Get day start/end hour from prefs and set on the view. + // This happens here to keep tests happy. + this.setDayStartEndHours( + Services.prefs.getIntPref("calendar.view.daystarthour", 8), + Services.prefs.getIntPref("calendar.view.dayendhour", 17) + ); + + // We set the scrollMinute, so that when onResize is eventually triggered + // by refresh, we will scroll to this. + // FIXME: Find a cleaner solution. + this.scrollMinute = this.dayStartHour * 60; + } + + ensureInitialized() { + if (this.isInitialized) { + return; + } + + this.grid = document.createElement("div"); + this.grid.classList.add("multiday-grid"); + this.appendChild(this.grid); + + this.headerCorner = document.createElement("div"); + this.headerCorner.classList.add("multiday-header-corner"); + + this.grid.appendChild(this.headerCorner); + + this.timebar = document.createElement("div"); + this.timebar.classList.add("multiday-timebar", "multiday-hour-box-container"); + this.nowIndicator = document.createElement("div"); + this.nowIndicator.classList.add("multiday-timebar-now-indicator"); + this.nowIndicator.hidden = true; + this.timebar.appendChild(this.nowIndicator); + + let formatter = cal.dtz.formatter; + let jsTime = new Date(); + for (let hour = 0; hour < 24; hour++) { + let hourBox = document.createElement("div"); + hourBox.classList.add("multiday-hour-box", "multiday-timebar-time"); + // Set the time label. + jsTime.setHours(hour, 0, 0); + hourBox.textContent = formatter.formatTime( + cal.dtz.jsDateToDateTime(jsTime, cal.dtz.floating) + ); + this.timebar.appendChild(hourBox); + this.hourBoxes.push(hourBox); + } + this.grid.appendChild(this.timebar); + + this.endBorder = document.createElement("div"); + this.endBorder.classList.add("multiday-end-border"); + this.grid.appendChild(this.endBorder); + + this.initializeAttributeInheritance(); + + // super.connectedCallback has to be called after the time bar is added to the DOM. + super.ensureInitialized(); + + this.addEventListener("click", event => { + if (event.button != 2) { + return; + } + this.selectedDateTime = null; + }); + + this.addEventListener("wheel", event => { + // Only shift hours if no modifier is pressed. + if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) { + return; + } + let deltaTime = this.getAttribute("orient") == "horizontal" ? event.deltaX : event.deltaY; + if (!deltaTime) { + // Scroll is not in the same direction as the time axis, so just do + // the default scroll (if any). + return; + } + if ( + this.headerCorner.contains(event.target) || + this.dayColumns.some(col => col.headingContainer.contains(event.target)) + ) { + // Prevent any scrolling in these sticky headers. + event.preventDefault(); + return; + } + let header = this.dayColumns.find(col => col.header.contains(event.target))?.header; + if (header) { + if (!header.wheelOnScrollableArea(event)) { + // Prevent any scrolling in this header. + event.preventDefault(); + // Otherwise, we let the default wheel handler scroll the header. + // NOTE: We have the CSS overscroll-behavior set to "none", to stop + // the default wheel handler from scrolling the parent if the header + // is already at its scrolling edge. + } + return; + } + let minute = this.scrollMinute; + if (event.deltaMode == event.DOM_DELTA_LINE) { + // We snap from the current hour to the next one. + let scrollHour = deltaTime < 0 ? Math.floor(minute / 60) : Math.ceil(minute / 60); + if (Math.abs(scrollHour * 60 - minute) < 10) { + // If the change in minutes would be less than 10 minutes, go to the + // next hour. This means that anything in the close neighbourhood of + // the hour line will scroll to the same hour. + scrollHour += Math.sign(deltaTime); + } + minute = scrollHour * 60; + } else if (event.deltaMode == event.DOM_DELTA_PIXEL) { + let minDiff = deltaTime / this.pixelsPerMinute; + minute += minDiff < 0 ? Math.floor(minDiff) : Math.ceil(minDiff); + } else { + return; + } + event.preventDefault(); + this.scrollToMinute(minute); + }); + + this.grid.addEventListener("scroll", event => { + if (!this.clientHeight) { + // Hidden, so don't store the scroll position. + // FIXME: We don't expect scrolling whilst we are hidden, so we should + // try and remove. This is only seems to happen in mochitests. + return; + } + let scrollPx; + if (this.getAttribute("orient") == "horizontal") { + scrollPx = document.dir == "rtl" ? -this.grid.scrollLeft : this.grid.scrollLeft; + } else { + scrollPx = this.grid.scrollTop; + } + this.scrollMinute = Math.round(scrollPx / this.pixelsPerMinute); + }); + + // Get visible hours from prefs and set on the view. + this.setVisibleHours(Services.prefs.getIntPref("calendar.view.visiblehours", 9)); + } + + // calICalendarView Properties + + get supportsZoom() { + return true; + } + + get supportsRotation() { + return true; + } + + get supportsDisjointDates() { + return true; + } + + get hasDisjointDates() { + return this.mDateList != null; + } + + set selectedDay(day) { + // Ignore if just 1 visible, it's always selected, but we don't indicate it. + if (this.numVisibleDates == 1) { + this.fireEvent("dayselect", day); + return; + } + + if (this.mSelectedDayCol) { + this.mSelectedDayCol.container.classList.remove("day-column-selected"); + } + + if (day) { + this.mSelectedDayCol = this.findColumnForDate(day); + if (this.mSelectedDayCol) { + this.mSelectedDay = this.mSelectedDayCol.date; + this.mSelectedDayCol.container.classList.add("day-column-selected"); + } else { + this.mSelectedDay = day; + } + } + this.fireEvent("dayselect", day); + } + + get selectedDay() { + let selected; + if (this.numVisibleDates == 1) { + selected = this.dayColumns[0].date; + } else if (this.mSelectedDay) { + selected = this.mSelectedDay; + } else if (this.mSelectedDayCol) { + selected = this.mSelectedDayCol.date; + } + + // TODO Make sure the selected day is valid. + // TODO Select now if it is in the range? + return selected; + } + + // End calICalendarView Properties + + set selectedDateTime(dateTime) { + this.mClickedTime = dateTime; + } + + get selectedDateTime() { + return this.mClickedTime; + } + + // Private + + get numVisibleDates() { + if (this.mDateList) { + return this.mDateList.length; + } + + let count = 0; + + if (!this.mStartDate || !this.mEndDate) { + // The view has not been initialized, so there are 0 visible dates. + return count; + } + + const date = this.mStartDate.clone(); + while (date.compare(this.mEndDate) <= 0) { + count++; + date.day += 1; + } + + return count; + } + + /** + * Update the position of the time indicator. + */ + updateTimeIndicatorPosition() { + // Calculate the position of the indicator based on how far into the day + // it is and the size of the current view. + const now = cal.dtz.now(); + const nowMinutes = now.hour * 60 + now.minute; + + let position = `${this.pixelsPerMinute * nowMinutes - 1}px`; + let isVertical = this.getAttribute("orient") == "vertical"; + + // Control the position of the dot in the time bar, which is present even + // when the view does not show the current day. Inline start controls + // horizontal position of the dot, block controls vertical. + this.nowIndicator.style.insetInlineStart = isVertical ? null : position; + this.nowIndicator.style.insetBlockStart = isVertical ? position : null; + + // Control the position of the bar, which should be visible only for the + // current day. + const todayIndicator = this.findColumnForDate(this.today())?.column.timeIndicatorBox; + if (todayIndicator) { + todayIndicator.style.marginInlineStart = isVertical ? null : position; + todayIndicator.style.marginBlockStart = isVertical ? position : null; + } + } + + /** + * Handle preference changes. Typically called by a preference observer. + * + * @param {object} subject - The subject, a prefs object. + * @param {string} topic - The notification topic. + * @param {string} preference - The preference to handle. + */ + handlePreference(subject, topic, preference) { + subject.QueryInterface(Ci.nsIPrefBranch); + switch (preference) { + case "calendar.view.daystarthour": + this.setDayStartEndHours(subject.getIntPref(preference), this.dayEndHour); + break; + + case "calendar.view.dayendhour": + this.setDayStartEndHours(this.dayStartHour, subject.getIntPref(preference)); + break; + + case "calendar.view.visiblehours": + this.setVisibleHours(subject.getIntPref(preference)); + this.readjustView(true, true, this.scrollMinute); + break; + + default: + this.handleCommonPreference(subject, topic, preference); + break; + } + } + + /** + * Handle resizing by adjusting the view to the new size. + */ + onResize() { + // Assume resize in both directions. + this.readjustView(true, true, this.scrollMinute); + } + + /** + * Perform an operation on the header that may cause it to resize, such that + * the view can adjust itself accordingly. + * + * @param {Element} header - The header that may resize. + * @param {Function} operation - An operation to run. + */ + doResizingHeaderOperation(header, operation) { + // Capture scrollMinute before we potentially change the size of the view. + let scrollMinute = this.scrollMinute; + let beforeRect = header.getBoundingClientRect(); + + operation(); + + let afterRect = header.getBoundingClientRect(); + this.readjustView( + beforeRect.height != afterRect.height, + beforeRect.width != afterRect.width, + scrollMinute + ); + } + + /** + * Adjust the view based an a change in rotation, layout, view size, or + * header size. + * + * Note, this method will do nothing whilst the view is hidden, so must be + * called again once it is shown. + * + * @param {boolean} verticalResize - There may have been a change in the + * vertical direction. + * @param {boolean} horizontalResize - There may have been a change in the + * horizontal direction. + * @param {number} scrollMinute - The minute we should scroll after + * adjusting the view in the time-direction. + */ + readjustView(verticalResize, horizontalResize, scrollMinute) { + if (!this.clientHeight || !this.clientWidth) { + // Do nothing if we have zero width or height since we cannot measure + // elements. Should be called again once we can. + return; + } + + let isHorizontal = this.getAttribute("orient") == "horizontal"; + + // Adjust the headings. We do this before measuring the pixels per minute + // because this may adjust the size of the headings. + if (this.headingDatesChanged) { + this.shortHeadingContentWidth = 0; + for (let dayCol of this.dayColumns) { + // Make sure both headings are visible for measuring. + // We will hide one of them again further below. + dayCol.shortHeading.hidden = false; + dayCol.longHeading.hidden = false; + + // We can safely measure the widths of the short and long headings + // because their headingContainer does not grow or shrink them. + let longHeadingRect = dayCol.longHeading.getBoundingClientRect(); + if (!this.headingContentHeight) { + // We assume this is constant and the same for each heading. + this.headingContentHeight = longHeadingRect.height; + } + + dayCol.longHeadingContentAreaWidth = longHeadingRect.width; + this.shortHeadingContentWidth = Math.max( + this.shortHeadingContentWidth, + dayCol.shortHeading.getBoundingClientRect().width + ); + } + // Unset the other properties that use these values. + // NOTE: We do not calculate new values for these properties here + // because they can only be measured in one of the rotated or + // non-rotated states. So we will calculate them as needed. + delete this.rotatedHeadingWidth; + delete this.minHeadingWidth; + } + + // Whether the headings need readjusting. + let adjustHeadingPositioning = this.headingDatesChanged || this.rotationChanged; + // Position headers. + if (isHorizontal) { + // We're in the rotated state, so we can measure the corresponding + // header dimensions. + // NOTE: we always use short headings in the rotated view. + if (!this.rotatedHeadingWidth) { + // Width is shared by all headings in the rotated view, so we set it + // so that its large enough to fit the text of each heading. + if (!this.rotatedHeadingContentToBorderWidthOffset) { + // We cache the value since we assume it is constant within the + // rotated view. + this.rotatedHeadingContentToBorderOffset = this.measureHeadingContentToBorderOffset(); + } + this.rotatedHeadingWidth = + this.shortHeadingContentWidth + this.rotatedHeadingContentToBorderOffset.inline; + adjustHeadingPositioning = true; + } + if (adjustHeadingPositioning) { + for (let dayCol of this.dayColumns) { + // The header is sticky, so we need to position it. We want a constant + // position, so we offset the header by the heading width. + // NOTE: We assume there is no margin between the two. + dayCol.header.style.insetBlockStart = null; + dayCol.header.style.insetInlineStart = `${this.rotatedHeadingWidth}px`; + // NOTE: The heading must have its box-sizing set to border-box for + // this to work properly. + dayCol.headingContainer.style.width = `${this.rotatedHeadingWidth}px`; + dayCol.headingContainer.style.minWidth = null; + } + } + } else { + // We're in the non-rotated state, so we can measure the corresponding + // header dimensions. + if (!this.headingContentToBorderOffset) { + // We cache the value since we assume it is constant within the + // non-rotated view. + this.headingContentToBorderOffset = this.measureHeadingContentToBorderOffset(); + } + if (!this.headingHeight) { + this.headingHeight = this.headingContentHeight + this.headingContentToBorderOffset.block; + } + if (!this.minHeadingWidth) { + // Make the minimum width large enough to fit the short heading. + this.minHeadingWidth = + this.shortHeadingContentWidth + this.headingContentToBorderOffset.inline; + adjustHeadingPositioning = true; + } + if (adjustHeadingPositioning) { + for (let dayCol of this.dayColumns) { + // We offset the header by the heading height. + dayCol.header.style.insetBlockStart = `${this.headingHeight}px`; + dayCol.header.style.insetInlineStart = null; + dayCol.headingContainer.style.minWidth = `${this.minHeadingWidth}px`; + dayCol.headingContainer.style.width = null; + } + } + } + + // If the view is horizontal, we always use the short headings. + // We do this before calculating the pixelsPerMinute since the width of + // the heading is important to determining the size of the scroll area. + // We only need to do this when the view has been rotated, or when new + // headings have been added. adjustHeadingPosition covers both of these. + if (isHorizontal && adjustHeadingPositioning) { + for (let dayCol of this.dayColumns) { + dayCol.shortHeading.hidden = false; + dayCol.longHeading.hidden = true; + } + } + // Otherwise, if the view is vertical, we determine whether to use short + // or long headings after changing the pixelsPerMinute, which can change + // the amount of horizontal space. + // NOTE: when the view is vertical, both the short and long headings + // should take up the same vertical space, so this shouldn't effect the + // pixelsPerMinute calculation. + + if (this.rotationChanged) { + // Clear the set widths/heights or positions before calculating the + // scroll area. Otherwise they will remain extended in the wrong + // direction, and keep the grid content larger than necessary, which can + // cause the grid content to overflow, which in turn shrinks the + // calculated scroll area due to extra scrollbars. + // The timebar will be corrected when the pixelsPerMinute is calculated. + this.timebar.style.width = null; + this.timebar.style.height = null; + // The time indicators will be corrected in updateTimeIndicatorPosition. + this.nowIndicator.style.insetInlineStart = null; + this.nowIndicator.style.insetBlockStart = null; + let todayIndicator = this.findColumnForDate(this.today())?.column.timeIndicatorBox; + if (todayIndicator) { + todayIndicator.style.marginInlineStart = null; + todayIndicator.style.marginBlockStart = null; + } + } + + // Adjust pixels per minute. + let ppmHasChanged = false; + if ( + adjustHeadingPositioning || + (isHorizontal && horizontalResize) || + (!isHorizontal && verticalResize) + ) { + if (isHorizontal && !this.timebarMinWidth) { + // Measure the minimum width such that the time labels do not overflow + // and are equal width. + this.timebar.style.height = null; + this.timebar.style.width = "min-content"; + let maxWidth = 0; + for (let hourBox of this.hourBoxes) { + maxWidth = Math.max(maxWidth, hourBox.getBoundingClientRect().width); + } + // NOTE: We assume no margin between the boxes. + this.timebarMinWidth = maxWidth * this.hourBoxes.length; + // width should be set to the correct value below when the + // pixelsPerMinute changes. + } else if (!isHorizontal && !this.timebarMinHeight) { + // Measure the minimum height such that the time labels do not + // overflow and are equal height. + this.timebar.style.width = null; + this.timebar.style.height = "min-content"; + let maxHeight = 0; + for (let hourBox of this.hourBoxes) { + maxHeight = Math.max(maxHeight, hourBox.getBoundingClientRect().height); + } + // NOTE: We assume no margin between the boxes. + this.timebarMinHeight = maxHeight * this.hourBoxes.length; + // height should be set to the correct value below when the + // pixelsPerMinute changes. + } + + // We want to know how much visible space is available in the + // "time-direction" of this view's scrollable area, which will be used + // to show 'this.visibleHour' hours in the timebar. + // NOTE: The area returned by getScrollAreaRect is the *current* + // scrollable area. We are working with the assumption that the length + // in the time-direction will not change when we change the pixels per + // minute. This assumption is broken if the changes cause the + // non-time-direction to switch from overflowing to not, or vis versa, + // which adds or removes a scrollbar. Since we are only changing the + // content length in the time-direction, this should only happen in edge + // cases (e.g. scrollbar being added from a time-direction overflow also + // causes the non-time-direction to overflow). + let scrollArea = this.getScrollAreaRect(); + let dayScale = 24 / this.visibleHours; + let dayPixels = isHorizontal + ? Math.max((scrollArea.right - scrollArea.left) * dayScale, this.timebarMinWidth) + : Math.max((scrollArea.bottom - scrollArea.top) * dayScale, this.timebarMinHeight); + let pixelsPerMinute = dayPixels / MINUTES_IN_DAY; + if (this.rotationChanged || pixelsPerMinute != this.pixelsPerMinute) { + ppmHasChanged = true; + this.pixelsPerMinute = pixelsPerMinute; + + // Use the same calculation as in the event columns. + let dayPx = `${MINUTES_IN_DAY * pixelsPerMinute}px`; + if (isHorizontal) { + this.timebar.style.width = dayPx; + this.timebar.style.height = null; + } else { + this.timebar.style.height = dayPx; + this.timebar.style.width = null; + } + + for (const col of this.dayColumns) { + col.column.pixelsPerMinute = pixelsPerMinute; + } + } + + // Scroll to the given minute. + this.scrollToMinute(scrollMinute); + // A change in pixels per minute can cause a scrollbar to appear or + // disappear, which can change the available space for headers. + if (ppmHasChanged) { + verticalResize = true; + horizontalResize = true; + } + } + + // Decide whether to use short headings. + if (!isHorizontal && (horizontalResize || adjustHeadingPositioning)) { + // Use short headings if *any* heading would horizontally overflow with + // a long heading. + let widthOffset = this.headingContentToBorderOffset.inline; + let useShortHeadings = this.dayColumns.some( + col => + col.headingContainer.getBoundingClientRect().width < + col.longHeadingContentAreaWidth + widthOffset + ); + for (let dayCol of this.dayColumns) { + dayCol.shortHeading.hidden = !useShortHeadings; + dayCol.longHeading.hidden = useShortHeadings; + } + } + + this.updateTimeIndicatorPosition(); + + // The changes have now been handled. + this.headingDatesChanged = false; + this.rotationChanged = false; + } + + /** + * Measure the total offset between the content width and border width of + * the day headings. + * + * @returns {{inline: number, block: number}} - The offsets in their + * respective directions. + */ + measureHeadingContentToBorderOffset() { + if (!this.dayColumns.length) { + // undefined properties. + return {}; + } + // We cache the offset. We expect these styles to differ between the + // rotated and non-rotated views, but to otherwise be constant. + let style = getComputedStyle(this.dayColumns[0].headingContainer); + return { + inline: + parseFloat(style.paddingInlineStart) + + parseFloat(style.paddingInlineEnd) + + parseFloat(style.borderInlineStartWidth) + + parseFloat(style.borderInlineEndWidth), + block: + parseFloat(style.paddingBlockStart) + + parseFloat(style.paddingBlockEnd) + + parseFloat(style.borderBlockStartWidth) + + parseFloat(style.borderBlockEndWidth), + }; + } + + /** + * Make a calendar item flash or stop flashing. Called when the item's alarm fires. + * + * @param {calIItemBase} item - The calendar item. + * @param {boolean} stop - Whether to stop the item from flashing. + */ + flashAlarm(item, stop) { + function setFlashingAttribute(box) { + if (stop) { + box.removeAttribute("flashing"); + } else { + box.setAttribute("flashing", "true"); + } + } + + const showIndicator = Services.prefs.getBoolPref("calendar.alarms.indicator.show", true); + const totaltime = Services.prefs.getIntPref("calendar.alarms.indicator.totaltime", 3600); + + if (!stop && (!showIndicator || totaltime < 1)) { + // No need to animate if the indicator should not be shown. + return; + } + + // Make sure the flashing attribute is set or reset on all visible boxes. + const columns = this.findColumnsForItem(item); + for (const col of columns) { + const colBox = col.column.findElementForEventItem(item); + const headerBox = col.header.findElementForEventItem(item); + + if (colBox) { + setFlashingAttribute(colBox); + } + if (headerBox) { + setFlashingAttribute(headerBox); + } + } + + if (stop) { + // We are done flashing, prevent newly created event boxes from flashing. + delete this.mFlashingEvents[item.hashId]; + } else { + // Set up a timer to stop the flashing after the total time. + this.mFlashingEvents[item.hashId] = item; + setTimeout(() => this.flashAlarm(item, true), totaltime); + } + } + + // calICalendarView Methods + + showDate(date) { + const targetDate = date.getInTimezone(this.mTimezone); + targetDate.isDate = true; + + if (this.mStartDate.timezone.tzid == date.timezone.tzid) { + if (this.mStartDate && this.mEndDate) { + if (this.mStartDate.compare(targetDate) <= 0 && this.mEndDate.compare(targetDate) >= 0) { + return; + } + } else if (this.mDateList) { + for (const listDate of this.mDateList) { + // If date is already visible, nothing to do. + if (listDate.compare(targetDate) == 0) { + return; + } + } + } + } + + // If we're only showing one date, then continue + // to only show one date; otherwise, show the week. + if (this.numVisibleDates == 1) { + this.setDateRange(date, date); + } else { + this.setDateRange(date.startOfWeek, date.endOfWeek); + } + + this.selectedDay = targetDate; + } + + setDateRange(startDate, endDate) { + this.rangeStartDate = startDate; + this.rangeEndDate = endDate; + + const viewStart = startDate.getInTimezone(this.mTimezone); + const viewEnd = endDate.getInTimezone(this.mTimezone); + + viewStart.isDate = true; + viewStart.makeImmutable(); + viewEnd.isDate = true; + viewEnd.makeImmutable(); + + this.mStartDate = viewStart; + this.mEndDate = viewEnd; + + // The start and end dates to query calendars with (in CalendarFilteredViewMixin). + this.startDate = viewStart; + let viewEndPlusOne = viewEnd.clone(); + viewEndPlusOne.day++; + this.endDate = viewEndPlusOne; + + // First, check values of tasksInView, workdaysOnly, showCompleted. + // Their status will determine the value of toggleStatus, which is + // saved to this.mToggleStatus during last call to relayout() + let toggleStatus = 0; + + if (this.mTasksInView) { + toggleStatus |= this.mToggleStatusFlag.TasksInView; + } + if (this.mWorkdaysOnly) { + toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly; + } + if (this.mShowCompleted) { + toggleStatus |= this.mToggleStatusFlag.ShowCompleted; + } + + // Update the navigation bar only when changes are related to the current view. + if (this.isVisible()) { + calendarNavigationBar.setDateRange(viewStart, viewEnd); + } + + // Check whether view range has been changed since last call to relayout(). + if ( + !this.mViewStart || + !this.mViewEnd || + this.mViewStart.timezone.tzid != viewStart.timezone.tzid || + this.mViewEnd.compare(viewEnd) != 0 || + this.mViewStart.compare(viewStart) != 0 || + this.mToggleStatus != toggleStatus + ) { + this.relayout({ dates: true }); + } + } + + getDateList() { + const dates = []; + if (this.mStartDate && this.mEndDate) { + const date = this.mStartDate.clone(); + while (date.compare(this.mEndDate) <= 0) { + dates.push(date.clone()); + date.day += 1; + } + } else if (this.mDateList) { + for (const date of this.mDateList) { + dates.push(date.clone()); + } + } + + return dates; + } + + setSelectedItems(items, suppressEvent) { + if (this.mSelectedItems) { + for (const item of this.mSelectedItems) { + for (const occ of this.getItemOccurrencesInView(item)) { + const cols = this.findColumnsForItem(occ); + for (const col of cols) { + col.header.selectEvent(occ, false); + col.column.selectEvent(occ, false); + } + } + } + } + this.mSelectedItems = items || []; + + for (const item of this.mSelectedItems) { + for (const occ of this.getItemOccurrencesInView(item)) { + const cols = this.findColumnsForItem(occ); + if (cols.length == 0) { + continue; + } + const start = item.startDate || item.entryDate || item.dueDate; + for (const col of cols) { + if (start.isDate) { + col.header.selectEvent(occ, true); + } else { + col.column.selectEvent(occ, true); + } + } + } + } + + if (!suppressEvent) { + this.fireEvent("itemselect", this.mSelectedItems); + } + } + + centerSelectedItems() { + const displayTZ = cal.dtz.defaultTimezone; + let lowMinute = MINUTES_IN_DAY; + let highMinute = 0; + + for (const item of this.mSelectedItems) { + const startDateProperty = cal.dtz.startDateProp(item); + const endDateProperty = cal.dtz.endDateProp(item); + + let occs = []; + if (item.recurrenceInfo) { + // If selected a parent item, show occurrence(s) in view range. + occs = item.getOccurrencesBetween(this.startDate, this.queryEndDate); + } else { + occs = [item]; + } + + for (const occ of occs) { + let occStart = occ[startDateProperty]; + let occEnd = occ[endDateProperty]; + // Must have at least one of start or end. + if (!occStart && !occEnd) { + // Task with no dates. + continue; + } + + // If just has single datetime, treat as zero duration item + // (such as task with due datetime or start datetime only). + occStart = occStart || occEnd; + occEnd = occEnd || occStart; + // Now both occStart and occEnd are datetimes. + + // Skip occurrence if all-day: it won't show in time view. + if (occStart.isDate || occEnd.isDate) { + continue; + } + + // Trim dates to view. (Not mutated so just reuse view dates.) + if (this.startDate.compare(occStart) > 0) { + occStart = this.startDate; + } + if (this.queryEndDate.compare(occEnd) < 0) { + occEnd = this.queryEndDate; + } + + // Convert to display timezone if different. + if (occStart.timezone != displayTZ) { + occStart = occStart.getInTimezone(displayTZ); + } + if (occEnd.timezone != displayTZ) { + occEnd = occEnd.getInTimezone(displayTZ); + } + // If crosses midnight in current TZ, set end just + // before midnight after start so start/title usually visible. + if (!cal.dtz.sameDay(occStart, occEnd)) { + occEnd = occStart.clone(); + occEnd.day = occStart.day; + occEnd.hour = 23; + occEnd.minute = 59; + } + + // Ensure range shows occ. + lowMinute = Math.min(occStart.hour * 60 + occStart.minute, lowMinute); + highMinute = Math.max(occEnd.hour * 60 + occEnd.minute, highMinute); + } + } + + let halfDurationMinutes = (highMinute - lowMinute) / 2; + if (this.mSelectedItems.length && halfDurationMinutes >= 0) { + let halfVisibleMinutes = this.visibleHours * 30; + if (halfDurationMinutes <= halfVisibleMinutes) { + // If the full duration fits in the view, then center the middle of + // the region. + this.scrollToMinute(lowMinute + halfDurationMinutes - halfVisibleMinutes); + } else if (this.mSelectedItems.length == 1) { + // Else, if only one event is selected, then center the start. + this.scrollToMinute(lowMinute - halfVisibleMinutes); + } + // Else, don't scroll. + } + } + + zoomIn(level) { + let visibleHours = Services.prefs.getIntPref("calendar.view.visiblehours", 9); + visibleHours += level || 1; + + Services.prefs.setIntPref("calendar.view.visiblehours", Math.min(visibleHours, 24)); + } + + zoomOut(level) { + let visibleHours = Services.prefs.getIntPref("calendar.view.visiblehours", 9); + visibleHours -= level || 1; + + Services.prefs.setIntPref("calendar.view.visiblehours", Math.max(1, visibleHours)); + } + + zoomReset() { + Services.prefs.setIntPref("calendar.view.visiblehours", 9); + } + + // End calICalendarView Methods + + /** + * Return all the occurrences of a given item that are currently displayed in the view. + * + * @param {calIItemBase} item - A calendar item. + * @returns {calIItemBase[]} An array of occurrences. + */ + getItemOccurrencesInView(item) { + if (item.recurrenceInfo && item.recurrenceStartDate) { + // If a parent item is selected, show occurrence(s) in view range. + return item.getOccurrencesBetween(this.startDate, this.queryEndDate); + } else if (item.recurrenceStartDate) { + return [item]; + } + // Undated todo. + return []; + } + + /** + * Set an attribute on the view element, and do re-orientation and re-layout if needed. + * + * @param {string} attr - The attribute to set. + * @param {string} value - The value to set. + */ + setAttribute(attr, value) { + let rotated = attr == "orient" && this.getAttribute("orient") != value; + let context = attr == "context" || attr == "item-context"; + + // This should be done using lookupMethod(), see bug 286629. + const ret = XULElement.prototype.setAttribute.call(this, attr, value); + + if (rotated || context) { + this.relayout({ rotated, context }); + } + + return ret; + } + + /** + * Re-render the view based on the given changes. + * + * Note, changing the dates will wipe the columns of all events, otherwise + * the current events are kept in place. + * + * @param {object} [changes] - The relevant changes to the view. Defaults to + * all changes. + * @property {boolean} dates - A change in the column dates. + * @property {boolean} rotated - A change in the rotation. + * @property {boolean} context - A change in the context menu. + */ + relayout(changes) { + if (!this.mStartDate || !this.mEndDate) { + return; + } + if (!changes) { + changes = { dates: true, rotated: true, context: true }; + } + let scrollMinute = this.scrollMinute; + + const orient = this.getAttribute("orient") || "vertical"; + this.grid.classList.toggle("multiday-grid-rotated", orient == "horizontal"); + + let context = this.getAttribute("context"); + let itemContext = this.getAttribute("item-context") || context; + + for (let dayCol of this.dayColumns) { + dayCol.column.startLayoutBatchChange(); + } + + if (changes.dates) { + const computedDateList = []; + const startDate = this.mStartDate.clone(); + while (startDate.compare(this.mEndDate) <= 0) { + const workday = startDate.clone(); + workday.makeImmutable(); + + if (this.mDisplayDaysOff || !this.mDaysOffArray.includes(startDate.weekday)) { + computedDateList.push(workday); + } + startDate.day += 1; + } + this.mDateList = computedDateList; + + this.grid.style.setProperty("--multiday-num-days", computedDateList.length); + + // Deselect the previously selected event upon switching views, + // otherwise those events will stay selected forever, if other events + // are selected after changing the view. + this.setSelectedItems([], true); + + // Get today's date. + let today = this.today(); + + let dateFormatter = cal.dtz.formatter; + + // Assume the heading widths are no longer valid because the displayed + // dates are likely to change. + // We do not measure them here since we may be hidden. Instead we do so + // in readjustView. + this.headingDatesChanged = true; + let colIndex; + for (colIndex = 0; colIndex < computedDateList.length; colIndex++) { + let dayDate = computedDateList[colIndex]; + let dayCol = this.dayColumns[colIndex]; + if (dayCol) { + dayCol.column.clear(); + dayCol.header.clear(); + } else { + dayCol = {}; + dayCol.container = document.createElement("article"); + dayCol.container.classList.add("day-column-container"); + this.grid.insertBefore(dayCol.container, this.endBorder); + + dayCol.headingContainer = document.createElement("h2"); + dayCol.headingContainer.classList.add("day-column-heading"); + dayCol.longHeading = document.createElement("span"); + dayCol.shortHeading = document.createElement("span"); + dayCol.headingContainer.appendChild(dayCol.longHeading); + dayCol.headingContainer.appendChild(dayCol.shortHeading); + dayCol.container.appendChild(dayCol.headingContainer); + + dayCol.header = document.createXULElement("calendar-header-container"); + dayCol.header.setAttribute("orient", "vertical"); + dayCol.container.appendChild(dayCol.header); + dayCol.header.calendarView = this; + + dayCol.column = document.createXULElement("calendar-event-column"); + dayCol.container.appendChild(dayCol.column); + dayCol.column.calendarView = this; + dayCol.column.startLayoutBatchChange(); + dayCol.column.pixelsPerMinute = this.pixelsPerMinute; + dayCol.column.setDayStartEndHours(this.dayStartHour, this.dayEndHour); + dayCol.column.setAttribute("orient", orient); + dayCol.column.setAttribute("context", context); + dayCol.column.setAttribute("item-context", itemContext); + + this.dayColumns[colIndex] = dayCol; + } + dayCol.date = dayDate.clone(); + dayCol.date.isDate = true; + dayCol.date.makeImmutable(); + + /* Set up day of the week headings. */ + dayCol.shortHeading.textContent = cal.l10n.getCalString("dayHeaderLabel", [ + dateFormatter.shortDayName(dayDate.weekday), + dateFormatter.formatDateWithoutYear(dayDate), + ]); + dayCol.longHeading.textContent = cal.l10n.getCalString("dayHeaderLabel", [ + dateFormatter.dayName(dayDate.weekday), + dateFormatter.formatDateWithoutYear(dayDate), + ]); + + /* Set up all-day header. */ + dayCol.header.date = dayDate; + + /* Set up event column. */ + dayCol.column.date = dayDate; + + /* Set up styling classes for day-off and today. */ + dayCol.container.classList.toggle( + "day-column-weekend", + this.mDaysOffArray.includes(dayDate.weekday) + ); + + let isToday = dayDate.compare(today) == 0; + dayCol.column.timeIndicatorBox.hidden = !isToday; + dayCol.container.classList.toggle("day-column-today", isToday); + } + // Remove excess columns. + for (let dayCol of this.dayColumns.splice(colIndex)) { + dayCol.column.endLayoutBatchChange(); + dayCol.container.remove(); + } + } + + if (changes.rotated) { + this.rotationChanged = true; + for (let dayCol of this.dayColumns) { + dayCol.column.setAttribute("orient", orient); + } + } + + if (changes.context) { + for (let dayCol of this.dayColumns) { + dayCol.column.setAttribute("context", context); + dayCol.column.setAttribute("item-context", itemContext); + } + } + + // Let the columns relayout themselves before we readjust the view. + for (let dayCol of this.dayColumns) { + dayCol.column.endLayoutBatchChange(); + } + + if (changes.dates || changes.rotated) { + // Fix pixels-per-minute and headers, now or when next visible. + this.readjustView(false, false, scrollMinute); + } + + // Store the start and end of current view. Next time when + // setDateRange is called, it will use mViewStart and mViewEnd to + // check if view range has been changed. + this.mViewStart = this.mStartDate; + this.mViewEnd = this.mEndDate; + + let toggleStatus = 0; + + if (this.mTasksInView) { + toggleStatus |= this.mToggleStatusFlag.TasksInView; + } + if (this.mWorkdaysOnly) { + toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly; + } + if (this.mShowCompleted) { + toggleStatus |= this.mToggleStatusFlag.ShowCompleted; + } + + this.mToggleStatus = toggleStatus; + if (changes.dates) { + // Fetch new items for the new dates. + this.refreshItems(true); + } + } + + /** + * Return the column object for a given date. + * + * @param {calIDateTime} date - A date. + * @returns {?DateColumn} A column object. + */ + findColumnForDate(date) { + for (const col of this.dayColumns) { + if (col.date.compare(date) == 0) { + return col; + } + } + return null; + } + + /** + * Return the day box (column header) for a given date. + * + * @param {calIDateTime} date - A date. + * @returns {Element} A `calendar-header-container` where "all day" events appear. + */ + findDayBoxForDate(date) { + const col = this.findColumnForDate(date); + return col && col.header; + } + + /** + * Return the column objects for a given calendar item. + * + * @param {calIItemBase} item - A calendar item. + * @returns {DateColumn[]} An array of column objects. + */ + findColumnsForItem(item) { + const columns = []; + + if (!this.dayColumns.length) { + return columns; + } + + // Note that these may be dates or datetimes. + const startDate = item.startDate || item.entryDate || item.dueDate; + if (!startDate) { + return columns; + } + const timezone = this.dayColumns[0].date.timezone; + let targetDate = startDate.getInTimezone(timezone); + let finishDate = (item.endDate || item.dueDate || item.entryDate || startDate).getInTimezone( + timezone + ); + + if (targetDate.compare(this.mStartDate) < 0) { + targetDate = this.mStartDate.clone(); + } + + if (finishDate.compare(this.mEndDate) > 0) { + finishDate = this.mEndDate.clone(); + finishDate.day++; + } + + // Set the time to 00:00 so that we get all the boxes. + targetDate.isDate = false; + targetDate.hour = 0; + targetDate.minute = 0; + targetDate.second = 0; + + if (targetDate.compare(finishDate) == 0) { + // We have also to handle zero length events in particular for + // tasks without entry or due date. + const col = this.findColumnForDate(targetDate); + if (col) { + columns.push(col); + } + } + + while (targetDate.compare(finishDate) == -1) { + const col = this.findColumnForDate(targetDate); + + // This might not exist if the event spans the view start or end. + if (col) { + columns.push(col); + } + targetDate.day += 1; + } + + return columns; + } + + /** + * Get an ordered list of all the calendar-event-column elements in this + * view. + * + * @returns {MozCalendarEventColumn[]} - The columns in this view. + */ + getEventColumns() { + return Array.from(this.dayColumns, col => col.column); + } + + /** + * Find the calendar-event-column that contains the given node. + * + * @param {Node} node - The node to search for. + * + * @returns {?MozCalendarEventColumn} - The column that contains the node, or + * null if none do. + */ + findEventColumnThatContains(node) { + return this.dayColumns.find(col => col.column.contains(node))?.column; + } + + /** + * Display a calendar item. + * + * @param {calIItemBase} event - A calendar item. + */ + doAddItem(event) { + const cols = this.findColumnsForItem(event); + if (!cols.length) { + return; + } + + for (const col of cols) { + const estart = event.startDate || event.entryDate || event.dueDate; + + if (estart.isDate) { + this.doResizingHeaderOperation(col.header, () => col.header.addEvent(event)); + } else { + col.column.addEvent(event); + } + } + } + + /** + * Remove a calendar item so it is no longer displayed. + * + * @param {calIItemBase} event - A calendar item. + */ + doRemoveItem(event) { + const cols = this.findColumnsForItem(event); + if (!cols.length) { + return; + } + + const oldLength = this.mSelectedItems.length; + this.mSelectedItems = this.mSelectedItems.filter(item => { + return item.hashId != event.hashId; + }); + + for (const col of cols) { + const estart = event.startDate || event.entryDate || event.dueDate; + + if (estart.isDate) { + this.doResizingHeaderOperation(col.header, () => col.header.deleteEvent(event)); + } else { + col.column.deleteEvent(event); + } + } + + // If a deleted event was selected, we need to announce that the selection changed. + if (oldLength != this.mSelectedItems.length) { + this.fireEvent("itemselect", this.mSelectedItems); + } + } + + // CalendarFilteredViewMixin implementation. + + /** + * Removes all items so they are no longer displayed. + */ + clearItems() { + for (let dayCol of this.dayColumns) { + dayCol.column.clear(); + dayCol.header.clear(); + } + } + + /** + * Remove all items for a given calendar so they are no longer displayed. + * + * @param {string} calendarId - The ID of the calendar to remove items from. + */ + removeItemsFromCalendar(calendarId) { + for (const col of this.dayColumns) { + // Get all-day events in column header and events within the column. + const colEvents = col.header.getAllEventItems().concat(col.column.getAllEventItems()); + + for (const event of colEvents) { + if (event.calendar.id == calendarId) { + this.doRemoveItem(event); + } + } + } + } + + // End of CalendarFilteredViewMixin implementation. + + /** + * Clear the pending magic scroll update method. + */ + clearMagicScroll() { + if (this.magicScrollTimer) { + clearTimeout(this.magicScrollTimer); + this.magicScrollTimer = null; + } + } + + /** + * Get the amount to scroll the view by. + * + * @param {number} startDiff - The number of pixels the mouse is from the + * starting edge. + * @param {number} endDiff - The number of pixels the mouse is from the + * ending edge. + * @param {number} scrollzone - The number of pixels from the edge at which + * point scrolling is triggered. + * @param {number} factor - The number of pixels to scroll by if touching + * the edge. + * + * @returns {number} - The number of pixels to scroll by scaled by the depth + * within the scrollzone. Zero if outside the scrollzone, negative if + * we're closer to the starting edge and positive if we're closer to the + * ending edge. + */ + getScrollBy(startDiff, endDiff, scrollzone, factor) { + if (startDiff >= scrollzone && endDiff >= scrollzone) { + return 0; + } else if (startDiff < endDiff) { + return Math.floor((-1 + startDiff / scrollzone) * factor); + } + return Math.ceil((1 - endDiff / scrollzone) * factor); + } + + /** + * Start scrolling the view if the given positions are close to or beyond + * its edge. + * + * Note, any pending updater sent to this method previously will be + * cancelled. + * + * @param {number} clientX - The horizontal viewport position. + * @param {number} clientY - The vertical viewport position. + * @param {Function} updater - A method to call, with some delay, if we + * scroll successfully. + */ + setupMagicScroll(clientX, clientY, updater) { + this.clearMagicScroll(); + + // If we are at the bottom or top of the view (or left/right when + // rotated), calculate the difference and start accelerating the + // scrollbar. + let scrollArea = this.getScrollAreaRect(); + + // Distance the mouse is from the edge. + let diffTop = Math.max(clientY - scrollArea.top, 0); + let diffBottom = Math.max(scrollArea.bottom - clientY, 0); + let diffLeft = Math.max(clientX - scrollArea.left, 0); + let diffRight = Math.max(scrollArea.right - clientX, 0); + + // How close to the edge we need to be to trigger scrolling. + let primaryZone = 50; + let secondaryZone = 20; + // How many pixels to scroll by. + let primaryFactor = Math.max(4 * this.pixelsPerMinute, 8); + let secondaryFactor = 4; + + let left; + let top; + if (this.getAttribute("orient") == "horizontal") { + left = this.getScrollBy(diffLeft, diffRight, primaryZone, primaryFactor); + top = this.getScrollBy(diffTop, diffBottom, secondaryZone, secondaryFactor); + } else { + top = this.getScrollBy(diffTop, diffBottom, primaryZone, primaryFactor); + left = this.getScrollBy(diffLeft, diffRight, secondaryZone, secondaryFactor); + } + + if (top || left) { + this.grid.scrollBy({ top, left, behaviour: "smooth" }); + this.magicScrollTimer = setTimeout(updater, 20); + } + } + + /** + * Get the position of the view's scrollable area (the padding area minus + * sticky headers and scrollbars) in the viewport. + * + * @returns {{top: number, bottom: number, left: number, right: number}} - + * The viewport positions of the respective scrollable area edges. + */ + getScrollAreaRect() { + // We want the viewport coordinates of the view's scrollable area. This is + // the same as the padding area minus the sticky headers and scrollbars. + let scrollTop; + let scrollBottom; + let scrollLeft; + let scrollRight; + let view = this.grid; + let viewRect = view.getBoundingClientRect(); + let headerRect = this.headerCorner.getBoundingClientRect(); + + // paddingTop is the top of the view's padding area. We translate from + // the border area of the view to the padding area by adding clientTop, + // which is the view's top border width. + let paddingTop = viewRect.top + view.clientTop; + // The top of the scroll area is the bottom of the sticky header. + scrollTop = headerRect.bottom; + // To get the bottom we add the clientHeight, which is the height of the + // padding area minus the scrollbar. + scrollBottom = paddingTop + view.clientHeight; + + // paddingLeft is the left of the view's padding area. We translate from + // the border area to the padding area by adding clientLeft, which is the + // left border width (plus the scrollbar in right-to-left). + let paddingLeft = viewRect.left + view.clientLeft; + if (document.dir == "rtl") { + scrollLeft = paddingLeft; + // The right of the scroll area is the left of the sticky header. + scrollRight = headerRect.left; + } else { + // The left of the scroll area is the right of the sticky header. + scrollLeft = headerRect.right; + // To get the right we add the clientWidth, which is the width of the + // padding area minus the scrollbar. + scrollRight = paddingLeft + view.clientWidth; + } + return { top: scrollTop, bottom: scrollBottom, left: scrollLeft, right: scrollRight }; + } + + /** + * Scroll the view to a given minute. + * + * @param {number} minute - The minute to scroll to. + */ + scrollToMinute(minute) { + let pos = Math.round(Math.max(0, minute) * this.pixelsPerMinute); + if (this.getAttribute("orient") == "horizontal") { + this.grid.scrollLeft = document.dir == "rtl" ? -pos : pos; + } else { + this.grid.scrollTop = pos; + } + // NOTE: this.scrollMinute is set by the "scroll" callback. + // This means that if we tried to scroll further than possible, the + // scrollMinute will be capped. + // Also, if pixelsPerMinute < 1, then scrollMinute may differ from the + // given 'minute' due to rounding errors. + } + + /** + * Set the hours when the day starts and ends. + * + * @param {number} dayStartHour - Hour at which the day starts. + * @param {number} dayEndHour - Hour at which the day ends. + */ + setDayStartEndHours(dayStartHour, dayEndHour) { + if (dayStartHour < 0 || dayStartHour > dayEndHour || dayEndHour > 24) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + this.dayStartHour = dayStartHour; + this.dayEndHour = dayEndHour; + // Also update on the timebar. + for (let [hour, hourBox] of this.hourBoxes.entries()) { + hourBox.classList.toggle( + "multiday-hour-box-off-time", + hour < dayStartHour || hour >= dayEndHour + ); + } + for (let dayCol of this.dayColumns) { + dayCol.column.setDayStartEndHours(dayStartHour, dayEndHour); + } + } + + /** + * Set how many hours are visible in the scrollable area. + * + * @param {number} hours - The number of visible hours. + */ + setVisibleHours(hours) { + if (hours <= 0 || hours > 24) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + this.visibleHours = hours; + } + } + + MozElements.CalendarMultidayBaseView = CalendarMultidayBaseView; +} |