UNPKG

chrome-devtools-frontend

Version:
376 lines (337 loc) • 11.2 kB
// Copyright 2015 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. import * as Host from '../host/host.js'; import * as i18n from '../i18n/i18n.js'; import * as Platform from '../platform/platform.js'; import * as SDK from '../sdk/sdk.js'; // eslint-disable-line no-unused-vars import * as UI from '../ui/ui.js'; export const UIStrings = { /** *@description Element title in Film Strip View of the Performance panel */ doubleclickToZoomImageClickTo: 'Doubleclick to zoom image. Click to view preceding requests.', /** *@description Aria label for captured screenshots in network panel. *@example {3ms} PH1 */ screenshotForSSelectToView: 'Screenshot for {PH1} - select to view preceding requests.', /** *@description Text for one or a group of screenshots */ screenshot: 'Screenshot', /** *@description Prev button title in Film Strip View of the Performance panel */ previousFrame: 'Previous frame', /** *@description Next button title in Film Strip View of the Performance panel */ nextFrame: 'Next frame', }; const str_ = i18n.i18n.registerUIStrings('perf_ui/FilmStripView.js', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export class FilmStripView extends UI.Widget.HBox { constructor() { super(true); this.registerRequiredCSS('perf_ui/filmStripView.css', {enableLegacyPatching: true}); this.contentElement.classList.add('film-strip-view'); this._statusLabel = this.contentElement.createChild('div', 'label'); this.reset(); this.setMode(Modes.TimeBased); /** @type {number} */ this._zeroTime; /** @type {number} */ this._spanTime; /** @type {!SDK.FilmStripModel.FilmStripModel} */ this._model; } /** * @param {!HTMLImageElement} imageElement * @param {?string} data */ static _setImageData(imageElement, data) { if (data) { imageElement.src = 'data:image/jpg;base64,' + data; } } /** * @param {string} mode */ setMode(mode) { this._mode = mode; this.contentElement.classList.toggle('time-based', mode === Modes.TimeBased); this.update(); } /** * @param {!SDK.FilmStripModel.FilmStripModel} filmStripModel * @param {number} zeroTime * @param {number} spanTime */ setModel(filmStripModel, zeroTime, spanTime) { this._model = filmStripModel; this._zeroTime = zeroTime; this._spanTime = spanTime; const frames = filmStripModel.frames(); if (!frames.length) { this.reset(); return; } this.update(); } /** * @param {!SDK.FilmStripModel.Frame} frame * @return {!Promise<!Element>} */ createFrameElement(frame) { const time = frame.timestamp; const frameTime = Number.millisToString(time - this._zeroTime); const element = document.createElement('div'); element.classList.add('frame'); UI.Tooltip.Tooltip.install(element, i18nString(UIStrings.doubleclickToZoomImageClickTo)); element.createChild('div', 'time').textContent = frameTime; element.tabIndex = 0; element.setAttribute('aria-label', i18nString(UIStrings.screenshotForSSelectToView, {PH1: frameTime})); UI.ARIAUtils.markAsButton(element); const imageElement = /** @type {!HTMLImageElement} */ (element.createChild('div', 'thumbnail').createChild('img')); imageElement.alt = i18nString(UIStrings.screenshot); element.addEventListener('mousedown', this._onMouseEvent.bind(this, Events.FrameSelected, time), false); element.addEventListener('mouseenter', this._onMouseEvent.bind(this, Events.FrameEnter, time), false); element.addEventListener('mouseout', this._onMouseEvent.bind(this, Events.FrameExit, time), false); element.addEventListener('dblclick', this._onDoubleClick.bind(this, frame), false); element.addEventListener('focusin', this._onMouseEvent.bind(this, Events.FrameEnter, time), false); element.addEventListener('focusout', this._onMouseEvent.bind(this, Events.FrameExit, time), false); element.addEventListener('keydown', event => { if (event.code === 'Enter' || event.code === 'Space') { this._onMouseEvent(Events.FrameSelected, time); } }); return frame.imageDataPromise().then(FilmStripView._setImageData.bind(null, imageElement)).then(returnElement); /** * @return {!Element} */ function returnElement() { return element; } } /** * @param {number} time * @return {!SDK.FilmStripModel.Frame} */ frameByTime(time) { /** * @param {number} time * @param {!SDK.FilmStripModel.Frame} frame * @return {number} */ function comparator(time, frame) { return time - frame.timestamp; } // Using the first frame to fill the interval between recording start // and a moment the frame is taken. const frames = this._model.frames(); const index = Math.max(Platform.ArrayUtilities.upperBound(frames, time, comparator) - 1, 0); return frames[index]; } update() { if (!this._model) { return; } const frames = this._model.frames(); if (!frames.length) { return; } if (this._mode === Modes.FrameBased) { Promise.all(frames.map(this.createFrameElement.bind(this))).then(appendElements.bind(this)); return; } const width = this.contentElement.clientWidth; const scale = this._spanTime / width; this.createFrameElement(frames[0]).then( continueWhenFrameImageLoaded.bind(this)); // Calculate frame width basing on the first frame. /** * @this {FilmStripView} * @param {!Element} element0 */ function continueWhenFrameImageLoaded(element0) { const frameWidth = Math.ceil(UI.UIUtils.measurePreferredSize(element0, this.contentElement).width); if (!frameWidth) { return; } const promises = []; for (let pos = frameWidth; pos < width; pos += frameWidth) { const time = pos * scale + this._zeroTime; promises.push(this.createFrameElement(this.frameByTime(time)).then(fixWidth)); } Promise.all(promises).then(appendElements.bind(this)); /** * @param {!Element} element * @return {!Element} */ function fixWidth(element) { /** @type {!HTMLElement} */ (element).style.width = frameWidth + 'px'; return element; } } /** * @param {!Array.<!Element>} elements * @this {FilmStripView} */ function appendElements(elements) { this.contentElement.removeChildren(); for (let i = 0; i < elements.length; ++i) { this.contentElement.appendChild(elements[i]); } } } /** * @override */ onResize() { if (this._mode === Modes.FrameBased) { return; } this.update(); } /** * @param {string|symbol} eventName * @param {number} timestamp */ _onMouseEvent(eventName, timestamp) { this.dispatchEventToListeners(eventName, timestamp); } /** * @param {!SDK.FilmStripModel.Frame} filmStripFrame */ _onDoubleClick(filmStripFrame) { new Dialog(filmStripFrame, this._zeroTime); } reset() { this._zeroTime = 0; this.contentElement.removeChildren(); this.contentElement.appendChild(this._statusLabel); } /** * @param {string} text */ setStatusText(text) { this._statusLabel.textContent = text; } } /** @enum {symbol} */ export const Events = { FrameSelected: Symbol('FrameSelected'), FrameEnter: Symbol('FrameEnter'), FrameExit: Symbol('FrameExit'), }; export const Modes = { TimeBased: 'TimeBased', FrameBased: 'FrameBased' }; export class Dialog { /** * @param {!SDK.FilmStripModel.Frame} filmStripFrame * @param {number=} zeroTime */ constructor(filmStripFrame, zeroTime) { const prevButton = UI.UIUtils.createTextButton('\u25C0', this._onPrevFrame.bind(this)); UI.Tooltip.Tooltip.install(prevButton, i18nString(UIStrings.previousFrame)); const nextButton = UI.UIUtils.createTextButton('\u25B6', this._onNextFrame.bind(this)); UI.Tooltip.Tooltip.install(nextButton, i18nString(UIStrings.nextFrame)); this._fragment = UI.Fragment.Fragment.build` <x-widget flex=none margin=12px> <x-hbox overflow=auto border='1px solid #ddd'> <img $='image' style="max-height: 80vh; max-width: 80vw"></img> </x-hbox> <x-hbox x-center justify-content=center margin-top=10px> ${prevButton} <x-hbox $='time' margin=8px></x-hbox> ${nextButton} </x-hbox> </x-widget> `; this._widget = /** @type {!UI.XWidget.XWidget} */ (this._fragment.element()); /** @type {!HTMLElement} */ (this._widget).tabIndex = 0; this._widget.addEventListener('keydown', this._keyDown.bind(this), false); this._frames = filmStripFrame.model().frames(); this._index = filmStripFrame.index; this._zeroTime = zeroTime || filmStripFrame.model().zeroTime(); /** @type {?UI.Dialog.Dialog} */ this._dialog = null; this._render(); } _resize() { if (!this._dialog) { this._dialog = new UI.Dialog.Dialog(); this._dialog.contentElement.appendChild(this._widget); this._dialog.setDefaultFocusedElement(this._widget); // Dialog can take an undefined `where` param for show(), however its superclass (GlassPane) // requires a Document. TypeScript is unhappy that show() is not given a parameter here, // however, so marking it as an ignore. // @ts-ignore See above. this._dialog.show(); } this._dialog.setSizeBehavior(UI.GlassPane.SizeBehavior.MeasureContent); } /** * @param {!Event} event */ _keyDown(event) { const keyboardEvent = /** @type {!KeyboardEvent} */ (event); switch (keyboardEvent.key) { case 'ArrowLeft': if (Host.Platform.isMac() && keyboardEvent.metaKey) { this._onFirstFrame(); } else { this._onPrevFrame(); } break; case 'ArrowRight': if (Host.Platform.isMac() && keyboardEvent.metaKey) { this._onLastFrame(); } else { this._onNextFrame(); } break; case 'Home': this._onFirstFrame(); break; case 'End': this._onLastFrame(); break; } } _onPrevFrame() { if (this._index > 0) { --this._index; } this._render(); } _onNextFrame() { if (this._index < this._frames.length - 1) { ++this._index; } this._render(); } _onFirstFrame() { this._index = 0; this._render(); } _onLastFrame() { this._index = this._frames.length - 1; this._render(); } /** * @return {!Promise<void>} */ _render() { const frame = this._frames[this._index]; this._fragment.$('time').textContent = Number.millisToString(frame.timestamp - this._zeroTime); return frame.imageDataPromise() .then(imageData => { const image = /** @type {!HTMLImageElement} */ (this._fragment.$('image')); return FilmStripView._setImageData(image, imageData); }) .then(this._resize.bind(this)); } }