UNPKG

chrome-devtools-frontend

Version:
1,322 lines (1,152 loc) 92.8 kB
// Copyright 2021 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. /* * Copyright (C) 2007 Apple Inc. All rights reserved. * Copyright (C) 2009 Joseph Pecoraro * * 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 Host from '../../core/host/host.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as Platform from '../../core/platform/platform.js'; import * as Root from '../../core/root/root.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as Protocol from '../../generated/protocol.js'; import * as Bindings from '../../models/bindings/bindings.js'; import type * as Formatter from '../../models/formatter/formatter.js'; import * as TextUtils from '../../models/text_utils/text_utils.js'; import * as Workspace from '../../models/workspace/workspace.js'; import * as WorkspaceDiff from '../../models/workspace_diff/workspace_diff.js'; import {formatCSSChangesFromDiff} from '../../panels/utils/utils.js'; import * as DiffView from '../../ui/components/diff_view/diff_view.js'; import * as IconButton from '../../ui/components/icon_button/icon_button.js'; import * as InlineEditor from '../../ui/legacy/components/inline_editor/inline_editor.js'; import * as Components from '../../ui/legacy/components/utils/utils.js'; import * as UI from '../../ui/legacy/legacy.js'; import * as ElementsComponents from './components/components.js'; import {ComputedStyleModel, type ComputedStyleChangedEvent} from './ComputedStyleModel.js'; import {ElementsPanel} from './ElementsPanel.js'; import {ElementsSidebarPane} from './ElementsSidebarPane.js'; import {ImagePreviewPopover} from './ImagePreviewPopover.js'; import {StyleEditorWidget} from './StyleEditorWidget.js'; import {StylePropertyHighlighter} from './StylePropertyHighlighter.js'; import stylesSidebarPaneStyles from './stylesSidebarPane.css.js'; import {activeHints, type StylePropertyTreeElement} from './StylePropertyTreeElement.js'; import { StylePropertiesSection, BlankStylePropertiesSection, KeyframePropertiesSection, HighlightPseudoStylePropertiesSection, TryRuleSection, } from './StylePropertiesSection.js'; import * as LayersWidget from './LayersWidget.js'; import {assertNotNullOrUndefined} from '../../core/platform/platform.js'; import {WebCustomData} from './WebCustomData.js'; const UIStrings = { /** *@description No matches element text content in Styles Sidebar Pane of the Elements panel */ noMatchingSelectorOrStyle: 'No matching selector or style', /** /** *@description Text to announce the result of the filter input in the Styles Sidebar Pane of the Elements panel */ visibleSelectors: '{n, plural, =1 {# visible selector listed below} other {# visible selectors listed below}}', /** *@description Text in Styles Sidebar Pane of the Elements panel */ invalidPropertyValue: 'Invalid property value', /** *@description Text in Styles Sidebar Pane of the Elements panel */ unknownPropertyName: 'Unknown property name', /** *@description Text to filter result items */ filter: 'Filter', /** *@description ARIA accessible name in Styles Sidebar Pane of the Elements panel */ filterStyles: 'Filter Styles', /** *@description Separator element text content in Styles Sidebar Pane of the Elements panel *@example {scrollbar-corner} PH1 */ pseudoSElement: 'Pseudo ::{PH1} element', /** *@description Text of a DOM element in Styles Sidebar Pane of the Elements panel */ inheritedFroms: 'Inherited from ', /** *@description Text of an inherited psuedo element in Styles Sidebar Pane of the Elements panel *@example {highlight} PH1 */ inheritedFromSPseudoOf: 'Inherited from ::{PH1} pseudo of ', /** *@description Title of in styles sidebar pane of the elements panel *@example {Ctrl} PH1 */ incrementdecrementWithMousewheelOne: 'Increment/decrement with mousewheel or up/down keys. {PH1}: R ±1, Shift: G ±1, Alt: B ±1', /** *@description Title of in styles sidebar pane of the elements panel *@example {Ctrl} PH1 */ incrementdecrementWithMousewheelHundred: 'Increment/decrement with mousewheel or up/down keys. {PH1}: ±100, Shift: ±10, Alt: ±0.1', /** *@description Announcement string for invalid properties. *@example {Invalid property value} PH1 *@example {font-size} PH2 *@example {invalidValue} PH3 */ invalidString: '{PH1}, property name: {PH2}, property value: {PH3}', /** *@description Tooltip text that appears when hovering over the largeicon add button in the Styles Sidebar Pane of the Elements panel */ newStyleRule: 'New Style Rule', /** *@description Text that is announced by the screen reader when the user focuses on an input field for entering the name of a CSS property in the Styles panel *@example {margin} PH1 */ cssPropertyName: '`CSS` property name: {PH1}', /** *@description Text that is announced by the screen reader when the user focuses on an input field for entering the value of a CSS property in the Styles panel *@example {10px} PH1 */ cssPropertyValue: '`CSS` property value: {PH1}', /** *@description Tooltip text that appears when hovering over the rendering button in the Styles Sidebar Pane of the Elements panel */ toggleRenderingEmulations: 'Toggle common rendering emulations', /** *@description Rendering emulation option for toggling the automatic dark mode */ automaticDarkMode: 'Automatic dark mode', /** *@description Tooltip text that appears when hovering over the css changes button in the Styles Sidebar Pane of the Elements panel */ copyAllCSSChanges: 'Copy CSS changes', /** *@description Tooltip text that appears after clicking on the copy CSS changes button */ copiedToClipboard: 'Copied to clipboard', /** *@description Text displayed on layer separators in the styles sidebar pane. */ layer: 'Layer', /** *@description Tooltip text for the link in the sidebar pane layer separators that reveals the layer in the layer tree view. */ clickToRevealLayer: 'Click to reveal layer in layer tree', /** *@description Text displayed in tooltip that shows specificity information. *@example {(0,0,1)} PH1 */ specificity: 'Specificity: {PH1}', }; const str_ = i18n.i18n.registerUIStrings('panels/elements/StylesSidebarPane.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); // Number of ms elapsed with no keypresses to determine is the input is finished, to announce results const FILTER_IDLE_PERIOD = 500; // Highlightable properties are those that can be hovered in the sidebar to trigger a specific // highlighting mode on the current element. const HIGHLIGHTABLE_PROPERTIES = [ {mode: 'padding', properties: ['padding']}, {mode: 'border', properties: ['border']}, {mode: 'margin', properties: ['margin']}, {mode: 'gap', properties: ['gap', 'grid-gap']}, {mode: 'column-gap', properties: ['column-gap', 'grid-column-gap']}, {mode: 'row-gap', properties: ['row-gap', 'grid-row-gap']}, {mode: 'grid-template-columns', properties: ['grid-template-columns']}, {mode: 'grid-template-rows', properties: ['grid-template-rows']}, {mode: 'grid-template-areas', properties: ['grid-areas']}, {mode: 'justify-content', properties: ['justify-content']}, {mode: 'align-content', properties: ['align-content']}, {mode: 'align-items', properties: ['align-items']}, {mode: 'flexibility', properties: ['flex', 'flex-basis', 'flex-grow', 'flex-shrink']}, ]; let stylesSidebarPaneInstance: StylesSidebarPane; export class StylesSidebarPane extends Common.ObjectWrapper.eventMixin<EventTypes, typeof ElementsSidebarPane>( ElementsSidebarPane) { private currentToolbarPane: UI.Widget.Widget|null; private animatedToolbarPane: UI.Widget.Widget|null; private pendingWidget: UI.Widget.Widget|null; private pendingWidgetToggle: UI.Toolbar.ToolbarToggle|null; private toolbar: UI.Toolbar.Toolbar|null; private toolbarPaneElement: HTMLElement; private lastFilterChange: number|null; private visibleSections: number|null; private noMatchesElement: HTMLElement; private sectionsContainer: HTMLElement; sectionByElement: WeakMap<Node, StylePropertiesSection>; private readonly swatchPopoverHelperInternal: InlineEditor.SwatchPopoverHelper.SwatchPopoverHelper; readonly linkifier: Components.Linkifier.Linkifier; private readonly decorator: StylePropertyHighlighter; private lastRevealedProperty: SDK.CSSProperty.CSSProperty|null; private userOperation: boolean; isEditingStyle: boolean; private filterRegexInternal: RegExp|null; private isActivePropertyHighlighted: boolean; private initialUpdateCompleted: boolean; hasMatchedStyles: boolean; private sectionBlocks: SectionBlock[]; private idleCallbackManager: IdleCallbackManager|null; private needsForceUpdate: boolean; private readonly resizeThrottler: Common.Throttler.Throttler; private scrollerElement?: Element; private readonly boundOnScroll: (event: Event) => void; private readonly imagePreviewPopover: ImagePreviewPopover; #webCustomData?: WebCustomData; #hintPopoverHelper: UI.PopoverHelper.PopoverHelper; #evaluatedCSSVarPopoverHelper: UI.PopoverHelper.PopoverHelper; activeCSSAngle: InlineEditor.CSSAngle.CSSAngle|null; #urlToChangeTracker: Map<Platform.DevToolsPath.UrlString, ChangeTracker> = new Map(); #copyChangesButton?: UI.Toolbar.ToolbarButton; #updateAbortController?: AbortController; #updateComputedStylesAbortController?: AbortController; static instance(opts?: {forceNew: boolean}): StylesSidebarPane { if (!stylesSidebarPaneInstance || opts?.forceNew) { stylesSidebarPaneInstance = new StylesSidebarPane(); } return stylesSidebarPaneInstance; } private constructor() { super(true /* delegatesFocus */); this.setMinimumSize(96, 26); this.registerCSSFiles([stylesSidebarPaneStyles]); Common.Settings.Settings.instance().moduleSetting('colorFormat').addChangeListener(this.update.bind(this)); Common.Settings.Settings.instance().moduleSetting('textEditorIndent').addChangeListener(this.update.bind(this)); this.currentToolbarPane = null; this.animatedToolbarPane = null; this.pendingWidget = null; this.pendingWidgetToggle = null; this.toolbar = null; this.lastFilterChange = null; this.visibleSections = null; this.toolbarPaneElement = this.createStylesSidebarToolbar(); this.computedStyleModelInternal = new ComputedStyleModel(); this.noMatchesElement = this.contentElement.createChild('div', 'gray-info-message hidden'); this.noMatchesElement.textContent = i18nString(UIStrings.noMatchingSelectorOrStyle); this.sectionsContainer = this.contentElement.createChild('div'); UI.ARIAUtils.markAsList(this.sectionsContainer); this.sectionsContainer.addEventListener('keydown', this.sectionsContainerKeyDown.bind(this), false); this.sectionsContainer.addEventListener('focusin', this.sectionsContainerFocusChanged.bind(this), false); this.sectionsContainer.addEventListener('focusout', this.sectionsContainerFocusChanged.bind(this), false); this.sectionByElement = new WeakMap(); this.swatchPopoverHelperInternal = new InlineEditor.SwatchPopoverHelper.SwatchPopoverHelper(); this.swatchPopoverHelperInternal.addEventListener( InlineEditor.SwatchPopoverHelper.Events.WillShowPopover, this.hideAllPopovers, this); this.linkifier = new Components.Linkifier.Linkifier(MAX_LINK_LENGTH, /* useLinkDecorator */ true); this.decorator = new StylePropertyHighlighter(this); this.lastRevealedProperty = null; this.userOperation = false; this.isEditingStyle = false; this.filterRegexInternal = null; this.isActivePropertyHighlighted = false; this.initialUpdateCompleted = false; this.hasMatchedStyles = false; this.contentElement.classList.add('styles-pane'); this.sectionBlocks = []; this.idleCallbackManager = null; this.needsForceUpdate = false; stylesSidebarPaneInstance = this; UI.Context.Context.instance().addFlavorChangeListener(SDK.DOMModel.DOMNode, this.forceUpdate, this); this.contentElement.addEventListener('copy', this.clipboardCopy.bind(this)); this.resizeThrottler = new Common.Throttler.Throttler(100); this.boundOnScroll = this.onScroll.bind(this); this.imagePreviewPopover = new ImagePreviewPopover(this.contentElement, event => { const link = event.composedPath()[0]; if (link instanceof Element) { return link; } return null; }, () => this.node()); this.activeCSSAngle = null; const showDocumentationSetting = Common.Settings.Settings.instance().moduleSetting('showCSSPropertyDocumentationOnHover'); showDocumentationSetting.addChangeListener(event => { const metricType = Boolean(event.data) ? Host.UserMetrics.CSSPropertyDocumentation.ToggledOn : Host.UserMetrics.CSSPropertyDocumentation.ToggledOff; Host.userMetrics.cssPropertyDocumentation(metricType); }); this.#hintPopoverHelper = new UI.PopoverHelper.PopoverHelper(this.contentElement, event => { const hoveredNode = event.composedPath()[0]; // This is a workaround to fix hint popover not showing after icon update. // Previously our `.hint` element was an icon itself and `composedPath()[0]` was referring to it. // However, our `Icon` component now is an element with shadow root and `event.composedPath()[0]` // refers to the markup inside shadow root. Though we want a reference to the `.hint` element itself. // So we trace back and reach to the possible `.hint` element from inside the shadow root. const possibleHintNodeFromHintIcon = event.composedPath()[2]; if (!hoveredNode || !(hoveredNode instanceof Element)) { return null; } if (possibleHintNodeFromHintIcon instanceof Element && possibleHintNodeFromHintIcon.matches('.hint')) { const hint = activeHints.get(possibleHintNodeFromHintIcon); if (hint) { return { box: hoveredNode.boxInWindow(), show: async(popover: UI.GlassPane.GlassPane): Promise<boolean> => { const popupElement = new ElementsComponents.CSSHintDetailsView.CSSHintDetailsView(hint); popover.contentElement.appendChild(popupElement); return true; }, }; } } if (showDocumentationSetting.get() && hoveredNode.matches('.webkit-css-property')) { if (!this.#webCustomData) { this.#webCustomData = WebCustomData.create(); } const cssPropertyName = hoveredNode.textContent; const cssProperty = cssPropertyName && this.#webCustomData.findCssProperty(cssPropertyName); if (cssProperty) { return { box: hoveredNode.boxInWindow(), show: async(popover: UI.GlassPane.GlassPane): Promise<boolean> => { const popupElement = new ElementsComponents.CSSPropertyDocsView.CSSPropertyDocsView(cssProperty); popover.contentElement.appendChild(popupElement); Host.userMetrics.cssPropertyDocumentation(Host.UserMetrics.CSSPropertyDocumentation.Shown); return true; }, }; } } if (hoveredNode.matches('.nesting-symbol')) { return { box: hoveredNode.boxInWindow(), show: async(popover: UI.GlassPane.GlassPane): Promise<boolean> => { popover.setIgnoreLeftMargin(true); const element = document.createElement('span'); element.textContent = (hoveredNode as HTMLElement).dataset.nestingSelectors || ''; popover.contentElement.appendChild(element); return true; }, }; } if (hoveredNode.matches('.simple-selector')) { const specificity = StylePropertiesSection.getSpecificityStoredForNodeElement(hoveredNode); return { box: hoveredNode.boxInWindow(), show: async(popover: UI.GlassPane.GlassPane): Promise<boolean> => { popover.setIgnoreLeftMargin(true); const element = document.createElement('span'); element.textContent = i18nString( UIStrings.specificity, {PH1: specificity ? `(${specificity.a},${specificity.b},${specificity.c})` : '(?,?,?)'}); popover.contentElement.appendChild(element); return true; }, }; } return null; }); this.#hintPopoverHelper.setDisableOnClick(true); this.#hintPopoverHelper.setTimeout(300); this.#hintPopoverHelper.setHasPadding(true); // Bind cssVarSwatch Popover. this.#evaluatedCSSVarPopoverHelper = new UI.PopoverHelper.PopoverHelper(this.contentElement, event => { const link = event.composedPath()[0]; if (!link || !(link instanceof Element) || !link.matches('.link-swatch-link')) { return null; } const linkContainer = event.composedPath()[2]; if (!linkContainer || !(linkContainer instanceof Element) || !linkContainer.matches('.css-var-link')) { return null; } const variableValue = link.getAttribute('data-title') || ''; return { box: link.boxInWindow(), show: async(popover: UI.GlassPane.GlassPane): Promise<boolean> => { const popupElement = new ElementsComponents.CSSVariableValueView.CSSVariableValueView(variableValue); popover.contentElement.appendChild(popupElement); return true; }, }; }); this.#evaluatedCSSVarPopoverHelper.setDisableOnClick(true); this.#evaluatedCSSVarPopoverHelper.setTimeout(500, 200); } private onScroll(_event: Event): void { this.hideAllPopovers(); } swatchPopoverHelper(): InlineEditor.SwatchPopoverHelper.SwatchPopoverHelper { return this.swatchPopoverHelperInternal; } setUserOperation(userOperation: boolean): void { this.userOperation = userOperation; } static createExclamationMark(property: SDK.CSSProperty.CSSProperty, title: string|null): Element { const exclamationElement = (document.createElement('span', {is: 'dt-icon-label'}) as UI.UIUtils.DevToolsIconLabel); exclamationElement.className = 'exclamation-mark'; if (!StylesSidebarPane.ignoreErrorsForProperty(property)) { exclamationElement .data = {iconName: 'warning-filled', color: 'var(--icon-warning)', width: '14px', height: '14px'}; } let invalidMessage: string|Common.UIString.LocalizedString; if (title) { UI.Tooltip.Tooltip.install(exclamationElement, title); invalidMessage = title; } else { invalidMessage = SDK.CSSMetadata.cssMetadata().isCSSPropertyName(property.name) ? i18nString(UIStrings.invalidPropertyValue) : i18nString(UIStrings.unknownPropertyName); UI.Tooltip.Tooltip.install(exclamationElement, invalidMessage); } const invalidString = i18nString(UIStrings.invalidString, {PH1: invalidMessage, PH2: property.name, PH3: property.value}); // Storing the invalidString for future screen reader support when editing the property property.setDisplayedStringForInvalidProperty(invalidString); return exclamationElement; } static ignoreErrorsForProperty(property: SDK.CSSProperty.CSSProperty): boolean { function hasUnknownVendorPrefix(string: string): boolean { return !string.startsWith('-webkit-') && /^[-_][\w\d]+-\w/.test(string); } const name = property.name.toLowerCase(); // IE hack. if (name.charAt(0) === '_') { return true; } // IE has a different format for this. if (name === 'filter') { return true; } // Common IE-specific property prefix. if (name.startsWith('scrollbar-')) { return true; } if (hasUnknownVendorPrefix(name)) { return true; } const value = property.value.toLowerCase(); // IE hack. if (value.endsWith('\\9')) { return true; } if (hasUnknownVendorPrefix(value)) { return true; } return false; } static createPropertyFilterElement( placeholder: string, container: Element, filterCallback: (arg0: RegExp|null) => void): Element { const input = document.createElement('input'); input.type = 'search'; input.classList.add('custom-search-input'); input.placeholder = placeholder; function searchHandler(): void { const regex = input.value ? new RegExp(Platform.StringUtilities.escapeForRegExp(input.value), 'i') : null; filterCallback(regex); } input.addEventListener('input', searchHandler, false); function keydownHandler(event: Event): void { const keyboardEvent = (event as KeyboardEvent); if (keyboardEvent.key !== Platform.KeyboardUtilities.ESCAPE_KEY || !input.value) { return; } keyboardEvent.consume(true); input.value = ''; searchHandler(); } input.addEventListener('keydown', keydownHandler, false); return input; } static formatLeadingProperties(section: StylePropertiesSection): { allDeclarationText: string, ruleText: string, } { const selectorText = section.headerText(); const indent = Common.Settings.Settings.instance().moduleSetting('textEditorIndent').get(); const style = section.style(); const lines: string[] = []; // Invalid property should also be copied. // For example: *display: inline. for (const property of style.leadingProperties()) { if (property.disabled) { lines.push(`${indent}/* ${property.name}: ${property.value}; */`); } else { lines.push(`${indent}${property.name}: ${property.value};`); } } const allDeclarationText: string = lines.join('\n'); const ruleText: string = `${selectorText} {\n${allDeclarationText}\n}`; return { allDeclarationText, ruleText, }; } revealProperty(cssProperty: SDK.CSSProperty.CSSProperty): void { this.decorator.highlightProperty(cssProperty); this.lastRevealedProperty = cssProperty; this.update(); } jumpToProperty(propertyName: string): void { this.decorator.findAndHighlightPropertyName(propertyName); } jumpToSectionBlock(section: string): void { this.decorator.findAndHighlightSectionBlock(section); } forceUpdate(): void { this.needsForceUpdate = true; this.swatchPopoverHelperInternal.hide(); this.#updateAbortController?.abort(); this.resetCache(); this.update(); } private sectionsContainerKeyDown(event: Event): void { const activeElement = Platform.DOMUtilities.deepActiveElement(this.sectionsContainer.ownerDocument); if (!activeElement) { return; } const section = this.sectionByElement.get(activeElement); if (!section) { return; } let sectionToFocus: (StylePropertiesSection|null)|null = null; let willIterateForward = false; switch ((event as KeyboardEvent).key) { case 'ArrowUp': case 'ArrowLeft': { sectionToFocus = section.previousSibling() || section.lastSibling(); willIterateForward = false; break; } case 'ArrowDown': case 'ArrowRight': { sectionToFocus = section.nextSibling() || section.firstSibling(); willIterateForward = true; break; } case 'Home': { sectionToFocus = section.firstSibling(); willIterateForward = true; break; } case 'End': { sectionToFocus = section.lastSibling(); willIterateForward = false; break; } } if (sectionToFocus && this.filterRegexInternal) { sectionToFocus = sectionToFocus.findCurrentOrNextVisible(/* willIterateForward= */ willIterateForward); } if (sectionToFocus) { sectionToFocus.element.focus(); event.consume(true); } } private sectionsContainerFocusChanged(): void { this.resetFocus(); } resetFocus(): void { // When a styles section is focused, shift+tab should leave the section. // Leaving tabIndex = 0 on the first element would cause it to be focused instead. if (!this.noMatchesElement.classList.contains('hidden')) { return; } if (this.sectionBlocks[0] && this.sectionBlocks[0].sections[0]) { const firstVisibleSection = this.sectionBlocks[0].sections[0].findCurrentOrNextVisible(/* willIterateForward= */ true); if (firstVisibleSection) { firstVisibleSection.element.tabIndex = this.sectionsContainer.hasFocus() ? -1 : 0; } } } onAddButtonLongClick(event: Event): void { const cssModel = this.cssModel(); if (!cssModel) { return; } const headers = cssModel.styleSheetHeaders().filter(styleSheetResourceHeader); const contextMenuDescriptors: { text: string, handler: () => Promise<void>, }[] = []; for (let i = 0; i < headers.length; ++i) { const header = headers[i]; const handler = this.createNewRuleInStyleSheet.bind(this, header); contextMenuDescriptors.push({text: Bindings.ResourceUtils.displayNameForURL(header.resourceURL()), handler}); } contextMenuDescriptors.sort(compareDescriptors); const contextMenu = new UI.ContextMenu.ContextMenu(event); for (let i = 0; i < contextMenuDescriptors.length; ++i) { const descriptor = contextMenuDescriptors[i]; contextMenu.defaultSection().appendItem(descriptor.text, descriptor.handler); } contextMenu.footerSection().appendItem( 'inspector-stylesheet', this.createNewRuleInViaInspectorStyleSheet.bind(this)); void contextMenu.show(); function compareDescriptors( descriptor1: { text: string, handler: () => Promise<void>, }, descriptor2: { text: string, handler: () => Promise<void>, }): number { return Platform.StringUtilities.naturalOrderComparator(descriptor1.text, descriptor2.text); } function styleSheetResourceHeader(header: SDK.CSSStyleSheetHeader.CSSStyleSheetHeader): boolean { return !header.isViaInspector() && !header.isInline && Boolean(header.resourceURL()); } } private onFilterChanged(regex: RegExp|null): void { this.lastFilterChange = Date.now(); this.filterRegexInternal = regex; this.updateFilter(); this.resetFocus(); setTimeout(() => { if (this.lastFilterChange) { const stillTyping = Date.now() - this.lastFilterChange < FILTER_IDLE_PERIOD; if (!stillTyping) { UI.ARIAUtils.alert( this.visibleSections ? i18nString(UIStrings.visibleSelectors, {n: this.visibleSections}) : i18nString(UIStrings.noMatchingSelectorOrStyle)); } } }, FILTER_IDLE_PERIOD); } refreshUpdate(editedSection: StylePropertiesSection, editedTreeElement?: StylePropertyTreeElement): void { if (editedTreeElement) { for (const section of this.allSections()) { if (section instanceof BlankStylePropertiesSection && section.isBlank) { continue; } section.updateVarFunctions(editedTreeElement); } } if (this.isEditingStyle) { return; } const node = this.node(); if (!node) { return; } for (const section of this.allSections()) { if (section instanceof BlankStylePropertiesSection && section.isBlank) { continue; } section.update(section === editedSection); } if (this.filterRegexInternal) { this.updateFilter(); } this.swatchPopoverHelper().reposition(); this.nodeStylesUpdatedForTest(node, false); } override async doUpdate(): Promise<void> { this.#updateAbortController?.abort(); this.#updateAbortController = new AbortController(); await this.#innerDoUpdate(this.#updateAbortController.signal); // Hide all popovers when scrolling. // Styles and Computed panels both have popover (e.g. imagePreviewPopover), // so we need to bind both scroll events. const scrollerElementLists = this?.contentElement?.enclosingNodeOrSelfWithClass('style-panes-wrapper') ?.parentElement?.querySelectorAll('.style-panes-wrapper') as unknown as NodeListOf<Element>; if (scrollerElementLists.length > 0) { for (const element of scrollerElementLists) { this.scrollerElement = element; this.scrollerElement.addEventListener('scroll', this.boundOnScroll, false); } } } async #innerDoUpdate(signal: AbortSignal): Promise<void> { if (!this.initialUpdateCompleted) { window.setTimeout(() => { if (signal.aborted) { return; } if (!this.initialUpdateCompleted) { // the spinner will get automatically removed when innerRebuildUpdate is called this.sectionsContainer.createChild('span', 'spinner'); } }, 200 /* only spin for loading time > 200ms to avoid unpleasant render flashes */); } const matchedStyles = await this.fetchMatchedCascade(); if (signal.aborted) { return; } const nodeId = this.node()?.id; const parentNodeId = matchedStyles?.getParentLayoutNodeId(); const [computedStyles, parentsComputedStyles] = await Promise.all([this.fetchComputedStylesFor(nodeId), this.fetchComputedStylesFor(parentNodeId)]); if (signal.aborted) { return; } await this.innerRebuildUpdate(signal, matchedStyles, computedStyles, parentsComputedStyles); if (signal.aborted) { return; } if (!this.initialUpdateCompleted) { this.initialUpdateCompleted = true; this.appendToolbarItem(this.createRenderingShortcuts()); if (Root.Runtime.experiments.isEnabled(Root.Runtime.ExperimentName.STYLES_PANE_CSS_CHANGES)) { this.#copyChangesButton = this.createCopyAllChangesButton(); this.appendToolbarItem(this.#copyChangesButton); this.#copyChangesButton.element.classList.add('hidden'); } this.dispatchEventToListeners(Events.InitialUpdateCompleted); } this.nodeStylesUpdatedForTest((this.node() as SDK.DOMModel.DOMNode), true); this.dispatchEventToListeners(Events.StylesUpdateCompleted, {hasMatchedStyles: this.hasMatchedStyles}); } private async fetchComputedStylesFor(nodeId: Protocol.DOM.NodeId|undefined): Promise<Map<string, string>|null> { const node = this.node(); if (node === null || nodeId === undefined) { return null; } return await node.domModel().cssModel().getComputedStyle(nodeId); } override onResize(): void { void this.resizeThrottler.schedule(this.innerResize.bind(this)); } private innerResize(): Promise<void> { const width = this.contentElement.getBoundingClientRect().width + 'px'; this.allSections().forEach(section => { section.propertiesTreeOutline.element.style.width = width; }); this.hideAllPopovers(); return Promise.resolve(); } private resetCache(): void { const cssModel = this.cssModel(); if (cssModel) { cssModel.discardCachedMatchedCascade(); } } private fetchMatchedCascade(): Promise<SDK.CSSMatchedStyles.CSSMatchedStyles|null> { const node = this.node(); if (!node || !this.cssModel()) { return Promise.resolve((null as SDK.CSSMatchedStyles.CSSMatchedStyles | null)); } const cssModel = this.cssModel(); if (!cssModel) { return Promise.resolve(null); } return cssModel.cachedMatchedCascadeForNode(node).then(validateStyles.bind(this)); function validateStyles(this: StylesSidebarPane, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles|null): SDK.CSSMatchedStyles.CSSMatchedStyles|null { return matchedStyles && matchedStyles.node() === this.node() ? matchedStyles : null; } } setEditingStyle(editing: boolean, _treeElement?: StylePropertyTreeElement): void { if (this.isEditingStyle === editing) { return; } this.contentElement.classList.toggle('is-editing-style', editing); this.isEditingStyle = editing; this.setActiveProperty(null); } setActiveProperty(treeElement: StylePropertyTreeElement|null): void { if (this.isActivePropertyHighlighted) { SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(); } this.isActivePropertyHighlighted = false; if (!this.node()) { return; } if (!treeElement || treeElement.overloaded() || treeElement.inherited()) { return; } const rule = treeElement.property.ownerStyle.parentRule; const selectorList = (rule instanceof SDK.CSSRule.CSSStyleRule) ? rule.selectorText() : undefined; for (const {properties, mode} of HIGHLIGHTABLE_PROPERTIES) { if (!properties.includes(treeElement.name)) { continue; } const node = this.node(); if (!node) { continue; } node.domModel().overlayModel().highlightInOverlay( {node: (this.node() as SDK.DOMModel.DOMNode), selectorList}, mode); this.isActivePropertyHighlighted = true; break; } } override onCSSModelChanged(event: Common.EventTarget.EventTargetEvent<ComputedStyleChangedEvent>): void { const edit = event?.data && 'edit' in event.data ? event.data.edit : null; if (edit) { for (const section of this.allSections()) { section.styleSheetEdited(edit); } void this.refreshComputedStyles(); return; } if (this.userOperation || this.isEditingStyle) { void this.refreshComputedStyles(); return; } this.resetCache(); this.update(); } async refreshComputedStyles(): Promise<void> { this.#updateComputedStylesAbortController?.abort(); this.#updateAbortController = new AbortController(); const signal = this.#updateAbortController.signal; const matchedStyles = await this.fetchMatchedCascade(); const nodeId = this.node()?.id; const parentNodeId = matchedStyles?.getParentLayoutNodeId(); const [computedStyles, parentsComputedStyles] = await Promise.all([this.fetchComputedStylesFor(nodeId), this.fetchComputedStylesFor(parentNodeId)]); if (signal.aborted) { return; } for (const section of this.allSections()) { section.setComputedStyles(computedStyles); section.setParentsComputedStyles(parentsComputedStyles); section.updateAuthoringHint(); } } focusedSectionIndex(): number { let index = 0; for (const block of this.sectionBlocks) { for (const section of block.sections) { if (section.element.hasFocus()) { return index; } index++; } } return -1; } continueEditingElement(sectionIndex: number, propertyIndex: number): void { const section = this.allSections()[sectionIndex]; if (section) { const element = (section.closestPropertyForEditing(propertyIndex) as StylePropertyTreeElement | null); if (!element) { section.element.focus(); return; } element.startEditing(); } } private async innerRebuildUpdate( signal: AbortSignal, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles|null, computedStyles: Map<string, string>|null, parentsComputedStyles: Map<string, string>|null): Promise<void> { // ElementsSidebarPane's throttler schedules this method. Usually, // rebuild is suppressed while editing (see onCSSModelChanged()), but we need a // 'force' flag since the currently running throttler process cannot be canceled. if (this.needsForceUpdate) { this.needsForceUpdate = false; } else if (this.isEditingStyle || this.userOperation) { return; } const focusedIndex = this.focusedSectionIndex(); this.linkifier.reset(); const prevSections = this.sectionBlocks.map(block => block.sections).flat(); this.sectionBlocks = []; const node = this.node(); this.hasMatchedStyles = matchedStyles !== null && node !== null; if (!this.hasMatchedStyles) { this.sectionsContainer.removeChildren(); this.noMatchesElement.classList.remove('hidden'); return; } const blocks = await this.rebuildSectionsForMatchedStyleRules( (matchedStyles as SDK.CSSMatchedStyles.CSSMatchedStyles), computedStyles, parentsComputedStyles); if (signal.aborted) { return; } this.sectionBlocks = blocks; // Style sections maybe re-created when flexbox editor is activated. // With the following code we re-bind the flexbox editor to the new // section with the same index as the previous section had. const newSections = this.sectionBlocks.map(block => block.sections).flat(); const styleEditorWidget = StyleEditorWidget.instance(); const boundSection = styleEditorWidget.getSection(); if (boundSection) { styleEditorWidget.unbindContext(); for (const [index, prevSection] of prevSections.entries()) { if (boundSection === prevSection && index < newSections.length) { styleEditorWidget.bindContext(this, newSections[index]); } } } this.sectionsContainer.removeChildren(); const fragment = document.createDocumentFragment(); let index = 0; let elementToFocus: HTMLDivElement|null = null; for (const block of this.sectionBlocks) { const titleElement = block.titleElement(); if (titleElement) { fragment.appendChild(titleElement); } for (const section of block.sections) { fragment.appendChild(section.element); if (index === focusedIndex) { elementToFocus = section.element; } index++; } } this.sectionsContainer.appendChild(fragment); if (elementToFocus) { elementToFocus.focus(); } if (focusedIndex >= index) { this.sectionBlocks[0].sections[0].element.focus(); } this.sectionsContainerFocusChanged(); if (this.filterRegexInternal) { this.updateFilter(); } else { this.noMatchesElement.classList.toggle('hidden', this.sectionBlocks.length > 0); } if (this.lastRevealedProperty) { this.decorator.highlightProperty(this.lastRevealedProperty); this.lastRevealedProperty = null; } this.swatchPopoverHelper().reposition(); // Record the elements tool load time after the sidepane has loaded. Host.userMetrics.panelLoaded('elements', 'DevTools.Launch.Elements'); this.dispatchEventToListeners(Events.StylesUpdateCompleted, {hasMatchedStyles: false}); } private nodeStylesUpdatedForTest(_node: SDK.DOMModel.DOMNode, _rebuild: boolean): void { // For sniffing in tests. } rebuildSectionsForMatchedStyleRulesForTest( matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, computedStyles: Map<string, string>|null, parentsComputedStyles: Map<string, string>|null): Promise<SectionBlock[]> { return this.rebuildSectionsForMatchedStyleRules(matchedStyles, computedStyles, parentsComputedStyles); } private async rebuildSectionsForMatchedStyleRules( matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, computedStyles: Map<string, string>|null, parentsComputedStyles: Map<string, string>|null): Promise<SectionBlock[]> { if (this.idleCallbackManager) { this.idleCallbackManager.discard(); } this.idleCallbackManager = new IdleCallbackManager(); const blocks = [new SectionBlock(null)]; let sectionIdx = 0; let lastParentNode: SDK.DOMModel.DOMNode|null = null; let lastLayers: SDK.CSSLayer.CSSLayer[]|null = null; let sawLayers: boolean = false; const addLayerSeparator = (style: SDK.CSSStyleDeclaration.CSSStyleDeclaration): void => { const parentRule = style.parentRule; if (parentRule instanceof SDK.CSSRule.CSSStyleRule) { const layers = parentRule.layers; if ((layers.length || lastLayers) && lastLayers !== layers) { const block = SectionBlock.createLayerBlock(parentRule); blocks.push(block); sawLayers = true; lastLayers = layers; } } }; // We disable the layer widget initially. If we see a layer in // the matched styles we reenable the button. LayersWidget.ButtonProvider.instance().item().setVisible(false); const refreshedURLs = new Set<string>(); for (const style of matchedStyles.nodeStyles()) { if (Root.Runtime.experiments.isEnabled(Root.Runtime.ExperimentName.STYLES_PANE_CSS_CHANGES) && style.parentRule) { const url = style.parentRule.resourceURL(); if (url && !refreshedURLs.has(url)) { await this.trackURLForChanges(url); refreshedURLs.add(url); } } const parentNode = matchedStyles.isInherited(style) ? matchedStyles.nodeForStyle(style) : null; if (parentNode && parentNode !== lastParentNode) { lastParentNode = parentNode; const block = await SectionBlock.createInheritedNodeBlock(lastParentNode); blocks.push(block); } addLayerSeparator(style); const lastBlock = blocks[blocks.length - 1]; if (lastBlock) { this.idleCallbackManager.schedule(() => { const section = new StylePropertiesSection(this, matchedStyles, style, sectionIdx, computedStyles, parentsComputedStyles); sectionIdx++; lastBlock.sections.push(section); }); } } const customHighlightPseudoRulesets: { highlightName: string|null, pseudoType: Protocol.DOM.PseudoType, pseudoStyles: SDK.CSSStyleDeclaration.CSSStyleDeclaration[], }[] = Array.from(matchedStyles.customHighlightPseudoNames()).map(highlightName => { return { 'highlightName': highlightName, 'pseudoType': Protocol.DOM.PseudoType.Highlight, 'pseudoStyles': matchedStyles.customHighlightPseudoStyles(highlightName), }; }); const otherPseudoRulesets: { highlightName: string|null, pseudoType: Protocol.DOM.PseudoType, pseudoStyles: SDK.CSSStyleDeclaration.CSSStyleDeclaration[], }[] = [...matchedStyles.pseudoTypes()].map(pseudoType => { return {'highlightName': null, 'pseudoType': pseudoType, 'pseudoStyles': matchedStyles.pseudoStyles(pseudoType)}; }); const pseudoRulesets = customHighlightPseudoRulesets.concat(otherPseudoRulesets).sort((a, b) => { // We want to show the ::before pseudos first, followed by the remaining pseudos // in alphabetical order. if (a.pseudoType === Protocol.DOM.PseudoType.Before && b.pseudoType !== Protocol.DOM.PseudoType.Before) { return -1; } if (a.pseudoType !== Protocol.DOM.PseudoType.Before && b.pseudoType === Protocol.DOM.PseudoType.Before) { return 1; } if (a.pseudoType < b.pseudoType) { return -1; } if (a.pseudoType > b.pseudoType) { return 1; } return 0; }); for (const pseudo of pseudoRulesets) { lastParentNode = null; for (let i = 0; i < pseudo.pseudoStyles.length; ++i) { const style = pseudo.pseudoStyles[i]; const parentNode = matchedStyles.isInherited(style) ? matchedStyles.nodeForStyle(style) : null; // Start a new SectionBlock if this is the first rule for this pseudo type, or if this // rule is inherited from a different parent than the previous rule. if (i === 0 || parentNode !== lastParentNode) { lastLayers = null; if (parentNode) { const block = await SectionBlock.createInheritedPseudoTypeBlock(pseudo.pseudoType, pseudo.highlightName, parentNode); blocks.push(block); } else { const block = SectionBlock.createPseudoTypeBlock(pseudo.pseudoType, pseudo.highlightName); blocks.push(block); } } lastParentNode = parentNode; addLayerSeparator(style); const lastBlock = blocks[blocks.length - 1]; this.idleCallbackManager.schedule(() => { const section = new HighlightPseudoStylePropertiesSection( this, matchedStyles, style, sectionIdx, computedStyles, parentsComputedStyles); sectionIdx++; lastBlock.sections.push(section); }); } } for (const keyframesRule of matchedStyles.keyframes()) { const block = SectionBlock.createKeyframesBlock(keyframesRule.name().text); for (const keyframe of keyframesRule.keyframes()) { this.idleCallbackManager.schedule(() => { block.sections.push(new KeyframePropertiesSection(this, matchedStyles, keyframe.style, sectionIdx)); sectionIdx++; }); } blocks.push(block); } for (const positionFallbackRule of matchedStyles.positionFallbackRules()) { const block = SectionBlock.createPositionFallbackBlock(positionFallbackRule.name().text); for (const tryRule of positionFallbackRule.tryRules()) { this.idleCallbackManager.schedule(() => { block.sections.push(new TryRuleSection( this, matchedStyles, tryRule.style, sectionIdx, computedStyles, parentsComputedStyles)); sectionIdx++; }); } blocks.push(block); } // If we have seen a layer in matched styles we enable // the layer widget button. if (sawLayers) { LayersWidget.ButtonProvider.instance().item().setVisible(true); } else if (LayersWidget.LayersWidget.instance().isShowing()) { // Since the button for toggling the layers view is now hidden // we ensure that the layers view is not currently toggled. ElementsPanel.instance().showToolbarPane(null, LayersWidget.ButtonProvider.instance().item()); } await this.idleCallbackManager.awaitDone(); return blocks; } async createNewRuleInViaInspectorStyleSheet(): Promise<void> { const cssModel = this.cssModel(); const node = this.node(); if (!cssModel || !node) { return; } this.setUserOperation(true); const styleSheetHeader = await cssModel.requestViaInspectorStylesheet((node as SDK.DOMModel.DOMNode)); this.setUserOperation(false); await this.createNewRuleInStyleSheet(styleSheetHeader); } private async createNewRuleInStyleSheet(styleSheetHeader: SDK.CSSStyleSheetHeader.CSSStyleSheetHeader| null): Promise<void> { if (!styleSheetHeader) { return; } const text = (await styleSheetHeader.requestContent()).content || ''; const lines = text.split('\n'); const range = TextUtils.TextRange.TextRange.createFromLocation(lines.length - 1, lines[lines.length - 1].length); if (this.sectionBlocks && this.sectionBlocks.length > 0) { this.addBlankSection(this.sectionBlocks[0].sections[0], styleSheetHeader.id, range); } } addBlankSection( insertAfterSection: StylePropertiesSection, styleSheetId: Protocol.CSS.StyleSheetId, ruleLocation: TextUtils.TextRange.TextRange): void { const node = this.node(); const blankSection = new BlankStylePropertiesSection( this, insertAfterSection.matchedStyles, node ? node.simpleSelector() : '', styleSheetId, ruleLocation, insertAfterSection.style(), 0); this.sectionsContainer.insertBefore(blankSection.element, insertAfterSection.element.nextSibling); for (const block of this.sectionBlocks) { const index = block.sections.indexOf(insertAfterSection); if (index === -1) { continue; } block.sections.splice(index + 1, 0, blankSection); blankSection.startEditingSelector(); } let sectionIdx = 0; for (const block of this.sectionBlocks) { for (const section of block.sections) { section.setSectionIdx(sectionIdx); sectionIdx++; } } } removeSection(section: StylePropertiesSection): void { for (const block of this.sectionBlocks) { const index = block.sections.indexOf(section); if (index === -1) { continue; } block.sections.splice(index, 1); section.element.remove(); } } filterRegex(): RegExp|null { return this.filterRegexInternal; } private updateFilter(): void { let hasAnyVi