| // Copyright 2017 The Chromium Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| Timeline.TimelineHistoryManager = class { | |
| constructor() { | |
| /** @type {!Array<!Timeline.PerformanceModel>} */ | |
| this._recordings = []; | |
| this._action = /** @type {!UI.Action} */ (UI.actionRegistry.action('timeline.show-history')); | |
| /** @type {!Map<string, number>} */ | |
| this._nextNumberByDomain = new Map(); | |
| this._button = new Timeline.TimelineHistoryManager.ToolbarButton(this._action); | |
| UI.ARIAUtils.markAsMenuButton(this._button.element); | |
| this.clear(); | |
| this._allOverviews = [ | |
| {constructor: Timeline.TimelineEventOverviewResponsiveness, height: 3}, | |
| {constructor: Timeline.TimelineEventOverviewFrames, height: 16}, | |
| {constructor: Timeline.TimelineEventOverviewCPUActivity, height: 20}, | |
| {constructor: Timeline.TimelineEventOverviewNetwork, height: 8} | |
| ]; | |
| this._totalHeight = this._allOverviews.reduce((acc, entry) => acc + entry.height, 0); | |
| this._enabled = true; | |
| /** @type {?Timeline.PerformanceModel} */ | |
| this._lastActiveModel = null; | |
| } | |
| /** | |
| * @param {!Timeline.PerformanceModel} performanceModel | |
| */ | |
| addRecording(performanceModel) { | |
| this._lastActiveModel = performanceModel; | |
| this._recordings.unshift(performanceModel); | |
| this._buildPreview(performanceModel); | |
| const modelTitle = this._title(performanceModel); | |
| this._button.setText(modelTitle); | |
| const buttonTitle = this._action.title(); | |
| UI.ARIAUtils.setAccessibleName(this._button.element, ls`Current Session: ${modelTitle}. ${buttonTitle}`); | |
| this._updateState(); | |
| if (this._recordings.length <= Timeline.TimelineHistoryManager._maxRecordings) { | |
| return; | |
| } | |
| const lruModel = this._recordings.reduce((a, b) => lastUsedTime(a) < lastUsedTime(b) ? a : b); | |
| this._recordings.splice(this._recordings.indexOf(lruModel), 1); | |
| lruModel.dispose(); | |
| /** | |
| * @param {!Timeline.PerformanceModel} model | |
| * @return {number} | |
| */ | |
| function lastUsedTime(model) { | |
| return Timeline.TimelineHistoryManager._dataForModel(model).lastUsed; | |
| } | |
| } | |
| /** | |
| * @param {boolean} enabled | |
| */ | |
| setEnabled(enabled) { | |
| this._enabled = enabled; | |
| this._updateState(); | |
| } | |
| button() { | |
| return this._button; | |
| } | |
| clear() { | |
| this._recordings.forEach(model => model.dispose()); | |
| this._recordings = []; | |
| this._lastActiveModel = null; | |
| this._updateState(); | |
| this._button.setText(Common.UIString('(no recordings)')); | |
| this._nextNumberByDomain.clear(); | |
| } | |
| /** | |
| * @return {!Promise<?Timeline.PerformanceModel>} | |
| */ | |
| async showHistoryDropDown() { | |
| if (this._recordings.length < 2 || !this._enabled) { | |
| return null; | |
| } | |
| // DropDown.show() function finishes when the dropdown menu is closed via selection or losing focus | |
| const model = await Timeline.TimelineHistoryManager.DropDown.show( | |
| this._recordings, /** @type {!Timeline.PerformanceModel} */ (this._lastActiveModel), this._button.element); | |
| if (!model) { | |
| return null; | |
| } | |
| const index = this._recordings.indexOf(model); | |
| if (index < 0) { | |
| console.assert(false, `selected recording not found`); | |
| return null; | |
| } | |
| this._setCurrentModel(model); | |
| return model; | |
| } | |
| cancelIfShowing() { | |
| Timeline.TimelineHistoryManager.DropDown.cancelIfShowing(); | |
| } | |
| /** | |
| * @param {number} direction | |
| * @return {?Timeline.PerformanceModel} | |
| */ | |
| navigate(direction) { | |
| if (!this._enabled || !this._lastActiveModel) { | |
| return null; | |
| } | |
| const index = this._recordings.indexOf(this._lastActiveModel); | |
| if (index < 0) { | |
| return null; | |
| } | |
| const newIndex = Number.constrain(index + direction, 0, this._recordings.length - 1); | |
| const model = this._recordings[newIndex]; | |
| this._setCurrentModel(model); | |
| return model; | |
| } | |
| /** | |
| * @param {!Timeline.PerformanceModel} model | |
| */ | |
| _setCurrentModel(model) { | |
| Timeline.TimelineHistoryManager._dataForModel(model).lastUsed = Date.now(); | |
| this._lastActiveModel = model; | |
| const modelTitle = this._title(model); | |
| const buttonTitle = this._action.title(); | |
| this._button.setText(modelTitle); | |
| UI.ARIAUtils.setAccessibleName(this._button.element, ls`Current Session: ${modelTitle}. ${buttonTitle}`); | |
| } | |
| _updateState() { | |
| this._action.setEnabled(this._recordings.length > 1 && this._enabled); | |
| } | |
| /** | |
| * @param {!Timeline.PerformanceModel} performanceModel | |
| * @return {!Element} | |
| */ | |
| static _previewElement(performanceModel) { | |
| const data = Timeline.TimelineHistoryManager._dataForModel(performanceModel); | |
| const startedAt = performanceModel.recordStartTime(); | |
| data.time.textContent = | |
| startedAt ? Common.UIString('(%s ago)', Timeline.TimelineHistoryManager._coarseAge(startedAt)) : ''; | |
| return data.preview; | |
| } | |
| /** | |
| * @param {number} time | |
| * @return {string} | |
| */ | |
| static _coarseAge(time) { | |
| const seconds = Math.round((Date.now() - time) / 1000); | |
| if (seconds < 50) { | |
| return Common.UIString('moments'); | |
| } | |
| const minutes = Math.round(seconds / 60); | |
| if (minutes < 50) { | |
| return Common.UIString('%s m', minutes); | |
| } | |
| const hours = Math.round(minutes / 60); | |
| return Common.UIString('%s h', hours); | |
| } | |
| /** | |
| * @param {!Timeline.PerformanceModel} performanceModel | |
| * @return {string} | |
| */ | |
| _title(performanceModel) { | |
| return Timeline.TimelineHistoryManager._dataForModel(performanceModel).title; | |
| } | |
| /** | |
| * @param {!Timeline.PerformanceModel} performanceModel | |
| */ | |
| _buildPreview(performanceModel) { | |
| const parsedURL = Common.ParsedURL.fromString(performanceModel.timelineModel().pageURL()); | |
| const domain = parsedURL ? parsedURL.host : ''; | |
| const sequenceNumber = this._nextNumberByDomain.get(domain) || 1; | |
| const title = Common.UIString('%s #%d', domain, sequenceNumber); | |
| this._nextNumberByDomain.set(domain, sequenceNumber + 1); | |
| const timeElement = createElement('span'); | |
| const preview = createElementWithClass('div', 'preview-item vbox'); | |
| const data = {preview: preview, title: title, time: timeElement, lastUsed: Date.now()}; | |
| performanceModel[Timeline.TimelineHistoryManager._previewDataSymbol] = data; | |
| preview.appendChild(this._buildTextDetails(performanceModel, title, timeElement)); | |
| const screenshotAndOverview = preview.createChild('div', 'hbox'); | |
| screenshotAndOverview.appendChild(this._buildScreenshotThumbnail(performanceModel)); | |
| screenshotAndOverview.appendChild(this._buildOverview(performanceModel)); | |
| return data.preview; | |
| } | |
| /** | |
| * @param {!Timeline.PerformanceModel} performanceModel | |
| * @param {string} title | |
| * @param {!Element} timeElement | |
| * @return {!Element} | |
| */ | |
| _buildTextDetails(performanceModel, title, timeElement) { | |
| const container = createElementWithClass('div', 'text-details hbox'); | |
| const nameSpan = container.createChild('span', 'name'); | |
| nameSpan.textContent = title; | |
| UI.ARIAUtils.setAccessibleName(nameSpan, title); | |
| const tracingModel = performanceModel.tracingModel(); | |
| const duration = Number.millisToString(tracingModel.maximumRecordTime() - tracingModel.minimumRecordTime(), false); | |
| const timeContainer = container.createChild('span', 'time'); | |
| timeContainer.appendChild(createTextNode(duration)); | |
| timeContainer.appendChild(timeElement); | |
| return container; | |
| } | |
| /** | |
| * @param {!Timeline.PerformanceModel} performanceModel | |
| * @return {!Element} | |
| */ | |
| _buildScreenshotThumbnail(performanceModel) { | |
| const container = createElementWithClass('div', 'screenshot-thumb'); | |
| const thumbnailAspectRatio = 3 / 2; | |
| container.style.width = this._totalHeight * thumbnailAspectRatio + 'px'; | |
| container.style.height = this._totalHeight + 'px'; | |
| const filmStripModel = performanceModel.filmStripModel(); | |
| const lastFrame = filmStripModel.frames().peekLast(); | |
| if (!lastFrame) { | |
| return container; | |
| } | |
| lastFrame.imageDataPromise() | |
| .then(data => UI.loadImageFromData(data)) | |
| .then(image => image && container.appendChild(image)); | |
| return container; | |
| } | |
| /** | |
| * @param {!Timeline.PerformanceModel} performanceModel | |
| * @return {!Element} | |
| */ | |
| _buildOverview(performanceModel) { | |
| const container = createElement('div'); | |
| container.style.width = Timeline.TimelineHistoryManager._previewWidth + 'px'; | |
| container.style.height = this._totalHeight + 'px'; | |
| const canvas = container.createChild('canvas'); | |
| canvas.width = window.devicePixelRatio * Timeline.TimelineHistoryManager._previewWidth; | |
| canvas.height = window.devicePixelRatio * this._totalHeight; | |
| const ctx = canvas.getContext('2d'); | |
| let yOffset = 0; | |
| for (const overview of this._allOverviews) { | |
| const timelineOverview = new overview.constructor(); | |
| timelineOverview.setCanvasSize(Timeline.TimelineHistoryManager._previewWidth, overview.height); | |
| timelineOverview.setModel(performanceModel); | |
| timelineOverview.update(); | |
| const sourceContext = timelineOverview.context(); | |
| const imageData = sourceContext.getImageData(0, 0, sourceContext.canvas.width, sourceContext.canvas.height); | |
| ctx.putImageData(imageData, 0, yOffset); | |
| yOffset += overview.height * window.devicePixelRatio; | |
| } | |
| return container; | |
| } | |
| /** | |
| * @param {!Timeline.PerformanceModel} model | |
| * @return {?Timeline.TimelineHistoryManager.PreviewData} | |
| */ | |
| static _dataForModel(model) { | |
| return model[Timeline.TimelineHistoryManager._previewDataSymbol] || null; | |
| } | |
| }; | |
| /** @typedef {!{preview: !Element, time: !Element, lastUsed: number, title: string}} */ | |
| Timeline.TimelineHistoryManager.PreviewData; | |
| Timeline.TimelineHistoryManager._maxRecordings = 5; | |
| Timeline.TimelineHistoryManager._previewWidth = 450; | |
| Timeline.TimelineHistoryManager._previewDataSymbol = Symbol('previewData'); | |
| /** | |
| * @implements {UI.ListDelegate<!Timeline.PerformanceModel>} | |
| */ | |
| Timeline.TimelineHistoryManager.DropDown = class { | |
| /** | |
| * @param {!Array<!Timeline.PerformanceModel>} models | |
| */ | |
| constructor(models) { | |
| this._glassPane = new UI.GlassPane(); | |
| this._glassPane.setSizeBehavior(UI.GlassPane.SizeBehavior.MeasureContent); | |
| this._glassPane.setOutsideClickCallback(() => this._close(null)); | |
| this._glassPane.setPointerEventsBehavior(UI.GlassPane.PointerEventsBehavior.BlockedByGlassPane); | |
| this._glassPane.setAnchorBehavior(UI.GlassPane.AnchorBehavior.PreferBottom); | |
| this._glassPane.element.addEventListener('blur', () => this._close(null)); | |
| const shadowRoot = | |
| UI.createShadowRootWithCoreStyles(this._glassPane.contentElement, 'timeline/timelineHistoryManager.css'); | |
| const contentElement = shadowRoot.createChild('div', 'drop-down'); | |
| const listModel = new UI.ListModel(); | |
| this._listControl = new UI.ListControl(listModel, this, UI.ListMode.NonViewport); | |
| this._listControl.element.addEventListener('mousemove', this._onMouseMove.bind(this), false); | |
| listModel.replaceAll(models); | |
| UI.ARIAUtils.markAsMenu(this._listControl.element); | |
| UI.ARIAUtils.setAccessibleName(this._listControl.element, ls`Select Timeline Session`); | |
| contentElement.appendChild(this._listControl.element); | |
| contentElement.addEventListener('keydown', this._onKeyDown.bind(this), false); | |
| contentElement.addEventListener('click', this._onClick.bind(this), false); | |
| this._focusRestorer = new UI.ElementFocusRestorer(this._listControl.element); | |
| /** @type {?function(?Timeline.PerformanceModel)} */ | |
| this._selectionDone = null; | |
| } | |
| /** | |
| * @param {!Array<!Timeline.PerformanceModel>} models | |
| * @param {!Timeline.PerformanceModel} currentModel | |
| * @param {!Element} anchor | |
| * @return {!Promise<?Timeline.PerformanceModel>} | |
| */ | |
| static show(models, currentModel, anchor) { | |
| if (Timeline.TimelineHistoryManager.DropDown._instance) { | |
| return Promise.resolve(/** @type {?Timeline.PerformanceModel} */ (null)); | |
| } | |
| const instance = new Timeline.TimelineHistoryManager.DropDown(models); | |
| return instance._show(anchor, currentModel); | |
| } | |
| static cancelIfShowing() { | |
| if (!Timeline.TimelineHistoryManager.DropDown._instance) { | |
| return; | |
| } | |
| Timeline.TimelineHistoryManager.DropDown._instance._close(null); | |
| } | |
| /** | |
| * @param {!Element} anchor | |
| * @param {!Timeline.PerformanceModel} currentModel | |
| * @return {!Promise<?Timeline.PerformanceModel>} | |
| */ | |
| _show(anchor, currentModel) { | |
| Timeline.TimelineHistoryManager.DropDown._instance = this; | |
| this._glassPane.setContentAnchorBox(anchor.boxInWindow()); | |
| this._glassPane.show(/** @type {!Document} */ (this._glassPane.contentElement.ownerDocument)); | |
| this._listControl.element.focus(); | |
| this._listControl.selectItem(currentModel); | |
| return new Promise(fulfill => this._selectionDone = fulfill); | |
| } | |
| /** | |
| * @param {!Event} event | |
| */ | |
| _onMouseMove(event) { | |
| const node = event.target.enclosingNodeOrSelfWithClass('preview-item'); | |
| const listItem = node && this._listControl.itemForNode(node); | |
| if (!listItem) { | |
| return; | |
| } | |
| this._listControl.selectItem(listItem); | |
| } | |
| /** | |
| * @param {!Event} event | |
| */ | |
| _onClick(event) { | |
| if (!event.target.enclosingNodeOrSelfWithClass('preview-item')) { | |
| return; | |
| } | |
| this._close(this._listControl.selectedItem()); | |
| } | |
| /** | |
| * @param {!Event} event | |
| */ | |
| _onKeyDown(event) { | |
| switch (event.key) { | |
| case 'Tab': | |
| case 'Escape': | |
| this._close(null); | |
| break; | |
| case 'Enter': | |
| this._close(this._listControl.selectedItem()); | |
| break; | |
| default: | |
| return; | |
| } | |
| event.consume(true); | |
| } | |
| /** | |
| * @param {?Timeline.PerformanceModel} model | |
| */ | |
| _close(model) { | |
| this._selectionDone(model); | |
| this._focusRestorer.restore(); | |
| this._glassPane.hide(); | |
| Timeline.TimelineHistoryManager.DropDown._instance = null; | |
| } | |
| /** | |
| * @override | |
| * @param {!Timeline.PerformanceModel} item | |
| * @return {!Element} | |
| */ | |
| createElementForItem(item) { | |
| const element = Timeline.TimelineHistoryManager._previewElement(item); | |
| UI.ARIAUtils.markAsMenuItem(element); | |
| element.classList.remove('selected'); | |
| return element; | |
| } | |
| /** | |
| * @override | |
| * @param {!Timeline.PerformanceModel} item | |
| * @return {number} | |
| */ | |
| heightForItem(item) { | |
| console.assert(false, 'Should not be called'); | |
| return 0; | |
| } | |
| /** | |
| * @override | |
| * @param {!Timeline.PerformanceModel} item | |
| * @return {boolean} | |
| */ | |
| isItemSelectable(item) { | |
| return true; | |
| } | |
| /** | |
| * @override | |
| * @param {?Timeline.PerformanceModel} from | |
| * @param {?Timeline.PerformanceModel} to | |
| * @param {?Element} fromElement | |
| * @param {?Element} toElement | |
| */ | |
| selectedItemChanged(from, to, fromElement, toElement) { | |
| if (fromElement) { | |
| fromElement.classList.remove('selected'); | |
| } | |
| if (toElement) { | |
| toElement.classList.add('selected'); | |
| } | |
| } | |
| /** | |
| * @override | |
| * @param {?Element} fromElement | |
| * @param {?Element} toElement | |
| * @return {boolean} | |
| */ | |
| updateSelectedItemARIA(fromElement, toElement) { | |
| return false; | |
| } | |
| }; | |
| /** | |
| * @type {?Timeline.TimelineHistoryManager.DropDown} | |
| */ | |
| Timeline.TimelineHistoryManager.DropDown._instance = null; | |
| Timeline.TimelineHistoryManager.ToolbarButton = class extends UI.ToolbarItem { | |
| /** | |
| * @param {!UI.Action} action | |
| */ | |
| constructor(action) { | |
| super(createElementWithClass('button', 'history-dropdown-button')); | |
| UI.appendStyle(this.element, 'timeline/historyToolbarButton.css'); | |
| this._contentElement = this.element.createChild('span', 'content'); | |
| const dropdownArrowIcon = UI.Icon.create('smallicon-triangle-down'); | |
| this.element.appendChild(dropdownArrowIcon); | |
| this.element.addEventListener('click', () => void action.execute(), false); | |
| this.setEnabled(action.enabled()); | |
| action.addEventListener(UI.Action.Events.Enabled, event => this.setEnabled(/** @type {boolean} */ (event.data))); | |
| this.setTitle(action.title()); | |
| } | |
| /** | |
| * @param {string} text | |
| */ | |
| setText(text) { | |
| this._contentElement.textContent = text; | |
| } | |
| }; |