| // 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. |
| /* eslint-disable @devtools/no-imperative-dom-api */ |
| |
| /* |
| * 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 '../../ui/legacy/legacy.js'; |
| |
| 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 {assertNotNullOrUndefined} 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 * as TextUtils from '../../models/text_utils/text_utils.js'; |
| import {createIcon, Icon} from '../../ui/kit/kit.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 VisualLogging from '../../ui/visual_logging/visual_logging.js'; |
| import * as PanelsCommon from '../common/common.js'; |
| |
| import * as ElementsComponents from './components/components.js'; |
| import type {ComputedStyleModel, CSSModelChangedEvent} from './ComputedStyleModel.js'; |
| import {ElementsPanel} from './ElementsPanel.js'; |
| import {ElementsSidebarPane} from './ElementsSidebarPane.js'; |
| import {ImagePreviewPopover} from './ImagePreviewPopover.js'; |
| import * as LayersWidget from './LayersWidget.js'; |
| import {StyleEditorWidget} from './StyleEditorWidget.js'; |
| import { |
| AtRuleSection, |
| BlankStylePropertiesSection, |
| FunctionRuleSection, |
| HighlightPseudoStylePropertiesSection, |
| KeyframePropertiesSection, |
| PositionTryRuleSection, |
| RegisteredPropertiesSection, |
| StylePropertiesSection, |
| } from './StylePropertiesSection.js'; |
| import {StylePropertyHighlighter} from './StylePropertyHighlighter.js'; |
| import type {StylePropertyTreeElement} from './StylePropertyTreeElement.js'; |
| import stylesSidebarPaneStyles from './stylesSidebarPane.css.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 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 pseudo 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 |
| * @example {Alt} PH2 |
| */ |
| incrementdecrementWithMousewheelOne: |
| 'Increment/decrement with mousewheel or up/down keys. {PH1}: R ±1, Shift: G ±1, {PH2}: B ±1', |
| /** |
| * @description Title of in styles sidebar pane of the elements panel |
| * @example {Ctrl} PH1 |
| * @example {Alt} PH2 |
| */ |
| incrementdecrementWithMousewheelHundred: |
| 'Increment/decrement with mousewheel or up/down keys. {PH1}: ±100, Shift: ±10, {PH2}: ±0.1', |
| /** |
| * @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 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', |
| } as const; |
| |
| 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; |
| // Minimum number of @property rules for the @property section block to be folded initially |
| const MIN_FOLDED_SECTIONS_COUNT = 5; |
| /** Title of the registered properties section **/ |
| export const REGISTERED_PROPERTY_SECTION_NAME = '@property'; |
| /** Title of the function section **/ |
| export const FUNCTION_SECTION_NAME = '@function'; |
| /** Title of the general at-rule section */ |
| export const AT_RULE_SECTION_NAME = '@font-*'; |
| |
| // 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']}, |
| ]; |
| |
| export class StylesSidebarPane extends Common.ObjectWrapper.eventMixin<EventTypes, typeof ElementsSidebarPane>( |
| ElementsSidebarPane) { |
| private matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles|null = null; |
| private currentToolbarPane: UI.Widget.Widget|null = null; |
| private animatedToolbarPane: UI.Widget.Widget|null = null; |
| private pendingWidget: UI.Widget.Widget|null = null; |
| private pendingWidgetToggle: UI.Toolbar.ToolbarToggle|null = null; |
| private toolbar: UI.Toolbar.Toolbar|null = null; |
| private toolbarPaneElement: HTMLElement; |
| private lastFilterChange: number|null = null; |
| private visibleSections: number|null = null; |
| private noMatchesElement: HTMLElement; |
| private sectionsContainer: UI.Widget.Widget; |
| sectionByElement = new WeakMap<Node, StylePropertiesSection>(); |
| readonly #swatchPopoverHelper = new InlineEditor.SwatchPopoverHelper.SwatchPopoverHelper(); |
| readonly linkifier = new Components.Linkifier.Linkifier(MAX_LINK_LENGTH, /* useLinkDecorator */ true); |
| |
| private readonly decorator: StylePropertyHighlighter; |
| |
| private lastRevealedProperty: SDK.CSSProperty.CSSProperty|null = null; |
| private userOperation = false; |
| isEditingStyle = false; |
| #filterRegex: RegExp|null = null; |
| private isActivePropertyHighlighted = false; |
| private initialUpdateCompleted = false; |
| hasMatchedStyles = false; |
| private sectionBlocks: SectionBlock[] = []; |
| private idleCallbackManager: IdleCallbackManager|null = null; |
| private needsForceUpdate = false; |
| private readonly resizeThrottler = new Common.Throttler.Throttler(100); |
| private readonly resetUpdateThrottler = new Common.Throttler.Throttler(500); |
| private readonly computedStyleUpdateThrottler = new Common.Throttler.Throttler(500); |
| |
| private scrollerElement?: Element; |
| private readonly boundOnScroll: (event: Event) => void; |
| |
| private readonly imagePreviewPopover: ImagePreviewPopover; |
| #webCustomData?: WebCustomData; |
| |
| activeCSSAngle: InlineEditor.CSSAngle.CSSAngle|null = null; |
| #updateAbortController?: AbortController; |
| #updateComputedStylesAbortController?: AbortController; |
| |
| constructor(computedStyleModel: ComputedStyleModel) { |
| super(computedStyleModel, true /* delegatesFocus */); |
| this.setMinimumSize(96, 26); |
| this.registerRequiredCSS(stylesSidebarPaneStyles); |
| Common.Settings.Settings.instance().moduleSetting('text-editor-indent').addChangeListener(this.update.bind(this)); |
| this.toolbarPaneElement = this.createStylesSidebarToolbar(); |
| this.noMatchesElement = this.contentElement.createChild('div', 'gray-info-message hidden'); |
| this.noMatchesElement.textContent = i18nString(UIStrings.noMatchingSelectorOrStyle); |
| this.sectionsContainer = new UI.Widget.VBox(); |
| this.sectionsContainer.show(this.contentElement); |
| UI.ARIAUtils.markAsList(this.sectionsContainer.contentElement); |
| this.sectionsContainer.contentElement.addEventListener('keydown', this.sectionsContainerKeyDown.bind(this), false); |
| this.sectionsContainer.contentElement.addEventListener( |
| 'focusin', this.sectionsContainerFocusChanged.bind(this), false); |
| this.sectionsContainer.contentElement.addEventListener( |
| 'focusout', this.sectionsContainerFocusChanged.bind(this), false); |
| |
| this.#swatchPopoverHelper.addEventListener( |
| InlineEditor.SwatchPopoverHelper.Events.WILL_SHOW_POPOVER, this.hideAllPopovers, this); |
| this.decorator = new StylePropertyHighlighter(this); |
| this.contentElement.classList.add('styles-pane'); |
| |
| UI.Context.Context.instance().addFlavorChangeListener(SDK.DOMModel.DOMNode, this.forceUpdate, this); |
| this.contentElement.addEventListener('copy', this.clipboardCopy.bind(this)); |
| |
| 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()); |
| } |
| |
| get webCustomData(): WebCustomData|undefined { |
| if (!this.#webCustomData && |
| Common.Settings.Settings.instance().moduleSetting('show-css-property-documentation-on-hover').get()) { |
| // WebCustomData.create() fetches the property docs, so this must happen lazily. |
| this.#webCustomData = WebCustomData.create(); |
| } |
| return this.#webCustomData; |
| } |
| |
| private onScroll(_event: Event): void { |
| this.hideAllPopovers(); |
| } |
| |
| swatchPopoverHelper(): InlineEditor.SwatchPopoverHelper.SwatchPopoverHelper { |
| return this.#swatchPopoverHelper; |
| } |
| |
| setUserOperation(userOperation: boolean): void { |
| this.userOperation = userOperation; |
| } |
| |
| 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 formatLeadingProperties(section: StylePropertiesSection): { |
| allDeclarationText: string, |
| ruleText: string, |
| } { |
| const selectorText = section.headerText(); |
| const indent = Common.Settings.Settings.instance().moduleSetting('text-editor-indent').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 = `${selectorText} {\n${allDeclarationText}\n}`; |
| |
| return { |
| allDeclarationText, |
| ruleText, |
| }; |
| } |
| |
| revealProperty(cssProperty: SDK.CSSProperty.CSSProperty): void { |
| void this.decorator.highlightProperty(cssProperty); |
| this.lastRevealedProperty = cssProperty; |
| this.update(); |
| } |
| |
| jumpToProperty(propertyName: string, sectionName?: string, blockName?: string): boolean { |
| return this.decorator.findAndHighlightPropertyName(propertyName, sectionName, blockName); |
| } |
| |
| jumpToDeclaration(valueSource: SDK.CSSMatchedStyles.CSSValueSource): void { |
| if (valueSource.declaration instanceof SDK.CSSProperty.CSSProperty) { |
| this.revealProperty(valueSource.declaration); |
| } else { |
| this.jumpToProperty('initial-value', valueSource.name, REGISTERED_PROPERTY_SECTION_NAME); |
| } |
| } |
| |
| jumpToSection(sectionName: string, blockName: string): void { |
| this.decorator.findAndHighlightSection(sectionName, blockName); |
| } |
| |
| jumpToSectionBlock(section: string): void { |
| this.decorator.findAndHighlightSectionBlock(section); |
| } |
| |
| jumpToFunctionDefinition(functionName: string): void { |
| this.jumpToSection(functionName, FUNCTION_SECTION_NAME); |
| } |
| |
| jumpToFontPaletteDefinition(paletteName: string): void { |
| this.jumpToSection(`@font-palette-values ${paletteName}`, AT_RULE_SECTION_NAME); |
| } |
| |
| forceUpdate(): void { |
| this.needsForceUpdate = true; |
| this.#swatchPopoverHelper.hide(); |
| this.#updateAbortController?.abort(); |
| this.resetCache(); |
| this.update(); |
| } |
| |
| private sectionsContainerKeyDown(event: Event): void { |
| const activeElement = UI.DOMUtilities.deepActiveElement(this.sectionsContainer.contentElement.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.#filterRegex) { |
| 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]?.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: Array<{ |
| 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, {jslogContext: 'style-sheet-header'}); |
| } |
| contextMenu.footerSection().appendItem( |
| 'inspector-stylesheet', this.createNewRuleInViaInspectorStyleSheet.bind(this), |
| {jslogContext: 'inspector-stylesheet'}); |
| 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(event: Common.EventTarget.EventTargetEvent<string>): void { |
| const regex = event.data ? new RegExp(Platform.StringUtilities.escapeForRegExp(event.data), 'i') : null; |
| this.setFilter(regex); |
| } |
| |
| setFilter(regex: RegExp|null): void { |
| this.lastFilterChange = Date.now(); |
| this.#filterRegex = regex; |
| this.updateFilter(); |
| this.resetFocus(); |
| setTimeout(() => { |
| if (this.lastFilterChange) { |
| const stillTyping = Date.now() - this.lastFilterChange < FILTER_IDLE_PERIOD; |
| if (!stillTyping) { |
| UI.ARIAUtils.LiveAnnouncer.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.#filterRegex) { |
| 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.contentElement.createChild('span', 'spinner'); |
| } |
| }, 200 /* only spin for loading time > 200ms to avoid unpleasant render flashes */); |
| } |
| |
| const matchedStyles = await this.fetchMatchedCascade(); |
| |
| if (signal.aborted) { |
| return; |
| } |
| |
| this.matchedStyles = matchedStyles; |
| const nodeId = this.node()?.id; |
| const parentNodeId = this.matchedStyles?.getParentLayoutNodeId(); |
| |
| const [computedStyles, parentsComputedStyles] = |
| await Promise.all([this.fetchComputedStylesFor(nodeId), this.fetchComputedStylesFor(parentNodeId)]); |
| |
| if (signal.aborted) { |
| return; |
| } |
| |
| await this.innerRebuildUpdate(signal, this.matchedStyles, computedStyles, parentsComputedStyles); |
| |
| if (signal.aborted) { |
| return; |
| } |
| |
| if (!this.initialUpdateCompleted) { |
| this.initialUpdateCompleted = true; |
| this.appendToolbarItem(this.createRenderingShortcuts()); |
| this.dispatchEventToListeners(Events.INITIAL_UPDATE_COMPLETED); |
| } |
| |
| this.nodeStylesUpdatedForTest((this.node() as SDK.DOMModel.DOMNode), true); |
| |
| this.dispatchEventToListeners(Events.STYLES_UPDATE_COMPLETED, {hasMatchedStyles: this.hasMatchedStyles}); |
| } |
| |
| #getRegisteredPropertyDetails(matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, variableName: string): |
| ElementsComponents.CSSVariableValueView.RegisteredPropertyDetails|undefined { |
| const registration = matchedStyles.getRegisteredProperty(variableName); |
| const goToDefinition = (): void => this.jumpToSection(variableName, REGISTERED_PROPERTY_SECTION_NAME); |
| return registration ? {registration, goToDefinition} : undefined; |
| } |
| |
| getVariableParserError(matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, variableName: string): |
| ElementsComponents.CSSVariableValueView.CSSVariableParserError|null { |
| const registrationDetails = this.#getRegisteredPropertyDetails(matchedStyles, variableName); |
| return registrationDetails ? |
| new ElementsComponents.CSSVariableValueView.CSSVariableParserError(registrationDetails) : |
| null; |
| } |
| |
| getVariablePopoverContents( |
| matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, variableName: string, |
| computedValue: string|null): ElementsComponents.CSSVariableValueView.CSSVariableValueView { |
| return new ElementsComponents.CSSVariableValueView.CSSVariableValueView({ |
| variableName, |
| value: computedValue ?? undefined, |
| details: this.#getRegisteredPropertyDetails(matchedStyles, variableName), |
| }); |
| } |
| |
| 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.#resize.bind(this)); |
| } |
| |
| #resize(): 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): 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<CSSModelChangedEvent>): 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; |
| } |
| |
| this.#resetUpdateIfNotEditing(); |
| } |
| |
| override onComputedStyleChanged(): void { |
| if (!Root.Runtime.hostConfig.devToolsAnimationStylesInStylesTab?.enabled) { |
| return; |
| } |
| |
| void this.computedStyleUpdateThrottler.schedule(async () => { |
| await this.#updateAnimatedStyles(); |
| this.handledComputedStyleChangedForTest(); |
| }); |
| } |
| |
| handledComputedStyleChangedForTest(): void { |
| } |
| |
| #resetUpdateIfNotEditing(): void { |
| if (this.userOperation || this.isEditingStyle) { |
| void this.#refreshComputedStyles(); |
| return; |
| } |
| |
| this.resetCache(); |
| this.update(); |
| } |
| |
| #scheduleResetUpdateIfNotEditing(): void { |
| this.scheduleResetUpdateIfNotEditingCalledForTest(); |
| |
| void this.resetUpdateThrottler.schedule(async () => { |
| this.#resetUpdateIfNotEditing(); |
| }); |
| } |
| |
| scheduleResetUpdateIfNotEditingCalledForTest(): void { |
| } |
| |
| async #updateAnimatedStyles(): Promise<void> { |
| if (!this.matchedStyles) { |
| return; |
| } |
| |
| const nodeId = this.node()?.id; |
| if (!nodeId) { |
| return; |
| } |
| |
| const animatedStyles = await this.cssModel()?.getAnimatedStylesForNode(nodeId); |
| if (!animatedStyles) { |
| return; |
| } |
| |
| const updateStyleSection = |
| (currentStyle: SDK.CSSStyleDeclaration.CSSStyleDeclaration|null, newStyle: Protocol.CSS.CSSStyle|null): |
| void => { |
| // The newly fetched matched styles contain a new style. |
| if (newStyle) { |
| // If the number of CSS properties in the new style |
| // differs from the current style, it indicates a potential change |
| // in property overrides. In this case, re-fetch the entire style |
| // cascade to ensure accurate updates. |
| if (currentStyle?.allProperties().length !== newStyle.cssProperties.length) { |
| this.#scheduleResetUpdateIfNotEditing(); |
| return; |
| } |
| |
| // If the number of properties remains the same, update the |
| // existing style properties with the new values from the |
| // fetched style. |
| currentStyle.allProperties().forEach((property, index) => { |
| const newProperty = newStyle.cssProperties[index]; |
| if (!newProperty) { |
| return; |
| } |
| |
| property.setLocalValue(newProperty.value); |
| }); |
| } else if (currentStyle) { |
| // If no new style is fetched while a current style exists, |
| // it implies the style has been removed (e.g., animation or |
| // transition ended). Trigger a reset and update the UI to |
| // reflect this change. |
| this.#scheduleResetUpdateIfNotEditing(); |
| return; |
| } |
| }; |
| |
| updateStyleSection(this.matchedStyles.transitionsStyle() ?? null, animatedStyles.transitionsStyle ?? null); |
| |
| const animationStyles = this.matchedStyles.animationStyles() ?? []; |
| const animationStylesPayload = animatedStyles.animationStyles ?? []; |
| // There either is a new animation or a previous animation is ended. |
| if (animationStyles.length !== animationStylesPayload.length) { |
| this.#scheduleResetUpdateIfNotEditing(); |
| return; |
| } |
| |
| for (let i = 0; i < animationStyles.length; i++) { |
| const currentAnimationStyle = animationStyles[i]; |
| const nextAnimationStyle = animationStylesPayload[i].style; |
| updateStyleSection(currentAnimationStyle ?? null, nextAnimationStyle); |
| } |
| |
| const inheritedStyles = this.matchedStyles.inheritedStyles() ?? []; |
| const currentInheritedTransitionsStyles = |
| inheritedStyles.filter(style => style.type === SDK.CSSStyleDeclaration.Type.Transition); |
| const newInheritedTransitionsStyles = |
| animatedStyles.inherited?.map(inherited => inherited.transitionsStyle) |
| .filter( |
| style => style?.cssProperties.some( |
| cssProperty => SDK.CSSMetadata.cssMetadata().isPropertyInherited(cssProperty.name))) ?? |
| []; |
| if (currentInheritedTransitionsStyles.length !== newInheritedTransitionsStyles.length) { |
| this.#scheduleResetUpdateIfNotEditing(); |
| return; |
| } |
| |
| for (let i = 0; i < currentInheritedTransitionsStyles.length; i++) { |
| const currentInheritedTransitionsStyle = currentInheritedTransitionsStyles[i]; |
| const newInheritedTransitionsStyle = newInheritedTransitionsStyles[i]; |
| updateStyleSection(currentInheritedTransitionsStyle, newInheritedTransitionsStyle ?? null); |
| } |
| |
| const currentInheritedAnimationsStyles = |
| inheritedStyles.filter(style => style.type === SDK.CSSStyleDeclaration.Type.Animation); |
| const newInheritedAnimationsStyles = |
| animatedStyles.inherited?.flatMap(inherited => inherited.animationStyles) |
| .filter( |
| animationStyle => animationStyle?.style.cssProperties.some( |
| cssProperty => SDK.CSSMetadata.cssMetadata().isPropertyInherited(cssProperty.name))) ?? |
| []; |
| if (currentInheritedAnimationsStyles.length !== newInheritedAnimationsStyles.length) { |
| this.#scheduleResetUpdateIfNotEditing(); |
| return; |
| } |
| |
| for (let i = 0; i < currentInheritedAnimationsStyles.length; i++) { |
| const currentInheritedAnimationsStyle = currentInheritedAnimationsStyles[i]; |
| const newInheritedAnimationsStyle = newInheritedAnimationsStyles[i]?.style; |
| updateStyleSection(currentInheritedAnimationsStyle, newInheritedAnimationsStyle ?? null); |
| } |
| } |
| |
| 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.startEditingName(); |
| } |
| } |
| |
| 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.contentElement.removeChildren(); |
| this.sectionsContainer.detachChildWidgets(); |
| 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.contentElement.removeChildren(); |
| this.sectionsContainer.detachChildWidgets(); |
| 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.contentElement.appendChild(fragment); |
| |
| if (elementToFocus) { |
| elementToFocus.focus(); |
| } |
| |
| if (focusedIndex >= index) { |
| this.sectionBlocks[0].sections[0].element.focus(); |
| } |
| |
| this.sectionsContainerFocusChanged(); |
| |
| if (this.#filterRegex) { |
| this.updateFilter(); |
| } else { |
| this.noMatchesElement.classList.toggle('hidden', this.sectionBlocks.length > 0); |
| } |
| if (this.lastRevealedProperty) { |
| void 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.STYLES_UPDATE_COMPLETED, {hasMatchedStyles: false}); |
| } |
| |
| private nodeStylesUpdatedForTest(_node: SDK.DOMModel.DOMNode, _rebuild: boolean): void { |
| // For sniffing in tests. |
| } |
| |
| setMatchedStylesForTest(matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles): void { |
| this.matchedStyles = matchedStyles; |
| } |
| |
| 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 lastLayerParent: SectionBlock|undefined; |
| let lastLayers: SDK.CSSLayer.CSSLayer[]|null = null; |
| let sawLayers = 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); |
| lastLayerParent?.childBlocks.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); |
| for (const style of matchedStyles.nodeStyles()) { |
| const parentNode = matchedStyles.isInherited(style) ? matchedStyles.nodeForStyle(style) : null; |
| if (parentNode && parentNode !== lastParentNode) { |
| lastParentNode = parentNode; |
| const block = await SectionBlock.createInheritedNodeBlock(lastParentNode); |
| lastLayerParent = block; |
| blocks.push(block); |
| } |
| |
| addLayerSeparator(style); |
| |
| const lastBlock = blocks[blocks.length - 1]; |
| const isTransitionOrAnimationStyle = style.type === SDK.CSSStyleDeclaration.Type.Transition || |
| style.type === SDK.CSSStyleDeclaration.Type.Animation; |
| if (lastBlock && (!isTransitionOrAnimationStyle || style.allProperties().length > 0)) { |
| this.idleCallbackManager.schedule(() => { |
| const section = |
| new StylePropertiesSection(this, matchedStyles, style, sectionIdx, computedStyles, parentsComputedStyles); |
| sectionIdx++; |
| lastBlock.sections.push(section); |
| }); |
| } |
| } |
| lastLayerParent = undefined; |
| |
| const customHighlightPseudoRulesets: Array<{ |
| highlightName: string | null, |
| pseudoType: Protocol.DOM.PseudoType, |
| pseudoStyles: SDK.CSSStyleDeclaration.CSSStyleDeclaration[], |
| }> = Array.from(matchedStyles.customHighlightPseudoNames()).map(highlightName => { |
| return { |
| highlightName, |
| pseudoType: Protocol.DOM.PseudoType.Highlight, |
| pseudoStyles: matchedStyles.customHighlightPseudoStyles(highlightName), |
| }; |
| }); |
| |
| const otherPseudoRulesets: Array<{ |
| highlightName: string | null, |
| pseudoType: Protocol.DOM.PseudoType, |
| pseudoStyles: SDK.CSSStyleDeclaration.CSSStyleDeclaration[], |
| }> = [...matchedStyles.pseudoTypes()].map(pseudoType => { |
| return {highlightName: null, 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); |
| lastLayerParent = block; |
| blocks.push(block); |
| } else { |
| const block = SectionBlock.createPseudoTypeBlock(pseudo.pseudoType, pseudo.highlightName); |
| lastLayerParent = block; |
| 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); |
| } |
| |
| const atRules = matchedStyles.atRules(); |
| if (atRules.length > 0) { |
| const expandedByDefault = atRules.length <= MIN_FOLDED_SECTIONS_COUNT; |
| const block = SectionBlock.createAtRuleBlock(expandedByDefault); |
| for (const atRule of atRules) { |
| this.idleCallbackManager.schedule(() => { |
| block.sections.push(new AtRuleSection(this, matchedStyles, atRule.style, sectionIdx, expandedByDefault)); |
| sectionIdx++; |
| }); |
| } |
| blocks.push(block); |
| } |
| |
| for (const positionTryRule of matchedStyles.positionTryRules()) { |
| const block = SectionBlock.createPositionTryBlock(positionTryRule.name().text); |
| this.idleCallbackManager.schedule(() => { |
| block.sections.push(new PositionTryRuleSection( |
| this, matchedStyles, positionTryRule.style, sectionIdx, positionTryRule.active())); |
| sectionIdx++; |
| }); |
| blocks.push(block); |
| } |
| |
| if (matchedStyles.registeredProperties().length > 0) { |
| const expandedByDefault = matchedStyles.registeredProperties().length <= MIN_FOLDED_SECTIONS_COUNT; |
| const block = SectionBlock.createRegisteredPropertiesBlock(expandedByDefault); |
| for (const propertyRule of matchedStyles.registeredProperties()) { |
| this.idleCallbackManager.schedule(() => { |
| block.sections.push(new RegisteredPropertiesSection( |
| this, matchedStyles, propertyRule.style(), sectionIdx, propertyRule.propertyName(), expandedByDefault)); |
| sectionIdx++; |
| }); |
| } |
| blocks.push(block); |
| } |
| |
| if (matchedStyles.functionRules().length > 0) { |
| const expandedByDefault = matchedStyles.functionRules().length <= MIN_FOLDED_SECTIONS_COUNT; |
| const block = SectionBlock.createFunctionBlock(expandedByDefault); |
| for (const functionRule of matchedStyles.functionRules()) { |
| this.idleCallbackManager.schedule(() => { |
| block.sections.push(new FunctionRuleSection( |
| this, matchedStyles, functionRule.style, functionRule.children(), sectionIdx, |
| functionRule.nameWithParameters(), expandedByDefault)); |
| 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.frameId()); |
| |
| this.setUserOperation(false); |
| await this.createNewRuleInStyleSheet(styleSheetHeader); |
| } |
| |
| private async createNewRuleInStyleSheet(styleSheetHeader: SDK.CSSStyleSheetHeader.CSSStyleSheetHeader|null): |
| Promise<void> { |
| if (!styleSheetHeader) { |
| return; |
| } |
| |
| const contentDataOrError = await styleSheetHeader.requestContentData(); |
| const lines = TextUtils.ContentData.ContentData.textOr(contentDataOrError, '').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, range); |
| } |
| } |
| |
| addBlankSection( |
| insertAfterSection: StylePropertiesSection, styleSheetHeader: SDK.CSSStyleSheetHeader.CSSStyleSheetHeader, |
| ruleLocation: TextUtils.TextRange.TextRange): void { |
| const node = this.node(); |
| const blankSection = new BlankStylePropertiesSection( |
| this, insertAfterSection.matchedStyles, node ? node.simpleSelector() : '', styleSheetHeader, ruleLocation, |
| insertAfterSection.style(), 0); |
| |
| this.sectionsContainer.contentElement.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.#filterRegex; |
| } |
| |
| private updateFilter(): void { |
| let hasAnyVisibleBlock = false; |
| let visibleSections = 0; |
| for (const block of this.sectionBlocks) { |
| visibleSections += block.updateFilter(); |
| hasAnyVisibleBlock = Boolean(visibleSections) || hasAnyVisibleBlock; |
| } |
| this.noMatchesElement.classList.toggle('hidden', Boolean(hasAnyVisibleBlock)); |
| |
| this.visibleSections = visibleSections; |
| } |
| |
| override wasShown(): void { |
| UI.Context.Context.instance().setFlavor(StylesSidebarPane, this); |
| super.wasShown(); |
| } |
| |
| override willHide(): void { |
| this.hideAllPopovers(); |
| super.willHide(); |
| UI.Context.Context.instance().setFlavor(StylesSidebarPane, null); |
| } |
| |
| hideAllPopovers(): void { |
| this.#swatchPopoverHelper.hide(); |
| this.imagePreviewPopover.hide(); |
| if (this.activeCSSAngle) { |
| this.activeCSSAngle.minify(); |
| this.activeCSSAngle = null; |
| } |
| } |
| |
| getSectionBlockByName(name: string): SectionBlock|undefined { |
| return this.sectionBlocks.find(block => block.titleElement()?.textContent === name); |
| } |
| |
| allSections(): StylePropertiesSection[] { |
| let sections: StylePropertiesSection[] = []; |
| for (const block of this.sectionBlocks) { |
| sections = sections.concat(block.sections); |
| } |
| return sections; |
| } |
| |
| private clipboardCopy(_event: Event): void { |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.StyleRuleCopied); |
| } |
| |
| private createStylesSidebarToolbar(): HTMLElement { |
| const container = this.contentElement.createChild('div', 'styles-sidebar-pane-toolbar-container'); |
| container.role = 'toolbar'; |
| const hbox = container.createChild('div', 'hbox styles-sidebar-pane-toolbar'); |
| const toolbar = hbox.createChild('devtools-toolbar', 'styles-pane-toolbar'); |
| toolbar.role = 'presentation'; |
| const filterInput = new UI.Toolbar.ToolbarFilter(undefined, 1, 1, undefined, undefined, false); |
| filterInput.addEventListener(UI.Toolbar.ToolbarInput.Event.TEXT_CHANGED, this.onFilterChanged, this); |
| toolbar.appendToolbarItem(filterInput); |
| void toolbar.appendItemsAtLocation('styles-sidebarpane-toolbar'); |
| this.toolbar = toolbar; |
| |
| const toolbarPaneContainer = container.createChild('div', 'styles-sidebar-toolbar-pane-container'); |
| const toolbarPaneContent = toolbarPaneContainer.createChild('div', 'styles-sidebar-toolbar-pane'); |
| |
| return toolbarPaneContent; |
| } |
| |
| showToolbarPane(widget: UI.Widget.Widget|null, toggle: UI.Toolbar.ToolbarToggle|null): void { |
| if (this.pendingWidgetToggle) { |
| this.pendingWidgetToggle.setToggled(false); |
| } |
| this.pendingWidgetToggle = toggle; |
| |
| if (this.animatedToolbarPane) { |
| this.pendingWidget = widget; |
| } else { |
| this.startToolbarPaneAnimation(widget); |
| } |
| |
| if (widget && toggle) { |
| toggle.setToggled(true); |
| } |
| } |
| |
| appendToolbarItem(item: UI.Toolbar.ToolbarItem): void { |
| if (this.toolbar) { |
| this.toolbar.appendToolbarItem(item); |
| } |
| } |
| |
| private startToolbarPaneAnimation(widget: UI.Widget.Widget|null): void { |
| if (widget === this.currentToolbarPane) { |
| return; |
| } |
| |
| if (widget && this.currentToolbarPane) { |
| this.currentToolbarPane.detach(); |
| widget.show(this.toolbarPaneElement); |
| this.currentToolbarPane = widget; |
| this.currentToolbarPane.focus(); |
| return; |
| } |
| |
| this.animatedToolbarPane = widget; |
| |
| if (this.currentToolbarPane) { |
| this.toolbarPaneElement.style.animationName = 'styles-element-state-pane-slideout'; |
| } else if (widget) { |
| this.toolbarPaneElement.style.animationName = 'styles-element-state-pane-slidein'; |
| } |
| |
| if (widget) { |
| widget.show(this.toolbarPaneElement); |
| } |
| |
| const listener = onAnimationEnd.bind(this); |
| this.toolbarPaneElement.addEventListener('animationend', listener, false); |
| |
| function onAnimationEnd(this: StylesSidebarPane): void { |
| this.toolbarPaneElement.style.removeProperty('animation-name'); |
| this.toolbarPaneElement.removeEventListener('animationend', listener, false); |
| |
| if (this.currentToolbarPane) { |
| this.currentToolbarPane.detach(); |
| } |
| |
| this.currentToolbarPane = this.animatedToolbarPane; |
| if (this.currentToolbarPane) { |
| this.currentToolbarPane.focus(); |
| } |
| this.animatedToolbarPane = null; |
| |
| if (this.pendingWidget) { |
| this.startToolbarPaneAnimation(this.pendingWidget); |
| this.pendingWidget = null; |
| } |
| } |
| } |
| |
| private createRenderingShortcuts(): UI.Toolbar.ToolbarButton { |
| const prefersColorSchemeSetting = |
| Common.Settings.Settings.instance().moduleSetting<string>('emulated-css-media-feature-prefers-color-scheme'); |
| const autoDarkModeSetting = Common.Settings.Settings.instance().moduleSetting('emulate-auto-dark-mode'); |
| const decorateStatus = (condition: boolean, title: string): string => `${condition ? '✓ ' : ''}${title}`; |
| |
| const button = new UI.Toolbar.ToolbarToggle( |
| i18nString(UIStrings.toggleRenderingEmulations), 'brush', 'brush-filled', undefined, false); |
| button.element.setAttribute('jslog', `${VisualLogging.dropDown('rendering-emulations').track({click: true})}`); |
| button.element.addEventListener('click', event => { |
| const boundingRect = button.element.getBoundingClientRect(); |
| const menu = new UI.ContextMenu.ContextMenu(event, { |
| x: boundingRect.left, |
| y: boundingRect.bottom, |
| }); |
| const preferredColorScheme = prefersColorSchemeSetting.get(); |
| const isLightColorScheme = preferredColorScheme === 'light'; |
| const isDarkColorScheme = preferredColorScheme === 'dark'; |
| const isAutoDarkEnabled = autoDarkModeSetting.get(); |
| const lightColorSchemeOption = decorateStatus(isLightColorScheme, 'prefers-color-scheme: light'); |
| const darkColorSchemeOption = decorateStatus(isDarkColorScheme, 'prefers-color-scheme: dark'); |
| const autoDarkModeOption = decorateStatus(isAutoDarkEnabled, i18nString(UIStrings.automaticDarkMode)); |
| |
| menu.defaultSection().appendItem(lightColorSchemeOption, () => { |
| autoDarkModeSetting.set(false); |
| prefersColorSchemeSetting.set(isLightColorScheme ? '' : 'light'); |
| button.setToggled(Boolean(prefersColorSchemeSetting.get())); |
| }, {jslogContext: 'prefer-light-color-scheme'}); |
| menu.defaultSection().appendItem(darkColorSchemeOption, () => { |
| autoDarkModeSetting.set(false); |
| prefersColorSchemeSetting.set(isDarkColorScheme ? '' : 'dark'); |
| button.setToggled(Boolean(prefersColorSchemeSetting.get())); |
| }, {jslogContext: 'prefer-dark-color-scheme'}); |
| menu.defaultSection().appendItem(autoDarkModeOption, () => { |
| autoDarkModeSetting.set(!isAutoDarkEnabled); |
| button.setToggled(Boolean(prefersColorSchemeSetting.get())); |
| }, {jslogContext: 'emulate-auto-dark-mode'}); |
| |
| void menu.show(); |
| event.stopPropagation(); |
| }, {capture: true}); |
| |
| return button; |
| } |
| } |
| |
| export const enum Events { |
| INITIAL_UPDATE_COMPLETED = 'InitialUpdateCompleted', |
| STYLES_UPDATE_COMPLETED = 'StylesUpdateCompleted', |
| } |
| |
| export interface StylesUpdateCompletedEvent { |
| hasMatchedStyles: boolean; |
| } |
| |
| interface CompletionResult extends UI.SuggestBox.Suggestion { |
| isCSSVariableColor?: boolean; |
| } |
| |
| export interface EventTypes { |
| [Events.INITIAL_UPDATE_COMPLETED]: void; |
| [Events.STYLES_UPDATE_COMPLETED]: StylesUpdateCompletedEvent; |
| } |
| |
| const MAX_LINK_LENGTH = 23; |
| |
| export class SectionBlock { |
| readonly #titleElement: Element|null; |
| sections: StylePropertiesSection[]; |
| childBlocks: SectionBlock[] = []; |
| #expanded = false; |
| #icon: Icon|undefined; |
| constructor(titleElement: Element|null, expandable?: boolean, expandedByDefault?: boolean) { |
| this.#titleElement = titleElement; |
| this.sections = []; |
| this.#expanded = expandedByDefault ?? false; |
| |
| if (expandable && titleElement instanceof HTMLElement) { |
| this.#icon = createIcon(this.#expanded ? 'triangle-down' : 'triangle-right', 'section-block-expand-icon'); |
| titleElement.classList.toggle('empty-section', !this.#expanded); |
| UI.ARIAUtils.setExpanded(titleElement, this.#expanded); |
| titleElement.appendChild(this.#icon); |
| // Intercept focus to avoid highlight on click. |
| titleElement.tabIndex = -1; |
| titleElement.addEventListener('click', () => this.expand(!this.#expanded), false); |
| } |
| } |
| |
| expand(expand: boolean): void { |
| if (!this.#titleElement || !this.#icon) { |
| return; |
| } |
| this.#titleElement.classList.toggle('empty-section', !expand); |
| this.#icon.name = expand ? 'triangle-down' : 'triangle-right'; |
| UI.ARIAUtils.setExpanded(this.#titleElement, expand); |
| this.#expanded = expand; |
| this.sections.forEach(section => section.element.classList.toggle('hidden', !expand)); |
| } |
| |
| static createPseudoTypeBlock(pseudoType: Protocol.DOM.PseudoType, pseudoArgument: string|null): SectionBlock { |
| const separatorElement = document.createElement('div'); |
| separatorElement.className = 'sidebar-separator'; |
| separatorElement.setAttribute('jslog', `${VisualLogging.sectionHeader('pseudotype')}`); |
| const pseudoArgumentString = pseudoArgument ? `(${pseudoArgument})` : ''; |
| const pseudoTypeString = `${pseudoType}${pseudoArgumentString}`; |
| separatorElement.textContent = i18nString(UIStrings.pseudoSElement, {PH1: pseudoTypeString}); |
| return new SectionBlock(separatorElement); |
| } |
| |
| static async createInheritedPseudoTypeBlock( |
| pseudoType: Protocol.DOM.PseudoType, pseudoArgument: string|null, |
| node: SDK.DOMModel.DOMNode): Promise<SectionBlock> { |
| const separatorElement = document.createElement('div'); |
| separatorElement.className = 'sidebar-separator'; |
| separatorElement.setAttribute('jslog', `${VisualLogging.sectionHeader('inherited-pseudotype')}`); |
| const pseudoArgumentString = pseudoArgument ? `(${pseudoArgument})` : ''; |
| const pseudoTypeString = `${pseudoType}${pseudoArgumentString}`; |
| UI.UIUtils.createTextChild(separatorElement, i18nString(UIStrings.inheritedFromSPseudoOf, {PH1: pseudoTypeString})); |
| const link = PanelsCommon.DOMLinkifier.Linkifier.instance().linkify(node, { |
| preventKeyboardFocus: true, |
| tooltip: undefined, |
| }); |
| separatorElement.appendChild(link); |
| return new SectionBlock(separatorElement); |
| } |
| |
| static createRegisteredPropertiesBlock(expandedByDefault: boolean): SectionBlock { |
| const separatorElement = document.createElement('div'); |
| const block = new SectionBlock(separatorElement, true, expandedByDefault); |
| separatorElement.className = 'sidebar-separator'; |
| separatorElement.appendChild(document.createTextNode(REGISTERED_PROPERTY_SECTION_NAME)); |
| return block; |
| } |
| |
| static createFunctionBlock(expandedByDefault: boolean): SectionBlock { |
| const separatorElement = document.createElement('div'); |
| const block = new SectionBlock(separatorElement, true, expandedByDefault); |
| separatorElement.className = 'sidebar-separator'; |
| separatorElement.appendChild(document.createTextNode(FUNCTION_SECTION_NAME)); |
| return block; |
| } |
| |
| static createKeyframesBlock(keyframesName: string): SectionBlock { |
| const separatorElement = document.createElement('div'); |
| separatorElement.className = 'sidebar-separator'; |
| separatorElement.setAttribute('jslog', `${VisualLogging.sectionHeader('keyframes')}`); |
| separatorElement.textContent = `@keyframes ${keyframesName}`; |
| return new SectionBlock(separatorElement); |
| } |
| |
| static createAtRuleBlock(expandedByDefault: boolean): SectionBlock { |
| const separatorElement = document.createElement('div'); |
| const block = new SectionBlock(separatorElement, true, expandedByDefault); |
| separatorElement.className = 'sidebar-separator'; |
| separatorElement.appendChild(document.createTextNode(AT_RULE_SECTION_NAME)); |
| return block; |
| } |
| |
| static createPositionTryBlock(positionTryName: string): SectionBlock { |
| const separatorElement = document.createElement('div'); |
| separatorElement.className = 'sidebar-separator'; |
| separatorElement.setAttribute('jslog', `${VisualLogging.sectionHeader('position-try')}`); |
| separatorElement.textContent = `@position-try ${positionTryName}`; |
| return new SectionBlock(separatorElement); |
| } |
| |
| static async createInheritedNodeBlock(node: SDK.DOMModel.DOMNode): Promise<SectionBlock> { |
| const separatorElement = document.createElement('div'); |
| separatorElement.className = 'sidebar-separator'; |
| separatorElement.setAttribute('jslog', `${VisualLogging.sectionHeader('inherited')}`); |
| UI.UIUtils.createTextChild(separatorElement, i18nString(UIStrings.inheritedFroms)); |
| const link = PanelsCommon.DOMLinkifier.Linkifier.instance().linkify(node, { |
| preventKeyboardFocus: true, |
| tooltip: undefined, |
| }); |
| separatorElement.appendChild(link); |
| return new SectionBlock(separatorElement); |
| } |
| |
| static createLayerBlock(rule: SDK.CSSRule.CSSStyleRule): SectionBlock { |
| const separatorElement = document.createElement('div'); |
| separatorElement.className = 'sidebar-separator layer-separator'; |
| separatorElement.setAttribute('jslog', `${VisualLogging.sectionHeader('layer')}`); |
| UI.UIUtils.createTextChild(separatorElement.createChild('div'), i18nString(UIStrings.layer)); |
| const layers = rule.layers; |
| if (!layers.length && rule.origin === Protocol.CSS.StyleSheetOrigin.UserAgent) { |
| const name = rule.origin === Protocol.CSS.StyleSheetOrigin.UserAgent ? '\xa0user\xa0agent\xa0stylesheet' : |
| '\xa0implicit\xa0outer\xa0layer'; |
| UI.UIUtils.createTextChild(separatorElement.createChild('div'), name); |
| return new SectionBlock(separatorElement); |
| } |
| const layerLink = separatorElement.createChild('button'); |
| layerLink.className = 'link'; |
| layerLink.title = i18nString(UIStrings.clickToRevealLayer); |
| const name = layers.map(layer => SDK.CSSModel.CSSModel.readableLayerName(layer.text)).join('.'); |
| layerLink.textContent = name; |
| layerLink.onclick = () => LayersWidget.LayersWidget.instance().revealLayer(name); |
| return new SectionBlock(separatorElement); |
| } |
| |
| updateFilter(): number { |
| let numVisibleSections = 0; |
| for (const childBlock of this.childBlocks) { |
| numVisibleSections += childBlock.updateFilter(); |
| } |
| for (const section of this.sections) { |
| numVisibleSections += section.updateFilter() ? 1 : 0; |
| } |
| if (this.#titleElement) { |
| this.#titleElement.classList.toggle('hidden', numVisibleSections === 0); |
| } |
| return numVisibleSections; |
| } |
| |
| titleElement(): Element|null { |
| return this.#titleElement; |
| } |
| } |
| |
| export class IdleCallbackManager { |
| private discarded: boolean; |
| private readonly promises: Array<Promise<void>>; |
| private readonly queue: Array<{fn: () => void, resolve: () => void, reject: (err: unknown) => void}>; |
| constructor() { |
| this.discarded = false; |
| this.promises = []; |
| this.queue = []; |
| } |
| |
| discard(): void { |
| this.discarded = true; |
| } |
| |
| schedule(fn: () => void): void { |
| if (this.discarded) { |
| return; |
| } |
| const promise = new Promise<void>((resolve, reject) => { |
| this.queue.push({fn, resolve, reject}); |
| }); |
| this.promises.push(promise); |
| this.scheduleIdleCallback(/* timeout=*/ 100); |
| } |
| |
| protected scheduleIdleCallback(timeout: number): void { |
| window.requestIdleCallback(() => { |
| const next = this.queue.shift(); |
| assertNotNullOrUndefined(next); |
| |
| try { |
| if (!this.discarded) { |
| next.fn(); |
| } |
| next.resolve(); |
| } catch (err) { |
| next.reject(err); |
| } |
| }, {timeout}); |
| } |
| |
| awaitDone(): Promise<void[]> { |
| return Promise.all(this.promises); |
| } |
| } |
| |
| export function quoteFamilyName(familyName: string): string { |
| return `'${familyName.replaceAll('\'', '\\\'')}'`; |
| } |
| |
| export class CSSPropertyPrompt extends UI.TextPrompt.TextPrompt { |
| private readonly isColorAware: boolean; |
| private readonly cssCompletions: string[]; |
| private selectedNodeComputedStyles: Map<string, string>|null; |
| private parentNodeComputedStyles: Map<string, string>|null; |
| private treeElement: StylePropertyTreeElement; |
| private isEditingName: boolean; |
| private readonly cssVariables: string[]; |
| |
| constructor(treeElement: StylePropertyTreeElement, isEditingName: boolean, completions: string[] = []) { |
| // Use the same callback both for applyItemCallback and acceptItemCallback. |
| super(); |
| this.initialize(this.buildPropertyCompletions.bind(this), UI.UIUtils.StyleValueDelimiters); |
| const cssMetadata = SDK.CSSMetadata.cssMetadata(); |
| this.isColorAware = SDK.CSSMetadata.cssMetadata().isColorAwareProperty(treeElement.property.name); |
| this.cssCompletions = []; |
| const node = treeElement.node(); |
| if (isEditingName) { |
| this.cssCompletions = cssMetadata.allProperties(); |
| if (node && !node.isSVGNode()) { |
| this.cssCompletions = this.cssCompletions.filter(property => !cssMetadata.isSVGProperty(property)); |
| } |
| } else { |
| this.cssCompletions = [...completions, ...cssMetadata.getPropertyValues(treeElement.property.name)]; |
| if (node && cssMetadata.isFontFamilyProperty(treeElement.property.name)) { |
| const fontFamilies = node.domModel().cssModel().fontFaces().map(font => quoteFamilyName(font.getFontFamily())); |
| this.cssCompletions.unshift(...fontFamilies); |
| } |
| } |
| |
| /** |
| * Computed styles cache populated for flexbox features. |
| */ |
| this.selectedNodeComputedStyles = null; |
| /** |
| * Computed styles cache populated for flexbox features. |
| */ |
| this.parentNodeComputedStyles = null; |
| this.treeElement = treeElement; |
| this.isEditingName = isEditingName; |
| this.cssVariables = treeElement.matchedStyles().availableCSSVariables(treeElement.property.ownerStyle); |
| if (this.cssVariables.length < 1000) { |
| this.cssVariables.sort(Platform.StringUtilities.naturalOrderComparator); |
| } else { |
| this.cssVariables.sort(); |
| } |
| |
| if (!isEditingName) { |
| this.disableDefaultSuggestionForEmptyInput(); |
| |
| // If a CSS value is being edited that has a numeric or hex substring, hint that precision modifier shortcuts are available. |
| if (treeElement?.valueElement) { |
| const cssValueText = treeElement.valueElement.textContent; |
| const cmdOrCtrl = Host.Platform.isMac() ? 'Cmd' : 'Ctrl'; |
| const optionOrAlt = Host.Platform.isMac() ? 'Option' : 'Alt'; |
| if (cssValueText !== null) { |
| if (cssValueText.match(/#[\da-f]{3,6}$/i)) { |
| this.setTitle( |
| i18nString(UIStrings.incrementdecrementWithMousewheelOne, {PH1: cmdOrCtrl, PH2: optionOrAlt})); |
| } else if (cssValueText.match(/\d+/)) { |
| this.setTitle( |
| i18nString(UIStrings.incrementdecrementWithMousewheelHundred, {PH1: cmdOrCtrl, PH2: optionOrAlt})); |
| } |
| } |
| } |
| } |
| } |
| |
| override onKeyDown(event: Event): void { |
| const keyboardEvent = (event as KeyboardEvent); |
| switch (keyboardEvent.key) { |
| case 'ArrowUp': |
| case 'ArrowDown': |
| case 'PageUp': |
| case 'PageDown': |
| if (!this.isSuggestBoxVisible() && this.handleNameOrValueUpDown(keyboardEvent)) { |
| keyboardEvent.preventDefault(); |
| return; |
| } |
| break; |
| case 'Enter': |
| if (keyboardEvent.shiftKey) { |
| return; |
| } |
| // Accept any available autocompletions and advance to the next field. |
| this.tabKeyPressed(); |
| keyboardEvent.preventDefault(); |
| return; |
| case ' ': |
| if (this.isEditingName) { |
| // Since property names cannot contain a space |
| // we accept available autocompletions for property name when the user presses space. |
| this.tabKeyPressed(); |
| keyboardEvent.preventDefault(); |
| return; |
| } |
| } |
| |
| super.onKeyDown(keyboardEvent); |
| } |
| |
| override onMouseWheel(event: Event): void { |
| if (this.handleNameOrValueUpDown(event)) { |
| event.consume(true); |
| return; |
| } |
| super.onMouseWheel(event); |
| } |
| |
| override tabKeyPressed(): boolean { |
| this.acceptAutoComplete(); |
| |
| // Always tab to the next field. |
| return false; |
| } |
| |
| private handleNameOrValueUpDown(event: Event): boolean { |
| function finishHandler(this: CSSPropertyPrompt, _originalValue: string, _replacementString: string): void { |
| // Synthesize property text disregarding any comments, custom whitespace etc. |
| if (this.treeElement.nameElement && this.treeElement.valueElement) { |
| void this.treeElement.applyStyleText( |
| this.treeElement.nameElement.textContent + ': ' + this.treeElement.valueElement.textContent, false); |
| } |
| } |
| |
| function customNumberHandler(this: CSSPropertyPrompt, prefix: string, number: number, suffix: string): string { |
| if (number !== 0 && !suffix.length && |
| SDK.CSSMetadata.cssMetadata().isLengthProperty(this.treeElement.property.name) && |
| !this.treeElement.property.value.toLowerCase().startsWith('calc(')) { |
| suffix = 'px'; |
| } |
| return prefix + number + suffix; |
| } |
| |
| // Handle numeric value increment/decrement only at this point. |
| if (!this.isEditingName && this.treeElement.valueElement && |
| UI.UIUtils.handleElementValueModifications( |
| event, this.treeElement.valueElement, finishHandler.bind(this), this.isValueSuggestion.bind(this), |
| customNumberHandler.bind(this))) { |
| return true; |
| } |
| |
| return false; |
| } |
| |
| private isValueSuggestion(word: string): boolean { |
| if (!word) { |
| return false; |
| } |
| word = word.toLowerCase(); |
| return this.cssCompletions.indexOf(word) !== -1 || word.startsWith('--'); |
| } |
| |
| private async buildPropertyCompletions(expression: string, query: string, force?: boolean): |
| Promise<UI.SuggestBox.Suggestions> { |
| const lowerQuery = query.toLowerCase(); |
| const editingVariable = !this.isEditingName && expression.trim().endsWith('var('); |
| if (this.isEditingName && expression) { |
| const invalidCharsRegex = /["':;,\s()]/; |
| if (invalidCharsRegex.test(expression)) { |
| return await Promise.resolve([]); |
| } |
| } |
| |
| if (!query && !force && !editingVariable && (this.isEditingName || expression)) { |
| return await Promise.resolve([]); |
| } |
| |
| const prefixResults: CompletionResult[] = []; |
| const anywhereResults: CompletionResult[] = []; |
| if (!editingVariable) { |
| this.cssCompletions.forEach(completion => filterCompletions.call(this, completion, false /* variable */)); |
| // When and only when editing property names, we also include aliases for autocomplete. |
| if (this.isEditingName) { |
| SDK.CSSMetadata.cssMetadata().aliasesFor().forEach((canonicalProperty, alias) => { |
| const index = alias.toLowerCase().indexOf(lowerQuery); |
| if (index !== 0) { |
| return; |
| } |
| const aliasResult: CompletionResult = { |
| text: alias, |
| priority: SDK.CSSMetadata.cssMetadata().propertyUsageWeight(alias), |
| isCSSVariableColor: false, |
| }; |
| const canonicalPropertyResult: CompletionResult = { |
| text: canonicalProperty, |
| priority: SDK.CSSMetadata.cssMetadata().propertyUsageWeight(canonicalProperty), |
| subtitle: `= ${alias}`, // This explains why this canonicalProperty is prompted. |
| isCSSVariableColor: false, |
| }; |
| // We add aliasResult *before* the canonicalProperty one because we want to prompt |
| // the alias one first, since it corresponds to what the user has typed. |
| prefixResults.push(aliasResult, canonicalPropertyResult); |
| }); |
| } |
| } |
| const node = this.treeElement.node(); |
| if (this.isEditingName && node) { |
| const nameValuePresets = SDK.CSSMetadata.cssMetadata().nameValuePresets(node.isSVGNode()); |
| nameValuePresets.forEach( |
| preset => filterCompletions.call(this, preset, false /* variable */, true /* nameValue */)); |
| } |
| if (this.isEditingName || editingVariable) { |
| this.cssVariables.forEach(variable => filterCompletions.call(this, variable, true /* variable */)); |
| } |
| |
| const results = prefixResults.concat(anywhereResults); |
| if (!this.isEditingName && !results.length && query.length > 1 && '!important'.startsWith(lowerQuery)) { |
| results.push({ |
| text: '!important', |
| title: undefined, |
| subtitle: undefined, |
| priority: undefined, |
| isSecondary: undefined, |
| subtitleRenderer: undefined, |
| selectionRange: undefined, |
| hideGhostText: undefined, |
| iconElement: undefined, |
| }); |
| } |
| const userEnteredText = query.replace('-', ''); |
| if (userEnteredText && (userEnteredText === userEnteredText.toUpperCase())) { |
| for (let i = 0; i < results.length; ++i) { |
| if (!results[i].text.startsWith('--')) { |
| results[i].text = results[i].text.toUpperCase(); |
| } |
| } |
| } |
| |
| for (const result of results) { |
| if (editingVariable) { |
| result.title = result.text; |
| result.text += ')'; |
| continue; |
| } |
| const valuePreset = SDK.CSSMetadata.cssMetadata().getValuePreset(this.treeElement.name, result.text); |
| if (!this.isEditingName && valuePreset) { |
| result.title = result.text; |
| result.text = valuePreset.text; |
| result.selectionRange = {startColumn: valuePreset.startColumn, endColumn: valuePreset.endColumn}; |
| } |
| } |
| |
| const ensureComputedStyles = async(): Promise<void> => { |
| if (!node || this.selectedNodeComputedStyles) { |
| return; |
| } |
| this.selectedNodeComputedStyles = await node.domModel().cssModel().getComputedStyle(node.id); |
| const parentNode = node.parentNode; |
| if (parentNode) { |
| this.parentNodeComputedStyles = await parentNode.domModel().cssModel().getComputedStyle(parentNode.id); |
| } |
| }; |
| for (const result of results) { |
| await ensureComputedStyles(); |
| // Using parent node's computed styles does not work in all cases. For example: |
| // |
| // <div id="container" style="display: flex;"> |
| // <div id="useless" style="display: contents;"> |
| // <div id="item">item</div> |
| // </div> |
| // </div> |
| // TODO(crbug/1139945): Find a better way to get the flex container styles. |
| const iconInfo = ElementsComponents.CSSPropertyIconResolver.findIcon( |
| this.isEditingName ? result.text : `${this.treeElement.property.name}: ${result.text}`, |
| this.selectedNodeComputedStyles, this.parentNodeComputedStyles); |
| if (!iconInfo) { |
| continue; |
| } |
| const icon = new Icon(); |
| icon.name = iconInfo.iconName; |
| icon.classList.add('extra-small'); |
| icon.style.transform = `rotate(${iconInfo.rotate}deg) scale(${iconInfo.scaleX * 1.1}, ${iconInfo.scaleY * 1.1})`; |
| icon.style.maxHeight = 'var(--sys-size-6)'; |
| icon.style.maxWidth = 'var(--sys-size-6)'; |
| result.iconElement = icon; |
| } |
| |
| if (this.isColorAware && !this.isEditingName) { |
| results.sort((a, b) => { |
| if (a.isCSSVariableColor && b.isCSSVariableColor) { |
| return 0; |
| } |
| return a.isCSSVariableColor ? -1 : 1; |
| }); |
| } |
| return await Promise.resolve(results); |
| |
| function filterCompletions( |
| this: CSSPropertyPrompt, completion: string, variable: boolean, nameValue?: boolean): void { |
| const index = completion.toLowerCase().indexOf(lowerQuery); |
| const result: CompletionResult = { |
| text: completion, |
| title: undefined, |
| subtitle: undefined, |
| priority: undefined, |
| isSecondary: undefined, |
| subtitleRenderer: undefined, |
| selectionRange: undefined, |
| hideGhostText: undefined, |
| iconElement: undefined, |
| isCSSVariableColor: false, |
| }; |
| if (variable) { |
| const computedValue = |
| this.treeElement.matchedStyles().computeCSSVariable(this.treeElement.property.ownerStyle, completion); |
| if (computedValue) { |
| const color = Common.Color.parse(computedValue.value); |
| if (color) { |
| result.subtitleRenderer = colorSwatchRenderer.bind(null, color); |
| result.isCSSVariableColor = true; |
| } else { |
| result.subtitleRenderer = computedValueSubtitleRenderer.bind(null, computedValue.value); |
| } |
| } |
| } |
| if (nameValue) { |
| result.hideGhostText = true; |
| } |
| if (index === 0) { |
| result.priority = this.isEditingName ? SDK.CSSMetadata.cssMetadata().propertyUsageWeight(completion) : 1; |
| prefixResults.push(result); |
| } else if (index > -1) { |
| anywhereResults.push(result); |
| } |
| } |
| |
| function colorSwatchRenderer(color: Common.Color.Color): Element { |
| const swatch = new InlineEditor.ColorSwatch.ColorSwatch(); |
| swatch.color = color; |
| swatch.style.pointerEvents = 'none'; |
| return swatch; |
| } |
| function computedValueSubtitleRenderer(computedValue: string): Element { |
| const subtitleElement = document.createElement('span'); |
| subtitleElement.className = 'suggestion-subtitle'; |
| subtitleElement.textContent = `${computedValue}`; |
| subtitleElement.style.maxWidth = '100px'; |
| subtitleElement.title = `${computedValue}`; |
| return subtitleElement; |
| } |
| } |
| } |
| |
| export function unescapeCssString(input: string): string { |
| // https://drafts.csswg.org/css-syntax/#consume-escaped-code-point |
| const reCssEscapeSequence = /(?<!\\)\\(?:([a-fA-F0-9]{1,6})|(.))[\n\t\x20]?/gs; |
| return input.replace(reCssEscapeSequence, (_, $1, $2) => { |
| if ($2) { // Handle the single-character escape sequence. |
| return $2; |
| } |
| // Otherwise, handle the code point escape sequence. |
| const codePoint = parseInt($1, 16); |
| const isSurrogate = 0xD800 <= codePoint && codePoint <= 0xDFFF; |
| if (isSurrogate || codePoint === 0x0000 || codePoint > 0x10FFFF) { |
| return '\uFFFD'; |
| } |
| return String.fromCodePoint(codePoint); |
| }); |
| } |
| |
| export function escapeUrlAsCssComment(urlText: string): string { |
| const url = new URL(urlText); |
| if (url.search) { |
| return `${url.origin}${url.pathname}${url.search.replaceAll('*/', '*%2F')}${url.hash}`; |
| } |
| return url.toString(); |
| } |
| |
| export class ActionDelegate implements UI.ActionRegistration.ActionDelegate { |
| handleAction(_context: UI.Context.Context, actionId: string): boolean { |
| switch (actionId) { |
| case 'elements.new-style-rule': { |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.NewStyleRuleAdded); |
| void ElementsPanel.instance().stylesWidget.createNewRuleInViaInspectorStyleSheet(); |
| return true; |
| } |
| } |
| return false; |
| } |
| } |
| |
| let buttonProviderInstance: ButtonProvider; |
| export class ButtonProvider implements UI.Toolbar.Provider { |
| private readonly button: UI.Toolbar.ToolbarButton; |
| private constructor() { |
| this.button = UI.Toolbar.Toolbar.createActionButton('elements.new-style-rule'); |
| this.button.setLongClickable(true); |
| |
| new UI.UIUtils.LongClickController(this.button.element, this.longClicked.bind(this)); |
| UI.Context.Context.instance().addFlavorChangeListener(SDK.DOMModel.DOMNode, onNodeChanged.bind(this)); |
| onNodeChanged.call(this); |
| |
| function onNodeChanged(this: ButtonProvider): void { |
| let node = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode); |
| node = node ? node.enclosingElementOrSelf() : null; |
| this.button.setEnabled(Boolean(node)); |
| } |
| } |
| |
| static instance(opts: { |
| forceNew: boolean|null, |
| } = {forceNew: null}): ButtonProvider { |
| const {forceNew} = opts; |
| if (!buttonProviderInstance || forceNew) { |
| buttonProviderInstance = new ButtonProvider(); |
| } |
| |
| return buttonProviderInstance; |
| } |
| |
| private longClicked(event: Event): void { |
| ElementsPanel.instance().stylesWidget.onAddButtonLongClick(event); |
| } |
| |
| item(): UI.Toolbar.ToolbarItem { |
| return this.button; |
| } |
| } |