| // 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 type * as ComputedStyle from '../../models/computed_style/computed_style.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 {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: ComputedStyle.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); |
| } |
| } |