| // Copyright 2014 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| /* eslint-disable @devtools/no-imperative-dom-api */ |
| |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import type * as Protocol from '../../generated/protocol.js'; |
| import * as Geometry from '../../models/geometry/geometry.js'; |
| import * as Trace from '../../models/trace/trace.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| import * as Lit from '../../ui/lit/lit.js'; |
| import * as LayerViewer from '../layer_viewer/layer_viewer.js'; |
| |
| import timelinePaintProfilerStyles from './timelinePaintProfiler.css.js'; |
| import {TracingFrameLayerTree} from './TracingLayerTree.js'; |
| |
| const {html, render} = Lit; |
| const {createRef, ref} = Lit.Directives; |
| |
| export class TimelinePaintProfilerView extends UI.SplitWidget.SplitWidget { |
| private readonly logAndImageSplitWidget: UI.SplitWidget.SplitWidget; |
| private readonly imageView: TimelinePaintImageView; |
| private readonly paintProfilerView: LayerViewer.PaintProfilerView.PaintProfilerView; |
| private readonly logTreeView: LayerViewer.PaintProfilerView.PaintProfilerCommandLogView; |
| private needsUpdateWhenVisible: boolean; |
| private pendingSnapshot: SDK.PaintProfiler.PaintProfilerSnapshot|null; |
| private event: Trace.Types.Events.Event|null; |
| private paintProfilerModel: SDK.PaintProfiler.PaintProfilerModel|null; |
| private lastLoadedSnapshot: SDK.PaintProfiler.PaintProfilerSnapshot|null; |
| #parsedTrace: Trace.TraceModel.ParsedTrace; |
| |
| constructor(parsedTrace: Trace.TraceModel.ParsedTrace) { |
| super(false, false); |
| this.setSidebarSize(60); |
| this.setResizable(false); |
| |
| this.#parsedTrace = parsedTrace; |
| |
| this.logAndImageSplitWidget = new UI.SplitWidget.SplitWidget(true, false, 'timeline-paint-profiler-log-split'); |
| this.setMainWidget(this.logAndImageSplitWidget); |
| this.imageView = new TimelinePaintImageView(); |
| this.logAndImageSplitWidget.setMainWidget(this.imageView); |
| |
| this.paintProfilerView = |
| new LayerViewer.PaintProfilerView.PaintProfilerView(this.imageView.showImage.bind(this.imageView)); |
| this.paintProfilerView.addEventListener( |
| LayerViewer.PaintProfilerView.Events.WINDOW_CHANGED, this.onWindowChanged, this); |
| this.setSidebarWidget(this.paintProfilerView); |
| |
| this.logTreeView = new LayerViewer.PaintProfilerView.PaintProfilerCommandLogView(); |
| this.logAndImageSplitWidget.setSidebarWidget(this.logTreeView); |
| |
| this.needsUpdateWhenVisible = false; |
| this.pendingSnapshot = null; |
| this.event = null; |
| this.paintProfilerModel = null; |
| this.lastLoadedSnapshot = null; |
| } |
| |
| override wasShown(): void { |
| super.wasShown(); |
| if (this.needsUpdateWhenVisible) { |
| this.needsUpdateWhenVisible = false; |
| this.update(); |
| } |
| } |
| |
| setSnapshot(snapshot: SDK.PaintProfiler.PaintProfilerSnapshot): void { |
| this.releaseSnapshot(); |
| this.pendingSnapshot = snapshot; |
| this.event = null; |
| this.updateWhenVisible(); |
| } |
| |
| #rasterEventHasTile(event: Trace.Types.Events.RasterTask): boolean { |
| const data = event.args.tileData; |
| if (!data) { |
| return false; |
| } |
| |
| const frame = this.#parsedTrace.data.Frames.framesById[data.sourceFrameNumber]; |
| if (!frame?.layerTree) { |
| return false; |
| } |
| return true; |
| } |
| |
| setEvent(paintProfilerModel: SDK.PaintProfiler.PaintProfilerModel, event: Trace.Types.Events.Event): boolean { |
| this.releaseSnapshot(); |
| this.paintProfilerModel = paintProfilerModel; |
| this.pendingSnapshot = null; |
| this.event = event; |
| |
| this.updateWhenVisible(); |
| if (Trace.Types.Events.isPaint(event)) { |
| const snapshot = this.#parsedTrace.data.LayerTree.paintsToSnapshots.get(event); |
| return Boolean(snapshot); |
| } |
| if (Trace.Types.Events.isRasterTask(event)) { |
| return this.#rasterEventHasTile(event); |
| } |
| return false; |
| } |
| |
| private updateWhenVisible(): void { |
| if (this.isShowing()) { |
| this.update(); |
| } else { |
| this.needsUpdateWhenVisible = true; |
| } |
| } |
| |
| async #rasterTilePromise(rasterEvent: Trace.Types.Events.RasterTask): Promise<{ |
| rect: Protocol.DOM.Rect, |
| snapshot: SDK.PaintProfiler.PaintProfilerSnapshot, |
| }|null> { |
| const data = rasterEvent.args.tileData; |
| if (!data) { |
| return null; |
| } |
| |
| if (!data.tileId.id_ref) { |
| return null; |
| } |
| |
| const target = SDK.TargetManager.TargetManager.instance().rootTarget(); |
| if (!target) { |
| return null; |
| } |
| |
| const frame = this.#parsedTrace.data.Frames.framesById[data.sourceFrameNumber]; |
| if (!frame?.layerTree) { |
| return null; |
| } |
| |
| const layerTree = new TracingFrameLayerTree( |
| target, |
| frame.layerTree, |
| ); |
| const tracingLayerTree = await layerTree.layerTreePromise(); |
| return tracingLayerTree ? await tracingLayerTree.pictureForRasterTile(data.tileId.id_ref) : null; |
| } |
| |
| update(): void { |
| this.logTreeView.setCommandLog([]); |
| void this.paintProfilerView.setSnapshotAndLog(null, [], null); |
| |
| let snapshotPromise: Promise<{ |
| rect: Protocol.DOM.Rect | null, |
| snapshot: SDK.PaintProfiler.PaintProfilerSnapshot, |
| }|null>; |
| if (this.pendingSnapshot) { |
| snapshotPromise = Promise.resolve({rect: null, snapshot: this.pendingSnapshot}); |
| } else if (this.event && this.paintProfilerModel && Trace.Types.Events.isPaint(this.event)) { |
| // When we process events (TimelineModel#processEvent) and find a |
| // snapshot event, we look for the last paint that occurred and link the |
| // snapshot to that paint event. That is why here if the event is a Paint |
| // event, we look to see if it has had a matching picture event set for |
| // it. |
| const snapshotEvent = this.#parsedTrace.data.LayerTree.paintsToSnapshots.get(this.event); |
| if (snapshotEvent) { |
| const encodedData = snapshotEvent.args.snapshot.skp64; |
| snapshotPromise = this.paintProfilerModel.loadSnapshot(encodedData).then(snapshot => { |
| return snapshot && {rect: null, snapshot}; |
| }); |
| } else { |
| snapshotPromise = Promise.resolve(null); |
| } |
| |
| } else if (this.event && Trace.Types.Events.isRasterTask(this.event)) { |
| snapshotPromise = this.#rasterTilePromise(this.event); |
| } else { |
| console.assert(false, 'Unexpected event type or no snapshot'); |
| return; |
| } |
| void snapshotPromise.then(snapshotWithRect => { |
| this.releaseSnapshot(); |
| if (!snapshotWithRect) { |
| this.imageView.showImage(); |
| return; |
| } |
| const snapshot = snapshotWithRect.snapshot; |
| this.lastLoadedSnapshot = snapshot; |
| this.imageView.setMask(snapshotWithRect.rect); |
| void snapshot.commandLog().then(log => onCommandLogDone.call(this, snapshot, snapshotWithRect.rect, log || [])); |
| }); |
| |
| function onCommandLogDone( |
| this: TimelinePaintProfilerView, snapshot: SDK.PaintProfiler.PaintProfilerSnapshot, |
| clipRect: Protocol.DOM.Rect|null, log?: SDK.PaintProfiler.PaintProfilerLogItem[]): void { |
| this.logTreeView.setCommandLog(log || []); |
| void this.paintProfilerView.setSnapshotAndLog(snapshot, log || [], clipRect); |
| } |
| } |
| |
| private releaseSnapshot(): void { |
| if (!this.lastLoadedSnapshot) { |
| return; |
| } |
| this.lastLoadedSnapshot.release(); |
| this.lastLoadedSnapshot = null; |
| } |
| |
| private onWindowChanged(): void { |
| this.logTreeView.updateWindow(this.paintProfilerView.selectionWindow()); |
| } |
| } |
| |
| export interface TimelinePaintImageViewInput { |
| maskElementHidden: boolean; |
| imageContainerHidden: boolean; |
| imageURL: string; |
| imageContainerWebKitTransform: string; |
| maskElementStyle: { |
| width?: string, |
| height?: string, |
| borderLeftWidth?: string, |
| borderTopWidth?: string, |
| borderRightWidth?: string, |
| borderBottomWidth?: string, |
| }; |
| } |
| |
| export const DEFAULT_VIEW = (input: TimelinePaintImageViewInput, output: undefined, target: HTMLElement): { |
| imageElementNaturalHeight: number, |
| imageElementNaturalWidth: number, |
| } => { |
| const imageElementRef = createRef<HTMLImageElement>(); |
| // clang-format off |
| render(html` |
| <div class="paint-profiler-image-view fill"> |
| <div class="paint-profiler-image-container" style="-webkit-transform: ${input.imageContainerWebKitTransform}"> |
| <img src=${input.imageURL} display=${input.imageContainerHidden ? 'none' : 'block'} ${ref(imageElementRef)}> |
| <div style=${Lit.Directives.styleMap({ |
| display: input.maskElementHidden ? 'none' : 'block', |
| ...input.maskElementStyle,})}> |
| </div> |
| </div> |
| </div>`, |
| target); |
| // clang-format on |
| |
| // The elements are guaranteed to exist after render completes |
| // because they are not conditionally rendered within the template. |
| const imageElement = imageElementRef.value; |
| |
| if (!imageElement?.naturalHeight || !imageElement.naturalWidth) { |
| throw new Error('ImageElement were not found in the TimelinePaintImageView.'); |
| } |
| |
| return {imageElementNaturalHeight: imageElement.naturalHeight, imageElementNaturalWidth: imageElement.naturalWidth}; |
| }; |
| export class TimelinePaintImageView extends UI.Widget.Widget { |
| private transformController: LayerViewer.TransformController.TransformController; |
| private maskRectangle?: Protocol.DOM.Rect|null; |
| |
| #inputData: TimelinePaintImageViewInput = { |
| maskElementHidden: true, |
| imageContainerHidden: true, |
| imageURL: '', |
| imageContainerWebKitTransform: '', |
| maskElementStyle: {}, |
| }; |
| |
| #view: typeof DEFAULT_VIEW; |
| #imageElementDimensions?: { |
| naturalHeight: number, |
| naturalWidth: number, |
| }; |
| |
| constructor(view = DEFAULT_VIEW) { |
| super(); |
| this.registerRequiredCSS(timelinePaintProfilerStyles); |
| this.#view = view; |
| this.transformController = new LayerViewer.TransformController.TransformController((this.contentElement), true); |
| this.transformController.addEventListener( |
| LayerViewer.TransformController.Events.TRANSFORM_CHANGED, this.updateImagePosition, this); |
| } |
| |
| override onResize(): void { |
| this.requestUpdate(); |
| this.updateImagePosition(); |
| } |
| |
| private updateImagePosition(): void { |
| if (!this.#imageElementDimensions) { |
| return; |
| } |
| |
| const width = this.#imageElementDimensions.naturalWidth; |
| const height = this.#imageElementDimensions.naturalHeight; |
| const clientWidth = this.contentElement.clientWidth; |
| const clientHeight = this.contentElement.clientHeight; |
| |
| const paddingFraction = 0.1; |
| const paddingX = clientWidth * paddingFraction; |
| const scale = clientHeight / height; |
| |
| const oldMaskStyle = JSON.stringify(this.#inputData.maskElementStyle); |
| let newMaskStyle = {}; |
| if (this.maskRectangle) { |
| newMaskStyle = { |
| width: width + 'px', |
| height: height + 'px', |
| borderLeftWidth: this.maskRectangle.x + 'px', |
| borderTopWidth: this.maskRectangle.y + 'px', |
| borderRightWidth: (width - this.maskRectangle.x - this.maskRectangle.width) + 'px', |
| borderBottomWidth: (height - this.maskRectangle.y - this.maskRectangle.height) + 'px', |
| }; |
| } |
| this.#inputData.maskElementStyle = newMaskStyle; |
| |
| if (!this.transformController) { |
| return; |
| } |
| this.transformController.setScaleConstraints(0.5, 10 / scale); |
| let matrix = new WebKitCSSMatrix() |
| .scale(this.transformController.scale(), this.transformController.scale()) |
| .translate(clientWidth / 2, clientHeight / 2) |
| .scale(scale, scale) |
| .translate(-width / 2, -height / 2); |
| const bounds = Geometry.boundsForTransformedPoints(matrix, [0, 0, 0, width, height, 0]); |
| this.transformController.clampOffsets(paddingX - bounds.maxX, clientWidth - paddingX - bounds.minX, 0, 0); |
| matrix = new WebKitCSSMatrix() |
| .translate(this.transformController.offsetX(), this.transformController.offsetY()) |
| .multiply(matrix); |
| |
| const oldTransform = this.#inputData.imageContainerWebKitTransform; |
| const newTransform = matrix.toString(); |
| this.#inputData.imageContainerWebKitTransform = newTransform; |
| |
| if (oldTransform !== newTransform || oldMaskStyle !== JSON.stringify(newMaskStyle)) { |
| this.requestUpdate(); |
| } |
| } |
| |
| showImage(imageURL?: string): void { |
| this.#inputData.imageContainerHidden = !imageURL; |
| if (imageURL) { |
| this.#inputData.imageURL = imageURL; |
| } |
| this.requestUpdate(); |
| } |
| |
| setMask(maskRectangle: Protocol.DOM.Rect|null): void { |
| this.maskRectangle = maskRectangle; |
| |
| this.#inputData.maskElementHidden = !maskRectangle; |
| this.requestUpdate(); |
| } |
| |
| override performUpdate(): void { |
| const {imageElementNaturalHeight, imageElementNaturalWidth} = |
| this.#view(this.#inputData, undefined, this.contentElement); |
| this.#imageElementDimensions = {naturalHeight: imageElementNaturalHeight, naturalWidth: imageElementNaturalWidth}; |
| // Image can only be updated to correctly fit the component when the component has loaded. |
| this.updateImagePosition(); |
| } |
| } |