UNPKG

chrome-devtools-frontend

Version:
534 lines (470 loc) 19.5 kB
// Copyright 2021 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* * Copyright (C) 2007 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. * 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of * its contributors may be used to endorse or promote products derived * from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY APPLE 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 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. */ import * as Common from '../../core/common/common.js'; import * as Platform from '../../core/platform/platform.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as UI from '../../ui/legacy/legacy.js'; import {Directives, html, type LitTemplate, nothing, render} from '../../ui/lit/lit.js'; import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import type {ComputedStyleModel} from './ComputedStyleModel.js'; import {ElementsSidebarPane} from './ElementsSidebarPane.js'; import metricsSidebarPaneStyles from './metricsSidebarPane.css.js'; const {live} = Directives; interface ViewInput { style: Map<string, string>; highlightedMode: string; node: SDK.DOMModel.DOMNode|null; contentWidth: string; contentHeight: string; onHighlightNode: (showHighlight: boolean, mode: string) => void; onStartEditing: (target: Element, box: string, styleProperty: string, computedStyle: Map<string, string>) => void; } type View = (input: ViewInput, output: undefined, target: HTMLElement) => void; const DEFAULT_VIEW: View = (input, output, target) => { const {style, highlightedMode, node, contentWidth, contentHeight, onHighlightNode, onStartEditing} = input; function createBoxPartElement(style: Map<string, string>, name: string, side: string, suffix: string): LitTemplate { const propertyName = (name !== 'position' ? name + '-' : '') + side + suffix; let value = style.get(propertyName); if (value === '' || (name !== 'position' && value === 'unset')) { value = '\u2012'; } else if (name === 'position' && value === 'auto') { value = '\u2012'; } value = value?.replace(/px$/, ''); value = value ? Platform.NumberUtilities.toFixedIfFloating(value) : value; // clang-format off return html`<div class=${side} jslog=${VisualLogging.value(propertyName).track({ dblclick: true, keydown: 'Enter|Escape|ArrowUp|ArrowDown|PageUp|PageDown', change: true, })} @dblclick=${(e: Event) => onStartEditing(e.currentTarget as Element, name, propertyName, style)} .innerText=${live(value ?? '')}> </div>`; // clang-format on } // Display types for which margin is ignored. const noMarginDisplayType = new Set<string>([ 'table-cell', 'table-column', 'table-column-group', 'table-footer-group', 'table-header-group', 'table-row', 'table-row-group', ]); // Display types for which padding is ignored. const noPaddingDisplayType = new Set<string>([ 'table-column', 'table-column-group', 'table-footer-group', 'table-header-group', 'table-row', 'table-row-group', ]); // Position types for which top, left, bottom and right are ignored. const noPositionType = new Set<string>(['static']); const boxes = ['content', 'padding', 'border', 'margin', 'position']; const boxColors = [ Common.Color.PageHighlight.Content, Common.Color.PageHighlight.Padding, Common.Color.PageHighlight.Border, Common.Color.PageHighlight.Margin, Common.Color.Legacy.fromRGBA([0, 0, 0, 0]), ]; const boxLabels = ['content', 'padding', 'border', 'margin', 'position']; let previousBox: LitTemplate = nothing; for (let i = 0; i < boxes.length; ++i) { const name = boxes[i]; const display = style.get('display'); const position = style.get('position'); if (!display || !position) { continue; } if (name === 'margin' && noMarginDisplayType.has(display)) { continue; } if (name === 'padding' && noPaddingDisplayType.has(display)) { continue; } if (name === 'position' && noPositionType.has(position)) { continue; } const shouldHighlight = !node || highlightedMode === 'all' || name === highlightedMode; const backgroundColor = boxColors[i].asString(Common.Color.Format.RGBA) || ''; const suffix = (name === 'border' ? '-width' : ''); // clang-format off const box: LitTemplate = html` <div class="${name} ${shouldHighlight ? 'highlighted' : ''}" style="background-color: ${shouldHighlight ? backgroundColor : ''}" jslog=${VisualLogging.metricsBox().context(name).track({hover: true})} @mouseover=${(e: Event) => {e.consume(); onHighlightNode(true, name === 'position' ? 'all' : name);}}> ${name === 'content' ? html` <span jslog=${VisualLogging.value('width').track({ dblclick: true, keydown: 'Enter|Escape|ArrowUp|ArrowDown|PageUp|PageDown', change: true, })} @dblclick=${(e: Event) => onStartEditing(e.currentTarget as Element, 'width', 'width', style)} .innerText=${live(contentWidth)}> </span> <span> × </span> <span jslog=${VisualLogging.value('height').track({ dblclick: true, keydown: 'Enter|Escape|ArrowUp|ArrowDown|PageUp|PageDown', change: true, })} @dblclick=${(e: Event) => onStartEditing(e.currentTarget as Element, 'height', 'height', style)} .innerText=${live(contentHeight)}> </span>` : html` <div class="label">${boxLabels[i]}</div> ${createBoxPartElement(style, name, 'top', suffix)} <br> ${createBoxPartElement(style, name, 'left', suffix)} ${previousBox} ${createBoxPartElement(style, name, 'right', suffix)} <br> ${createBoxPartElement(style, name, 'bottom', suffix)}`} </div>`; // clang-format on previousBox = box; } // clang-format off render(html` <div class="metrics ${!node ? 'collapsed' : ''}" @mouseover=${(e: Event) => { e.consume(); onHighlightNode(true, 'all'); }} @mouseleave=${(e: Event) => { e.consume(); onHighlightNode(false, 'all'); }}> ${previousBox} </div>`, target); // clang-format on }; export class MetricsSidebarPane extends ElementsSidebarPane { originalPropertyData: SDK.CSSProperty.CSSProperty|null; previousPropertyDataCandidate: SDK.CSSProperty.CSSProperty|null; private inlineStyle: SDK.CSSStyleDeclaration.CSSStyleDeclaration|null; private highlightMode: string; private computedStyle: Map<string, string>|null; private isEditingMetrics?: boolean; private view: View; constructor(computedStyleModel: ComputedStyleModel, view = DEFAULT_VIEW) { super(computedStyleModel, {jslog: `${VisualLogging.pane('styles-metrics')}`}); this.registerRequiredCSS(metricsSidebarPaneStyles); this.originalPropertyData = null; this.previousPropertyDataCandidate = null; this.inlineStyle = null; this.highlightMode = ''; this.computedStyle = null; this.view = view; } override async performUpdate(): Promise<void> { // "style" attribute might have changed. Update metrics unless they are being edited // (if a CSS property is added, a StyleSheetChanged event is dispatched). if (this.isEditingMetrics) { return await Promise.resolve(); } // FIXME: avoid updates of a collapsed pane. const node = this.node(); const cssModel = this.cssModel(); if (!node || node.nodeType() !== Node.ELEMENT_NODE || !cssModel) { this.view( { style: new Map(), highlightedMode: '', node: null, contentWidth: '', contentHeight: '', onHighlightNode: () => {}, onStartEditing: () => {}, }, undefined, this.contentElement); return await Promise.resolve(); } function callback(this: MetricsSidebarPane, style: Map<string, string>|null): void { if (!style || this.node() !== node) { this.computedStyle = null; return; } this.computedStyle = style; this.updateMetrics(style); } if (!node.id) { return await Promise.resolve(); } const promises = [ cssModel.getComputedStyle(node.id).then(callback.bind(this)), cssModel.getInlineStyles(node.id).then(inlineStyleResult => { if (inlineStyleResult && this.node() === node) { this.inlineStyle = inlineStyleResult.inlineStyle; } }), ]; return await (Promise.all(promises) as unknown as Promise<void>); } override onCSSModelChanged(): void { this.requestUpdate(); } private getPropertyValueAsPx(style: Map<string, string>, propertyName: string): number { const propertyValue = style.get(propertyName); if (!propertyValue) { return 0; } return Number(propertyValue.replace(/px$/, '') || 0); } private getBox(computedStyle: Map<string, string>, componentName: string): { left: number, top: number, right: number, bottom: number, } { const suffix = componentName === 'border' ? '-width' : ''; const left = this.getPropertyValueAsPx(computedStyle, componentName + '-left' + suffix); const top = this.getPropertyValueAsPx(computedStyle, componentName + '-top' + suffix); const right = this.getPropertyValueAsPx(computedStyle, componentName + '-right' + suffix); const bottom = this.getPropertyValueAsPx(computedStyle, componentName + '-bottom' + suffix); return {left, top, right, bottom}; } private highlightDOMNode(showHighlight: boolean, mode: string): void { const node = this.node(); if (showHighlight && node) { if (this.highlightMode === mode) { return; } this.highlightMode = mode; node.highlight(mode); } else { this.highlightMode = ''; SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(); } if (this.computedStyle) { this.updateMetrics(this.computedStyle, mode); } } private getContentAreaWidthPx(style: Map<string, string>): string { let width = style.get('width'); if (!width) { return ''; } width = width.replace(/px$/, ''); const widthValue = Number(width); if (!isNaN(widthValue) && style.get('box-sizing') === 'border-box') { const borderBox = this.getBox(style, 'border'); const paddingBox = this.getBox(style, 'padding'); width = (widthValue - borderBox.left - borderBox.right - paddingBox.left - paddingBox.right).toString(); } return Platform.NumberUtilities.toFixedIfFloating(width); } private getContentAreaHeightPx(style: Map<string, string>): string { let height = style.get('height'); if (!height) { return ''; } height = height.replace(/px$/, ''); const heightValue = Number(height); if (!isNaN(heightValue) && style.get('box-sizing') === 'border-box') { const borderBox = this.getBox(style, 'border'); const paddingBox = this.getBox(style, 'padding'); height = (heightValue - borderBox.top - borderBox.bottom - paddingBox.top - paddingBox.bottom).toString(); } return Platform.NumberUtilities.toFixedIfFloating(height); } private updateMetrics(style: Map<string, string>, highlightedMode = 'all'): void { this.view( { style, highlightedMode, node: this.node(), contentWidth: this.getContentAreaWidthPx(style), contentHeight: this.getContentAreaHeightPx(style), onHighlightNode: this.highlightDOMNode.bind(this), onStartEditing: this.startEditing.bind(this), }, undefined, this.contentElement); } startEditing(targetElement: Element, box: string, styleProperty: string, computedStyle: Map<string, string>): void { if (UI.UIUtils.isBeingEdited(targetElement)) { return; } const context: { box: string, styleProperty: string, computedStyle: Map<string, string>, keyDownHandler: (arg0: Event) => void, } = {box, styleProperty, computedStyle, keyDownHandler: () => {}}; const boundKeyDown = this.handleKeyDown.bind(this, context); context.keyDownHandler = boundKeyDown; targetElement.addEventListener('keydown', boundKeyDown, false); this.isEditingMetrics = true; const config = new UI.InplaceEditor.Config(this.editingCommitted.bind(this), this.editingCancelled.bind(this), context); UI.InplaceEditor.InplaceEditor.startEditing(targetElement, config); const selection = targetElement.getComponentSelection(); selection?.selectAllChildren(targetElement); } private handleKeyDown( context: { box: string, styleProperty: string, computedStyle: Map<string, string>, keyDownHandler: (arg0: Event) => void, }, event: Event): void { const element = (event.currentTarget as Element); function finishHandler(this: MetricsSidebarPane, originalValue: string, replacementString: string): void { this.applyUserInput(element, replacementString, originalValue, context, false); } function customNumberHandler(prefix: string, number: number, suffix: string): string { if (context.styleProperty !== 'margin' && number < 0) { number = 0; } return prefix + number + suffix; } UI.UIUtils.handleElementValueModifications( event, element, finishHandler.bind(this), undefined, customNumberHandler); } editingEnded(element: Element, context: { keyDownHandler: (arg0: Event) => void, }): void { this.originalPropertyData = null; this.previousPropertyDataCandidate = null; element.removeEventListener('keydown', context.keyDownHandler, false); delete this.isEditingMetrics; } editingCancelled(element: Element, context: { box: string, styleProperty: string, computedStyle: Map<string, string>, keyDownHandler: (arg0: Event) => void, }): void { if (this.inlineStyle) { if (!this.originalPropertyData) { // An added property, remove the last property in the style. const pastLastSourcePropertyIndex = this.inlineStyle.pastLastSourcePropertyIndex(); if (pastLastSourcePropertyIndex) { void this.inlineStyle.allProperties()[pastLastSourcePropertyIndex - 1].setText('', false); } } else { void this.inlineStyle.allProperties()[this.originalPropertyData.index].setText( this.originalPropertyData.propertyText || '', false); } } this.editingEnded(element, context); this.requestUpdate(); } private applyUserInput( element: Element, userInput: string, previousContent: string|null, context: { box: string, styleProperty: string, computedStyle: Map<string, string>, keyDownHandler: (arg0: Event) => void, }, commitEditor: boolean): void { if (!this.inlineStyle) { // Element has no renderer. return this.editingCancelled(element, context); // nothing changed, so cancel } if (commitEditor && userInput === previousContent) { return this.editingCancelled(element, context); } // nothing changed, so cancel if (context.box !== 'position' && (!userInput || userInput === '\u2012' || userInput === '-')) { userInput = 'unset'; } else if (context.box === 'position' && (!userInput || userInput === '\u2012' || userInput === '-')) { userInput = 'auto'; } userInput = userInput.toLowerCase(); // Append a "px" unit if the user input was just a number. if (/^\d+$/.test(userInput)) { userInput += 'px'; } const styleProperty = context.styleProperty; const computedStyle = context.computedStyle; if (computedStyle.get('box-sizing') === 'border-box' && (styleProperty === 'width' || styleProperty === 'height')) { if (!userInput.match(/px$/)) { Common.Console.Console.instance().error( 'For elements with box-sizing: border-box, only absolute content area dimensions can be applied'); return; } const borderBox = this.getBox(computedStyle, 'border'); const paddingBox = this.getBox(computedStyle, 'padding'); let userValuePx = Number(userInput.replace(/px$/, '')); if (isNaN(userValuePx)) { return; } if (styleProperty === 'width') { userValuePx += borderBox.left + borderBox.right + paddingBox.left + paddingBox.right; } else { userValuePx += borderBox.top + borderBox.bottom + paddingBox.top + paddingBox.bottom; } userInput = userValuePx + 'px'; } this.previousPropertyDataCandidate = null; const allProperties = this.inlineStyle.allProperties(); for (let i = 0; i < allProperties.length; ++i) { const property = allProperties[i]; if (property.name !== context.styleProperty || (property.parsedOk && !property.activeInStyle())) { continue; } this.previousPropertyDataCandidate = property; property.setValue(userInput, commitEditor, true, callback.bind(this)); return; } this.inlineStyle.appendProperty(context.styleProperty, userInput, callback.bind(this)); function callback(this: MetricsSidebarPane, success: boolean): void { if (!success) { return; } if (!this.originalPropertyData) { this.originalPropertyData = this.previousPropertyDataCandidate; } if (this.highlightMode) { const node = this.node(); if (!node) { return; } node.highlight(this.highlightMode); } if (commitEditor) { this.requestUpdate(); } } } private editingCommitted( element: Element, userInput: string, previousContent: string|null, context: { box: string, styleProperty: string, computedStyle: Map<string, string>, keyDownHandler: (arg0: Event) => void, }, ): void { this.editingEnded(element, context); this.applyUserInput(element, userInput, previousContent, context, true); } }