/* * Copyright (C) 2013 Apple Inc. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF * THE POSSIBILITY OF SUCH DAMAGE. */ WI.TimelineRuler = class TimelineRuler extends WI.View { constructor() { super(); this.element.classList.add("timeline-ruler"); this._headerElement = document.createElement("div"); this._headerElement.classList.add("header"); this.element.appendChild(this._headerElement); this._markersElement = document.createElement("div"); this._markersElement.classList.add("markers"); this.element.appendChild(this._markersElement); this._zeroTime = 0; this._startTime = 0; this._endTime = 0; this._duration = NaN; this._secondsPerPixel = 0; this._selectionStartTime = 0; this._selectionEndTime = Number.MAX_VALUE; this._endTimePinned = false; this._snapInterval = 0; this._allowsClippedLabels = false; this._allowsTimeRangeSelection = false; this._minimumSelectionDuration = 0.01; this._formatLabelCallback = null; this._timeRangeSelectionChanged = false; this._enabled = true; this._scannerMarker = null; this._markerElementMap = new Map; this._cachedClientWidth = 0; } // Public get enabled() { return this._enabled; } set enabled(x) { if (this._enabled === x) return; this._enabled = x; this.element.classList.toggle(WI.TreeElementStatusButton.DisabledStyleClassName, !this._enabled); } get allowsClippedLabels() { return this._allowsClippedLabels; } set allowsClippedLabels(x) { x = !!x; if (this._allowsClippedLabels === x) return; this._allowsClippedLabels = x; this.needsLayout(); } set formatLabelCallback(x) { console.assert(typeof x === "function" || !x, x); x = x || null; if (this._formatLabelCallback === x) return; this._formatLabelCallback = x; this.needsLayout(); } get allowsTimeRangeSelection() { return this._allowsTimeRangeSelection; } set allowsTimeRangeSelection(x) { x = !!x; if (this._allowsTimeRangeSelection === x) return; this._allowsTimeRangeSelection = x; if (x) { this._clickEventListener = this._handleClick.bind(this); this._doubleClickEventListener = this._handleDoubleClick.bind(this); this._mouseDownEventListener = this._handleMouseDown.bind(this); this.element.addEventListener("click", this._clickEventListener); this.element.addEventListener("dblclick", this._doubleClickEventListener); this.element.addEventListener("mousedown", this._mouseDownEventListener); this._leftShadedAreaElement = document.createElement("div"); this._leftShadedAreaElement.classList.add("shaded-area"); this._leftShadedAreaElement.classList.add("left"); this._rightShadedAreaElement = document.createElement("div"); this._rightShadedAreaElement.classList.add("shaded-area"); this._rightShadedAreaElement.classList.add("right"); this._leftSelectionHandleElement = document.createElement("div"); this._leftSelectionHandleElement.classList.add("selection-handle"); this._leftSelectionHandleElement.classList.add("left"); this._leftSelectionHandleElement.addEventListener("mousedown", this._handleSelectionHandleMouseDown.bind(this)); this._rightSelectionHandleElement = document.createElement("div"); this._rightSelectionHandleElement.classList.add("selection-handle"); this._rightSelectionHandleElement.classList.add("right"); this._rightSelectionHandleElement.addEventListener("mousedown", this._handleSelectionHandleMouseDown.bind(this)); this._selectionDragElement = document.createElement("div"); this._selectionDragElement.classList.add("selection-drag"); this._needsSelectionLayout(); } else { this.element.removeEventListener("click", this._clickEventListener); this.element.removeEventListener("dblclick", this._doubleClickEventListener); this.element.removeEventListener("mousedown", this._mouseDownEventListener); this._clickEventListener = null; this._doubleClickEventListener = null; this._mouseDownEventListener = null; this._leftShadedAreaElement.remove(); this._rightShadedAreaElement.remove(); this._leftSelectionHandleElement.remove(); this._rightSelectionHandleElement.remove(); this._selectionDragElement.remove(); delete this._leftShadedAreaElement; delete this._rightShadedAreaElement; delete this._leftSelectionHandleElement; delete this._rightSelectionHandleElement; delete this._selectionDragElement; } } get minimumSelectionDuration() { return this._minimumSelectionDuration; } set minimumSelectionDuration(x) { this._minimumSelectionDuration = x; } get zeroTime() { return this._zeroTime; } set zeroTime(x) { x = x || 0; if (this._zeroTime === x) return; if (this.entireRangeSelected) this.selectionStartTime = x; this._zeroTime = x; this.needsLayout(); } get startTime() { return this._startTime; } set startTime(x) { x = x || 0; if (this._startTime === x) return; this._startTime = x; if (!isNaN(this._duration)) this._endTime = this._startTime + this._duration; this._currentDividers = null; this.needsLayout(); } get duration() { if (!isNaN(this._duration)) return this._duration; return this.endTime - this.startTime; } get endTime() { if (!this._endTimePinned && this.layoutPending) this._recalculate(); return this._endTime; } set endTime(x) { x = x || 0; if (this._endTime === x) return; this._endTime = x; this._endTimePinned = true; this.needsLayout(); } get secondsPerPixel() { if (this.layoutPending) this._recalculate(); return this._secondsPerPixel; } set secondsPerPixel(x) { x = x || 0; if (this._secondsPerPixel === x) return; this._secondsPerPixel = x; this._endTimePinned = false; this._currentDividers = null; this._currentSliceTime = 0; this.needsLayout(); } get snapInterval() { return this._snapInterval; } set snapInterval(x) { if (this._snapInterval === x) return; this._snapInterval = x; } get selectionStartTime() { return this._selectionStartTime; } set selectionStartTime(x) { x = this._snapValue(x) || 0; if (this._selectionStartTime === x) return; this._selectionStartTime = x; this._timeRangeSelectionChanged = true; this._needsSelectionLayout(); } get selectionEndTime() { return this._selectionEndTime; } set selectionEndTime(x) { x = this._snapValue(x) || 0; if (this._selectionEndTime === x) return; this._selectionEndTime = x; this._timeRangeSelectionChanged = true; this._needsSelectionLayout(); } get entireRangeSelected() { return this._selectionStartTime === this._zeroTime && this._selectionEndTime === Number.MAX_VALUE; } selectEntireRange() { this.selectionStartTime = this._zeroTime; this.selectionEndTime = Number.MAX_VALUE; } addMarker(marker) { console.assert(marker instanceof WI.TimelineMarker); if (this._markerElementMap.has(marker)) return; marker.addEventListener(WI.TimelineMarker.Event.TimeChanged, this._timelineMarkerTimeChanged, this); let markerTime = marker.time - this._startTime; let markerElement = document.createElement("div"); markerElement.classList.add(marker.type, "marker"); switch (marker.type) { case WI.TimelineMarker.Type.LoadEvent: markerElement.title = WI.UIString("Load \u2014 %s").format(Number.secondsToString(markerTime)); break; case WI.TimelineMarker.Type.DOMContentEvent: markerElement.title = WI.UIString("DOM Content Loaded \u2014 %s").format(Number.secondsToString(markerTime)); break; case WI.TimelineMarker.Type.TimeStamp: if (marker.details) markerElement.title = WI.UIString("%s \u2014 %s").format(marker.details, Number.secondsToString(markerTime)); else markerElement.title = WI.UIString("Timestamp \u2014 %s").format(Number.secondsToString(markerTime)); break; } this._markerElementMap.set(marker, markerElement); this._needsMarkerLayout(); } clearMarkers() { for (let [marker, markerElement] of this._markerElementMap) { marker.removeEventListener(WI.TimelineMarker.Event.TimeChanged, this._timelineMarkerTimeChanged, this); markerElement.remove(); } this._markerElementMap.clear(); this._scannerMarker = null; } elementForMarker(marker) { return this._markerElementMap.get(marker) || null; } showScanner(time) { if (!this._scannerMarker) { this._scannerMarker = new WI.TimelineMarker(time, WI.TimelineMarker.Type.Scanner); this.addMarker(this._scannerMarker); } this._scannerMarker.time = time; } hideScanner() { if (this._scannerMarker) this._scannerMarker.time = -1; } updateLayoutIfNeeded(layoutReason) { // If a layout is pending we can let the base class handle it and return, since that will update // markers and the selection at the same time. if (this.layoutPending) { super.updateLayoutIfNeeded(layoutReason); return; } let visibleWidth = this._recalculate(); if (visibleWidth <= 0) return; if (this._scheduledMarkerLayoutUpdateIdentifier) this._updateMarkers(visibleWidth, this.duration); if (this._scheduledSelectionLayoutUpdateIdentifier) this._updateSelection(visibleWidth, this.duration); } needsLayout(layoutReason) { if (this.layoutPending) return; if (this._scheduledMarkerLayoutUpdateIdentifier) { cancelAnimationFrame(this._scheduledMarkerLayoutUpdateIdentifier); this._scheduledMarkerLayoutUpdateIdentifier = undefined; } if (this._scheduledSelectionLayoutUpdateIdentifier) { cancelAnimationFrame(this._scheduledSelectionLayoutUpdateIdentifier); this._scheduledSelectionLayoutUpdateIdentifier = undefined; } super.needsLayout(layoutReason); } // Protected layout() { let visibleWidth = this._recalculate(); if (visibleWidth <= 0) return; let duration = this.duration; let pixelsPerSecond = visibleWidth / duration; // Calculate a divider count based on the maximum allowed divider density. let dividerCount = Math.round(visibleWidth / WI.TimelineRuler.MinimumDividerSpacing); let sliceTime; if (this._endTimePinned || !this._currentSliceTime) { // Calculate the slice time based on the rough divider count and the time span. sliceTime = duration / dividerCount; // Snap the slice time to a nearest number (e.g. 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, etc.) sliceTime = Math.pow(10, Math.ceil(Math.log(sliceTime) / Math.LN10)); if (sliceTime * pixelsPerSecond >= 5 * WI.TimelineRuler.MinimumDividerSpacing) sliceTime = sliceTime / 5; if (sliceTime * pixelsPerSecond >= 2 * WI.TimelineRuler.MinimumDividerSpacing) sliceTime = sliceTime / 2; this._currentSliceTime = sliceTime; } else { // Reuse the last slice time since the time duration does not scale to fit when the end time isn't pinned. sliceTime = this._currentSliceTime; } // Calculate the divider count now based on the final slice time. dividerCount = Math.floor(visibleWidth * this.secondsPerPixel / sliceTime); let firstDividerTime = (Math.ceil((this._startTime - this._zeroTime) / sliceTime) * sliceTime) + this._zeroTime; let lastDividerTime = firstDividerTime + sliceTime * dividerCount; // Make an extra divider in case the last one is partially visible. if (!this._endTimePinned) ++dividerCount; let dividerData = { count: dividerCount, firstTime: firstDividerTime, lastTime: lastDividerTime, }; if (Object.shallowEqual(dividerData, this._currentDividers)) { this._updateMarkers(visibleWidth, duration); this._updateSelection(visibleWidth, duration); return; } this._currentDividers = dividerData; let markerDividers = this._markersElement.querySelectorAll("." + WI.TimelineRuler.DividerElementStyleClassName); let dividerElement = this._headerElement.firstChild; for (var i = 0; i <= dividerCount; ++i) { if (!dividerElement) { dividerElement = document.createElement("div"); dividerElement.className = WI.TimelineRuler.DividerElementStyleClassName; this._headerElement.appendChild(dividerElement); let labelElement = document.createElement("div"); labelElement.className = WI.TimelineRuler.DividerLabelElementStyleClassName; dividerElement.appendChild(labelElement); } let markerDividerElement = markerDividers[i]; if (!markerDividerElement) { markerDividerElement = document.createElement("div"); markerDividerElement.className = WI.TimelineRuler.DividerElementStyleClassName; this._markersElement.appendChild(markerDividerElement); } let dividerTime = firstDividerTime + (sliceTime * i); let newPosition = (dividerTime - this._startTime) / duration; if (!this._allowsClippedLabels) { // Don't allow dividers under 0% where they will be completely hidden. if (newPosition < 0) continue; // When over 100% it is time to stop making/updating dividers. if (newPosition > 1) break; // Don't allow the left-most divider spacing to be so tight it clips. if ((newPosition * visibleWidth) < WI.TimelineRuler.MinimumLeftDividerSpacing) continue; } let property = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "right" : "left"; this._updatePositionOfElement(dividerElement, newPosition, visibleWidth, property); this._updatePositionOfElement(markerDividerElement, newPosition, visibleWidth, property); console.assert(dividerElement.firstChild.classList.contains(WI.TimelineRuler.DividerLabelElementStyleClassName)); dividerElement.firstChild.textContent = isNaN(dividerTime) ? "" : this._formatDividerLabelText(dividerTime - this._zeroTime); dividerElement = dividerElement.nextSibling; } // Remove extra dividers. while (dividerElement) { let nextDividerElement = dividerElement.nextSibling; dividerElement.remove(); dividerElement = nextDividerElement; } for (; i < markerDividers.length; ++i) markerDividers[i].remove(); this._updateMarkers(visibleWidth, duration); this._updateSelection(visibleWidth, duration); } sizeDidChange() { this._cachedClientWidth = this.element.clientWidth; } // Private _needsMarkerLayout() { // If layout is scheduled, abort since markers will be updated when layout happens. if (this.layoutPending) return; if (this._scheduledMarkerLayoutUpdateIdentifier) return; this._scheduledMarkerLayoutUpdateIdentifier = requestAnimationFrame(() => { this._scheduledMarkerLayoutUpdateIdentifier = undefined; let visibleWidth = this._cachedClientWidth; if (visibleWidth <= 0) return; this._updateMarkers(visibleWidth, this.duration); }); } _needsSelectionLayout() { if (!this._allowsTimeRangeSelection) return; // If layout is scheduled, abort since the selection will be updated when layout happens. if (this.layoutPending) return; if (this._scheduledSelectionLayoutUpdateIdentifier) return; this._scheduledSelectionLayoutUpdateIdentifier = requestAnimationFrame(() => { this._scheduledSelectionLayoutUpdateIdentifier = undefined; let visibleWidth = this._cachedClientWidth; if (visibleWidth <= 0) return; this._updateSelection(visibleWidth, this.duration); }); } _recalculate() { let visibleWidth = this._cachedClientWidth; if (visibleWidth <= 0) return 0; let duration; if (this._endTimePinned) duration = this._endTime - this._startTime; else duration = visibleWidth * this._secondsPerPixel; this._secondsPerPixel = duration / visibleWidth; if (!this._endTimePinned) this._endTime = this._startTime + (visibleWidth * this._secondsPerPixel); return visibleWidth; } _updatePositionOfElement(element, newPosition, visibleWidth, property) { newPosition *= this._endTimePinned ? 100 : visibleWidth; let newPositionAprox = Math.round(newPosition * 100); let currentPositionAprox = Math.round(parseFloat(element.style[property]) * 100); if (currentPositionAprox !== newPositionAprox) element.style[property] = (newPositionAprox / 100) + (this._endTimePinned ? "%" : "px"); } _updateMarkers(visibleWidth, duration) { if (this._scheduledMarkerLayoutUpdateIdentifier) { cancelAnimationFrame(this._scheduledMarkerLayoutUpdateIdentifier); this._scheduledMarkerLayoutUpdateIdentifier = undefined; } for (let [marker, markerElement] of this._markerElementMap) { if (marker.time < 0) { markerElement.remove(); continue; } let newPosition = (marker.time - this._startTime) / duration; let property = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "right" : "left"; this._updatePositionOfElement(markerElement, newPosition, visibleWidth, property); if (!markerElement.parentNode) this._markersElement.appendChild(markerElement); } } _updateSelection(visibleWidth, duration) { if (this._scheduledSelectionLayoutUpdateIdentifier) { cancelAnimationFrame(this._scheduledSelectionLayoutUpdateIdentifier); this._scheduledSelectionLayoutUpdateIdentifier = undefined; } this.element.classList.toggle("allows-time-range-selection", this._allowsTimeRangeSelection); if (!this._allowsTimeRangeSelection) return; this.element.classList.toggle("selection-hidden", this.entireRangeSelected); if (this.entireRangeSelected) { this._dispatchTimeRangeSelectionChangedEvent(); return; } let startTimeClamped = this._selectionStartTime < this._startTime || this._selectionStartTime > this._endTime; let endTimeClamped = this._selectionEndTime < this._startTime || this._selectionEndTime > this._endTime; this.element.classList.toggle("both-handles-clamped", startTimeClamped && endTimeClamped); let formattedStartTimeText = this._formatDividerLabelText(this._selectionStartTime - this._zeroTime); let formattedEndTimeText = this._formatDividerLabelText(this._selectionEndTime - this._zeroTime); let startProperty = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "right" : "left"; let newStartPosition = Number.constrain((this._selectionStartTime - this._startTime) / duration, 0, 1); this._updatePositionOfElement(this._leftShadedAreaElement, newStartPosition, visibleWidth, "width"); this._updatePositionOfElement(this._leftSelectionHandleElement, newStartPosition, visibleWidth, startProperty); this._updatePositionOfElement(this._selectionDragElement, newStartPosition, visibleWidth, startProperty); this._leftSelectionHandleElement.classList.toggle("clamped", startTimeClamped); this._leftSelectionHandleElement.classList.toggle("hidden", startTimeClamped && endTimeClamped && this._selectionStartTime < this._startTime); this._leftSelectionHandleElement.title = formattedStartTimeText; let endProperty = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "left" : "right"; let newEndPosition = 1 - Number.constrain((this._selectionEndTime - this._startTime) / duration, 0, 1); this._updatePositionOfElement(this._rightShadedAreaElement, newEndPosition, visibleWidth, "width"); this._updatePositionOfElement(this._rightSelectionHandleElement, newEndPosition, visibleWidth, endProperty); this._updatePositionOfElement(this._selectionDragElement, newEndPosition, visibleWidth, endProperty); this._rightSelectionHandleElement.classList.toggle("clamped", endTimeClamped); this._rightSelectionHandleElement.classList.toggle("hidden", startTimeClamped && endTimeClamped && this._selectionEndTime > this._endTime); this._rightSelectionHandleElement.title = formattedEndTimeText; if (!this._selectionDragElement.parentNode) { this.element.appendChild(this._selectionDragElement); this.element.appendChild(this._leftShadedAreaElement); this.element.appendChild(this._leftSelectionHandleElement); this.element.appendChild(this._rightShadedAreaElement); this.element.appendChild(this._rightSelectionHandleElement); } this._dispatchTimeRangeSelectionChangedEvent(); } _formatDividerLabelText(value) { if (this._formatLabelCallback) return this._formatLabelCallback(value); return Number.secondsToString(value, true); } _snapValue(value) { if (!value || !this.snapInterval) return value; return Math.round(value / this.snapInterval) * this.snapInterval; } _dispatchTimeRangeSelectionChangedEvent() { if (!this._timeRangeSelectionChanged) return; this._timeRangeSelectionChanged = false; this.dispatchEventToListeners(WI.TimelineRuler.Event.TimeRangeSelectionChanged); } _timelineMarkerTimeChanged() { this._needsMarkerLayout(); } _shouldIgnoreMicroMovement(event) { if (this._mousePassedMicroMovementTest) return false; let pixels = Math.abs(event.pageX - this._mouseStartX); if (pixels <= 4) return true; this._mousePassedMicroMovementTest = true; return false; } _handleClick(event) { if (!this._enabled) return; if (this._mouseMoved) return; for (let newTarget of document.elementsFromPoint(event.pageX, event.pageY)) { if (!newTarget || typeof newTarget.click !== "function") continue; if (this.element.contains(newTarget)) continue; // Clone the event to dispatch it on the new element. let newEvent = new event.constructor(event.type, event); newTarget.dispatchEvent(newEvent); if (newEvent.__timelineRecordClickEventHandled) event.stop(); return; } } _handleDoubleClick(event) { if (this.entireRangeSelected) return; this.selectEntireRange(); } _handleMouseDown(event) { // Only handle left mouse clicks. if (event.button !== 0 || event.ctrlKey) return; this._selectionIsMove = event.target === this._selectionDragElement; this._rulerBoundingClientRect = this.element.getBoundingClientRect(); if (this._selectionIsMove) { this._lastMousePosition = event.pageX; var selectionDragElementRect = this._selectionDragElement.getBoundingClientRect(); this._moveSelectionMaximumLeftOffset = this._rulerBoundingClientRect.left + (event.pageX - selectionDragElementRect.left); this._moveSelectionMaximumRightOffset = this._rulerBoundingClientRect.right - (selectionDragElementRect.right - event.pageX); } else { if (WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL) this._mouseDownPosition = this._rulerBoundingClientRect.right - event.pageX; else this._mouseDownPosition = event.pageX - this._rulerBoundingClientRect.left; } this._mouseMoved = false; this._mousePassedMicroMovementTest = false; this._mouseStartX = event.pageX; this._mouseMoveEventListener = this._handleMouseMove.bind(this); this._mouseUpEventListener = this._handleMouseUp.bind(this); // Register these listeners on the document so we can track the mouse if it leaves the ruler. document.addEventListener("mousemove", this._mouseMoveEventListener); document.addEventListener("mouseup", this._mouseUpEventListener); event.preventDefault(); event.stopPropagation(); } _handleMouseMove(event) { console.assert(event.button === 0); if (this._shouldIgnoreMicroMovement(event)) return; this._mouseMoved = true; let isRTL = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL; let currentMousePosition; if (this._selectionIsMove) { currentMousePosition = Math.max(this._moveSelectionMaximumLeftOffset, Math.min(this._moveSelectionMaximumRightOffset, event.pageX)); let positionDelta = 0; if (isRTL) positionDelta = this._lastMousePosition - currentMousePosition; else positionDelta = currentMousePosition - this._lastMousePosition; let offsetTime = positionDelta * this.secondsPerPixel; let selectionDuration = this.selectionEndTime - this.selectionStartTime; let oldSelectionStartTime = this.selectionStartTime; this.selectionStartTime = Math.max(this.startTime, Math.min(this.selectionStartTime + offsetTime, this.endTime - selectionDuration)); this.selectionEndTime = this.selectionStartTime + selectionDuration; if (this.snapInterval) { // When snapping we need to check the mouse position delta relative to the last snap, rather than the // last mouse move. If a snap occurs we adjust for the amount the cursor drifted, so that the mouse // position relative to the selection remains constant. let snapOffset = this.selectionStartTime - oldSelectionStartTime; if (!snapOffset) return; let positionDrift = (offsetTime - snapOffset * this.snapInterval) / this.secondsPerPixel; currentMousePosition -= positionDrift; } this._lastMousePosition = currentMousePosition; } else { if (isRTL) currentMousePosition = this._rulerBoundingClientRect.right - event.pageX; else currentMousePosition = event.pageX - this._rulerBoundingClientRect.left; this.selectionStartTime = Math.max(this.startTime, this.startTime + (Math.min(currentMousePosition, this._mouseDownPosition) * this.secondsPerPixel)); this.selectionEndTime = Math.min(this.startTime + (Math.max(currentMousePosition, this._mouseDownPosition) * this.secondsPerPixel), this.endTime); // Turn on col-resize cursor style once dragging begins, rather than on the initial mouse down. this.element.classList.add(WI.TimelineRuler.ResizingSelectionStyleClassName); } this._updateSelection(this._cachedClientWidth, this.duration); event.preventDefault(); event.stopPropagation(); } _handleMouseUp(event) { console.assert(event.button === 0); if (!this._selectionIsMove) { this.element.classList.remove(WI.TimelineRuler.ResizingSelectionStyleClassName); if (this.selectionEndTime - this.selectionStartTime < this.minimumSelectionDuration) { // The section is smaller than allowed, grow in the direction of the drag to meet the minumum. let currentMousePosition = 0; if (WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL) currentMousePosition = this._rulerBoundingClientRect.right - event.pageX; else currentMousePosition = event.pageX - this._rulerBoundingClientRect.left; if (currentMousePosition > this._mouseDownPosition) { this.selectionEndTime = Math.min(this.selectionStartTime + this.minimumSelectionDuration, this.endTime); this.selectionStartTime = this.selectionEndTime - this.minimumSelectionDuration; } else { this.selectionStartTime = Math.max(this.startTime, this.selectionEndTime - this.minimumSelectionDuration); this.selectionEndTime = this.selectionStartTime + this.minimumSelectionDuration; } } } this._dispatchTimeRangeSelectionChangedEvent(); document.removeEventListener("mousemove", this._mouseMoveEventListener); document.removeEventListener("mouseup", this._mouseUpEventListener); delete this._mouseMoveEventListener; delete this._mouseUpEventListener; delete this._mouseDownPosition; delete this._lastMousePosition; delete this._selectionIsMove; delete this._rulerBoundingClientRect; delete this._moveSelectionMaximumLeftOffset; delete this._moveSelectionMaximumRightOffset; event.preventDefault(); event.stopPropagation(); } _handleSelectionHandleMouseDown(event) { // Only handle left mouse clicks. if (event.button !== 0 || event.ctrlKey) return; this._dragHandleIsStartTime = event.target === this._leftSelectionHandleElement; if (WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL) this._mouseDownPosition = this.element.totalOffsetRight - event.pageX; else this._mouseDownPosition = event.pageX - this.element.totalOffsetLeft; this._selectionHandleMouseMoveEventListener = this._handleSelectionHandleMouseMove.bind(this); this._selectionHandleMouseUpEventListener = this._handleSelectionHandleMouseUp.bind(this); // Register these listeners on the document so we can track the mouse if it leaves the ruler. document.addEventListener("mousemove", this._selectionHandleMouseMoveEventListener); document.addEventListener("mouseup", this._selectionHandleMouseUpEventListener); this.element.classList.add(WI.TimelineRuler.ResizingSelectionStyleClassName); event.preventDefault(); event.stopPropagation(); } _handleSelectionHandleMouseMove(event) { console.assert(event.button === 0); let currentMousePosition = 0; if (WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL) currentMousePosition = this.element.totalOffsetRight - event.pageX; else currentMousePosition = event.pageX - this.element.totalOffsetLeft; let currentTime = this.startTime + (currentMousePosition * this.secondsPerPixel); if (this.snapInterval) currentTime = this._snapValue(currentTime); if (event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) { // Resize the selection on both sides when the Option keys is held down. if (this._dragHandleIsStartTime) { let timeDifference = currentTime - this.selectionStartTime; this.selectionStartTime = Math.max(this.startTime, Math.min(currentTime, this.selectionEndTime - this.minimumSelectionDuration)); this.selectionEndTime = Math.min(Math.max(this.selectionStartTime + this.minimumSelectionDuration, this.selectionEndTime - timeDifference), this.endTime); } else { let timeDifference = currentTime - this.selectionEndTime; this.selectionEndTime = Math.min(Math.max(this.selectionStartTime + this.minimumSelectionDuration, currentTime), this.endTime); this.selectionStartTime = Math.max(this.startTime, Math.min(this.selectionStartTime - timeDifference, this.selectionEndTime - this.minimumSelectionDuration)); } } else { // Resize the selection on side being dragged. if (this._dragHandleIsStartTime) this.selectionStartTime = Math.max(this.startTime, Math.min(currentTime, this.selectionEndTime - this.minimumSelectionDuration)); else this.selectionEndTime = Math.min(Math.max(this.selectionStartTime + this.minimumSelectionDuration, currentTime), this.endTime); } this._updateSelection(this._cachedClientWidth, this.duration); event.preventDefault(); event.stopPropagation(); } _handleSelectionHandleMouseUp(event) { console.assert(event.button === 0); this.element.classList.remove(WI.TimelineRuler.ResizingSelectionStyleClassName); document.removeEventListener("mousemove", this._selectionHandleMouseMoveEventListener); document.removeEventListener("mouseup", this._selectionHandleMouseUpEventListener); delete this._selectionHandleMouseMoveEventListener; delete this._selectionHandleMouseUpEventListener; delete this._dragHandleIsStartTime; delete this._mouseDownPosition; event.preventDefault(); event.stopPropagation(); } }; WI.TimelineRuler.MinimumLeftDividerSpacing = 48; WI.TimelineRuler.MinimumDividerSpacing = 64; WI.TimelineRuler.ResizingSelectionStyleClassName = "resizing-selection"; WI.TimelineRuler.DividerElementStyleClassName = "divider"; WI.TimelineRuler.DividerLabelElementStyleClassName = "label"; WI.TimelineRuler.Event = { TimeRangeSelectionChanged: "time-ruler-time-range-selection-changed" };