UNPKG

chrome-devtools-frontend

Version:
1,321 lines (1,173 loc) • 84 kB
// Copyright 2022 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 */ /* eslint-disable @devtools/no-lit-render-outside-of-view */ /* * 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 * as Root from '../../core/root/root.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as Protocol from '../../generated/protocol.js'; import * as Badges from '../../models/badges/badges.js'; import * as Bindings from '../../models/bindings/bindings.js'; import * as TextUtils from '../../models/text_utils/text_utils.js'; import * as Buttons from '../../ui/components/buttons/buttons.js'; import * as Tooltips from '../../ui/components/tooltips/tooltips.js'; import {createIcon, type Icon} from '../../ui/kit/kit.js'; import type * as Components from '../../ui/legacy/components/utils/utils.js'; import * as UI from '../../ui/legacy/legacy.js'; import {html, type LitTemplate, nothing, render} from '../../ui/lit/lit.js'; import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import * as PanelsCommon from '../common/common.js'; import {FontEditorSectionManager} from './ColorSwatchPopoverIcon.js'; import * as ElementsComponents from './components/components.js'; import {ElementsPanel} from './ElementsPanel.js'; import stylePropertiesTreeOutlineStyles from './stylePropertiesTreeOutline.css.js'; import {type Context, GhostStylePropertyTreeElement, StylePropertyTreeElement} from './StylePropertyTreeElement.js'; import type {StylesContainer} from './StylesContainer.js'; const UIStrings = { /** * @description Tooltip text that appears when hovering over the largeicon add button in the Styles Sidebar Pane of the Elements panel */ insertStyleRuleBelow: 'Insert style rule below', /** * @description Text in Styles Sidebar Pane of the Elements panel */ constructedStylesheet: 'constructed stylesheet', /** * @description Text in Styles Sidebar Pane of the Elements panel */ userAgentStylesheet: 'user agent stylesheet', /** * @description Text in Styles Sidebar Pane of the Elements panel */ injectedStylesheet: 'injected stylesheet', /** * @description Text in Styles Sidebar Pane of the Elements panel */ viaInspector: 'via inspector', /** * @description Text in Styles Sidebar Pane of the Elements panel */ styleAttribute: '`style` attribute', /** * @description Text in Styles Sidebar Pane of the Elements panel * @example {html} PH1 */ sattributesStyle: '{PH1}[Attributes Style]', /** * @description Show all button text content in Styles Sidebar Pane of the Elements panel * @example {3} PH1 */ showAllPropertiesSMore: 'Show all properties ({PH1} more)', /** * @description Text in Elements Tree Element of the Elements panel, copy should be used as a verb */ copySelector: 'Copy `selector`', /** * @description A context menu item in Styles panel to copy CSS rule */ copyRule: 'Copy rule', /** * @description A context menu item in Styles panel to copy all CSS declarations */ copyAllDeclarations: 'Copy all declarations', /** * @description Text that is announced by the screen reader when the user focuses on an input field for editing the name of a CSS selector in the Styles panel */ cssSelector: '`CSS` selector', /** * @description Text displayed in tooltip that shows specificity information. * @example {(0,0,1)} PH1 */ specificity: 'Specificity: {PH1}', /** * @description Accessibility label for the button that expands a collapsed CSS rule in the Styles pane. */ expandCollapsedRule: 'Expand collapsed rule', /** * @description Accessibility label for the button that collapses an expanded CSS rule in the Styles pane. */ collapseExpandedRule: 'Collapse expanded rule', } as const; const str_ = i18n.i18n.registerUIStrings('panels/elements/StylePropertiesSection.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const {widget} = UI.Widget; const STYLE_TAG = '<style>'; const DEFAULT_MAX_PROPERTIES = 50; export interface ActiveAiSuggestionProperty { name: string; value: string; } export interface ActiveAiSuggestion { text: string; properties: ActiveAiSuggestionProperty[]; cursorPosition: number; clearCachedRequest?: () => void; cssProperty: SDK.CSSProperty.CSSProperty; } export class StylePropertiesSection { protected stylesContainer: StylesContainer; styleInternal: SDK.CSSStyleDeclaration.CSSStyleDeclaration; readonly matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles; private computedStyles: Map<string, string>|null; private parentsComputedStyles: Map<string, string>|null; private computedStyleExtraFields: Protocol.CSS.ComputedStyleExtraFields|null; editable: boolean; private hoverTimer: number|null = null; private willCauseCancelEditing = false; private forceShowAll = false; private readonly originalPropertiesCount: number; element: HTMLDivElement; readonly #styleRuleElement: HTMLElement; private readonly titleElement: HTMLElement; propertiesTreeOutline: UI.TreeOutline.TreeOutlineInShadow = new UI.TreeOutline.TreeOutlineInShadow(); private showAllButton: Buttons.Button.Button; protected selectorElement: HTMLSpanElement; private readonly newStyleRuleToolbar: UI.Toolbar.Toolbar|undefined; private readonly fontEditorToolbar: UI.Toolbar.Toolbar|undefined; private readonly fontEditorSectionManager: FontEditorSectionManager|undefined; private readonly fontEditorButton: UI.Toolbar.ToolbarButton|undefined; private selectedSinceMouseDown: boolean; private readonly elementToSelectorIndex = new WeakMap<Element, number>(); navigable: boolean|null|undefined; protected readonly selectorRefElement: HTMLElement; private hoverableSelectorsMode: boolean; #isHidden: boolean; #isCollapsed: boolean; #collapseIcon: Icon|null = null; protected customPopulateCallback: () => void; nestingLevel = 0; #ancestorRuleListElement: HTMLElement; #ancestorClosingBracesElement: HTMLElement; // Used to identify buttons that trigger a flexbox or grid editor. nextEditorTriggerButtonIdx = 1; private sectionIdx = 0; #customHeaderText: string|undefined; readonly #specificityTooltips: HTMLSpanElement; static #nextSpecificityTooltipId = 0; static #nextSectionTooltipIdPrefix = 0; readonly sectionTooltipIdPrefix = StylePropertiesSection.#nextSectionTooltipIdPrefix++; private ghostStyleTreeElements: GhostStylePropertyTreeElement[] = []; #activeAiSuggestion?: ActiveAiSuggestion; constructor( stylesContainer: StylesContainer, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, style: SDK.CSSStyleDeclaration.CSSStyleDeclaration, sectionIdx: number, computedStyles: Map<string, string>|null, parentsComputedStyles: Map<string, string>|null, computedStyleExtraFields: Protocol.CSS.ComputedStyleExtraFields|null, customHeaderText?: string) { this.#customHeaderText = customHeaderText; this.stylesContainer = stylesContainer; this.sectionIdx = sectionIdx; this.styleInternal = style; this.matchedStyles = matchedStyles; this.computedStyles = computedStyles; this.parentsComputedStyles = parentsComputedStyles; this.computedStyleExtraFields = computedStyleExtraFields; this.editable = Boolean(style.styleSheetId && style.range); this.originalPropertiesCount = style.leadingProperties().length; this.customPopulateCallback = () => this.populateStyle(this.styleInternal, this.propertiesTreeOutline); const rule = style.parentRule; const headerText = this.headerText(); this.element = document.createElement('div'); this.element.classList.add('styles-section'); this.element.classList.add('matched-styles'); this.element.classList.add('monospace'); this.element.setAttribute('jslog', `${VisualLogging.section('style-properties').track({ keydown: 'ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Enter|Space', })}`); UI.ARIAUtils.setLabel(this.element, `${headerText}, css selector`); this.element.tabIndex = -1; UI.ARIAUtils.markAsListitem(this.element); this.element.addEventListener('keydown', this.onKeyDown.bind(this), false); stylesContainer.sectionByElement.set(this.element, this); this.#styleRuleElement = this.element.createChild('div', 'style-rule'); this.#ancestorRuleListElement = document.createElement('div'); this.#ancestorRuleListElement.classList.add('ancestor-rule-list'); this.element.prepend(this.#ancestorRuleListElement); this.#ancestorClosingBracesElement = document.createElement('div'); this.#ancestorClosingBracesElement.classList.add('ancestor-closing-braces'); this.element.append(this.#ancestorClosingBracesElement); this.updateAncestorRuleList(); this.titleElement = this.#styleRuleElement.createChild('div', 'styles-section-title ' + (rule ? 'styles-selector' : '')); this.propertiesTreeOutline.setFocusable(false); this.propertiesTreeOutline.registerRequiredCSS(stylePropertiesTreeOutlineStyles); this.propertiesTreeOutline.element.classList.add('style-properties', 'matched-styles', 'monospace'); this.#styleRuleElement.appendChild(this.propertiesTreeOutline.element); this.showAllButton = UI.UIUtils.createTextButton('', this.showAllItems.bind(this), { className: 'styles-show-all', jslogContext: 'elements.show-all-style-properties', }); this.#styleRuleElement.appendChild(this.showAllButton); const indent = Common.Settings.Settings.instance().moduleSetting('text-editor-indent').get(); const selectorContainer = document.createElement('div'); selectorContainer.createChild('span', 'styles-clipboard-only').textContent = indent.repeat(this.nestingLevel); selectorContainer.classList.add('selector-container'); // Collapse/expand toggle icon for rules that can be manually toggled. this.#collapseIcon = createIcon('triangle-right', 'section-collapse-icon'); UI.ARIAUtils.markAsButton(this.#collapseIcon); UI.ARIAUtils.setControls(this.#collapseIcon, this.propertiesTreeOutline.element); this.#collapseIcon.tabIndex = 0; this.#collapseIcon.addEventListener('click', (event: Event) => { event.consume(true); this.#toggleCollapsed(); }); this.#collapseIcon.addEventListener('keydown', (event: KeyboardEvent) => { if (!Platform.KeyboardUtilities.isEnterOrSpaceKey(event)) { return; } event.consume(true); this.#toggleCollapsed(); }); selectorContainer.appendChild(this.#collapseIcon); this.selectorElement = document.createElement('span'); UI.ARIAUtils.setLabel(this.selectorElement, i18nString(UIStrings.cssSelector)); this.selectorElement.classList.add('selector'); this.selectorElement.textContent = headerText; selectorContainer.appendChild(this.selectorElement); this.selectorElement.addEventListener('mouseenter', this.onMouseEnterSelector.bind(this), false); this.selectorElement.addEventListener('mouseleave', this.onMouseOutSelector.bind(this), false); this.#specificityTooltips = selectorContainer.createChild('span'); // We only add braces for style rules with selectors and non-style rules, which create their own sections. if (headerText.length > 0 || !(rule instanceof SDK.CSSRule.CSSStyleRule)) { const openBrace = selectorContainer.createChild('span', 'sidebar-pane-open-brace'); openBrace.textContent = headerText.length > 0 ? ' {' : '{'; // We don't add spacing when there is no selector. const closeBrace = this.#styleRuleElement.createChild('div', 'sidebar-pane-closing-brace'); closeBrace.createChild('span', 'styles-clipboard-only').textContent = indent.repeat(this.nestingLevel); closeBrace.createChild('span').textContent = '}'; } else { this.titleElement.classList.add('hidden'); } if (rule) { const newRuleButton = new UI.Toolbar.ToolbarButton( i18nString(UIStrings.insertStyleRuleBelow), 'plus', undefined, 'elements.new-style-rule'); newRuleButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, this.onNewRuleClick, this); newRuleButton.setSize(Buttons.Button.Size.MICRO); newRuleButton.element.tabIndex = -1; if (!this.newStyleRuleToolbar) { this.newStyleRuleToolbar = this.element.createChild('devtools-toolbar', 'sidebar-pane-section-toolbar new-rule-toolbar'); } this.newStyleRuleToolbar.appendToolbarItem(newRuleButton); UI.ARIAUtils.setHidden(this.newStyleRuleToolbar, true); } if (Root.Runtime.experiments.isEnabled(Root.ExperimentNames.ExperimentName.FONT_EDITOR) && this.editable) { this.fontEditorToolbar = this.#styleRuleElement.createChild('devtools-toolbar', 'sidebar-pane-section-toolbar'); this.fontEditorSectionManager = new FontEditorSectionManager(this.stylesContainer.swatchPopoverHelper(), this); this.fontEditorButton = new UI.Toolbar.ToolbarButton('Font Editor', 'custom-typography', undefined, 'font-editor'); this.fontEditorButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => { this.onFontEditorButtonClicked(); }, this); this.fontEditorButton.element.addEventListener('keydown', event => { if (Platform.KeyboardUtilities.isEnterOrSpaceKey(event)) { event.consume(true); this.onFontEditorButtonClicked(); } }, false); this.fontEditorToolbar.appendToolbarItem(this.fontEditorButton); if (this.styleInternal.type === SDK.CSSStyleDeclaration.Type.Inline) { if (this.newStyleRuleToolbar) { this.newStyleRuleToolbar.classList.add('shifted-toolbar'); } } else { this.fontEditorToolbar.classList.add('font-toolbar-hidden'); } } this.selectorElement.addEventListener('click', this.handleSelectorClick.bind(this), false); this.selectorElement.setAttribute( 'jslog', `${VisualLogging.cssRuleHeader('selector').track({click: true, change: true})}`); this.element.addEventListener('contextmenu', this.handleContextMenuEvent.bind(this), false); this.element.addEventListener('mousedown', this.handleEmptySpaceMouseDown.bind(this), false); this.element.addEventListener('click', this.handleEmptySpaceClick.bind(this), false); this.element.addEventListener('mousemove', this.onMouseMove.bind(this), false); this.element.addEventListener('mouseleave', this.onMouseLeave.bind(this), false); this.selectedSinceMouseDown = false; if (rule) { // Prevent editing the user agent and user rules. if (rule.isUserAgent() || rule.isInjected()) { this.editable = false; // Check this is a real CSSRule, not a bogus object coming from BlankStylePropertiesSection. } else if (rule.header) { this.navigable = !rule.header.isAnonymousInlineStyleSheet(); } } this.selectorRefElement = document.createElement('div'); this.selectorRefElement.classList.add('styles-section-subtitle'); this.element.prepend(this.selectorRefElement); this.updateRuleOrigin(); this.titleElement.appendChild(selectorContainer); if (this.navigable) { this.element.classList.add('navigable'); } if (!this.editable) { this.element.classList.add('read-only'); this.propertiesTreeOutline.element.classList.add('read-only'); } this.hoverableSelectorsMode = false; this.#isHidden = false; this.#isCollapsed = false; this.markSelectorMatches(); this.onpopulate(); this.#updateCollapsedState(); } setComputedStyles(computedStyles: Map<string, string>|null): void { this.computedStyles = computedStyles; } setParentsComputedStyles(parentsComputedStyles: Map<string, string>|null): void { this.parentsComputedStyles = parentsComputedStyles; } setComputedStyleExtraFields(computedStyleExtraFields: Protocol.CSS.ComputedStyleExtraFields|null): void { this.computedStyleExtraFields = computedStyleExtraFields; } updateAuthoringHint(): void { let child = this.propertiesTreeOutline.firstChild(); while (child) { if (child instanceof StylePropertyTreeElement) { child.setComputedStyles(this.computedStyles); child.setParentsComputedStyles(this.parentsComputedStyles); child.updateAuthoringHint(); } child = child.nextSibling; } } setSectionIdx(sectionIdx: number): void { this.sectionIdx = sectionIdx; this.onpopulate(); } getSectionIdx(): number { return this.sectionIdx; } registerFontProperty(treeElement: StylePropertyTreeElement): void { if (this.fontEditorSectionManager) { this.fontEditorSectionManager.registerFontProperty(treeElement); } if (this.fontEditorToolbar) { this.fontEditorToolbar.classList.remove('font-toolbar-hidden'); if (this.newStyleRuleToolbar) { this.newStyleRuleToolbar.classList.add('shifted-toolbar'); } } } resetToolbars(): void { if (this.stylesContainer.swatchPopoverHelper().isShowing() || this.styleInternal.type === SDK.CSSStyleDeclaration.Type.Inline) { return; } if (this.fontEditorToolbar) { this.fontEditorToolbar.classList.add('font-toolbar-hidden'); } if (this.newStyleRuleToolbar) { this.newStyleRuleToolbar.classList.remove('shifted-toolbar'); } } static createRuleOriginNode( matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, linkifier: Components.Linkifier.Linkifier, rule: SDK.CSSRule.CSSRule|null): LitTemplate { if (!rule) { return nothing; } const ruleLocation = StylePropertiesSection.getRuleLocationFromCSSRule(rule); const header = rule.header; function linkifyRuleLocation(): Node|null { if (!rule) { return null; } if (ruleLocation && header && (!header.isAnonymousInlineStyleSheet() || matchedStyles.cssModel().sourceMapManager().sourceMapForClient(header))) { return StylePropertiesSection.linkifyRuleLocation( matchedStyles.cssModel(), linkifier, rule.header, ruleLocation); } return null; } function linkifyNode(label: string): LitTemplate|null { if (header?.ownerNode) { return html`<devtools-widget ${ widget(e => new PanelsCommon.DOMLinkifier.DeferredDOMNodeLink(e, header.ownerNode))}> ${label} </devtools-widget>`; } if (rule && rule.style.styleSheetId && rule.treeScope) { // Make link for adopted stylesheet. const ownerNode = new SDK.DOMModel.DeferredDOMNode(rule.cssModelInternal.target(), rule.treeScope); // clang-format off return html`<devtools-widget ${widget( e => new PanelsCommon.DOMLinkifier.DeferredDOMNodeLink(e, ownerNode, undefined, rule.style.styleSheetId))}> ${label} </devtools-widget>`; // clang-format on } return null; } if (header?.isMutable && !header.isViaInspector()) { const location = header.isConstructedByNew() && !header.sourceMapURL ? null : linkifyRuleLocation(); if (location) { return html`${location}`; } const label = header.isConstructedByNew() ? i18nString(UIStrings.constructedStylesheet) : STYLE_TAG; const node = linkifyNode(label); if (node) { return node; } return html`${label}`; } const location = linkifyRuleLocation(); if (location) { return html`${location}`; } if (rule.isUserAgent()) { return html`${i18nString(UIStrings.userAgentStylesheet)}`; } if (rule.isInjected()) { return html`${i18nString(UIStrings.injectedStylesheet)}`; } if (rule.isViaInspector()) { return html`${i18nString(UIStrings.viaInspector)}`; } const node = linkifyNode(STYLE_TAG); if (node) { return node; } return nothing; } protected createRuleOriginNode( matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, linkifier: Components.Linkifier.Linkifier, rule: SDK.CSSRule.CSSRule|null): LitTemplate { return StylePropertiesSection.createRuleOriginNode(matchedStyles, linkifier, rule); } private static getRuleLocationFromCSSRule(rule: SDK.CSSRule.CSSRule): TextUtils.TextRange.TextRange|null|undefined { let ruleLocation; if (rule instanceof SDK.CSSRule.CSSStyleRule) { ruleLocation = rule.style.range; } else if (rule instanceof SDK.CSSRule.CSSKeyframeRule) { ruleLocation = rule.key().range; } return ruleLocation; } static tryNavigateToRuleLocation( matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, rule: SDK.CSSRule.CSSRule|null): void { if (!rule) { return; } const ruleLocation = this.getRuleLocationFromCSSRule(rule); const header = rule.header; if (ruleLocation && header && !header.isAnonymousInlineStyleSheet()) { const matchingSelectorLocation = this.getCSSSelectorLocation(matchedStyles.cssModel(), rule.header, ruleLocation); this.revealSelectorSource(matchingSelectorLocation, true); } } protected static linkifyRuleLocation( cssModel: SDK.CSSModel.CSSModel, linkifier: Components.Linkifier.Linkifier, styleSheetHeader: SDK.CSSStyleSheetHeader.CSSStyleSheetHeader, ruleLocation: TextUtils.TextRange.TextRange): Node { const matchingSelectorLocation = this.getCSSSelectorLocation(cssModel, styleSheetHeader, ruleLocation); return linkifier.linkifyCSSLocation(matchingSelectorLocation); } private static getCSSSelectorLocation( cssModel: SDK.CSSModel.CSSModel, styleSheetHeader: SDK.CSSStyleSheetHeader.CSSStyleSheetHeader, ruleLocation: TextUtils.TextRange.TextRange): SDK.CSSModel.CSSLocation { const lineNumber = styleSheetHeader.lineNumberInSource(ruleLocation.startLine); const columnNumber = styleSheetHeader.columnNumberInSource(ruleLocation.startLine, ruleLocation.startColumn); return new SDK.CSSModel.CSSLocation(styleSheetHeader, lineNumber, columnNumber); } private getFocused(): HTMLElement|null { return (this.propertiesTreeOutline.shadowRoot.activeElement as HTMLElement) || null; } private focusNext(element: HTMLElement): void { // Clear remembered focused item (if any). const focused = this.getFocused(); if (focused) { focused.tabIndex = -1; } // Focus the next item and remember it (if in our subtree). element.focus(); if (this.propertiesTreeOutline.shadowRoot.contains(element)) { element.tabIndex = 0; } } private ruleNavigation(keyboardEvent: KeyboardEvent): void { if (keyboardEvent.altKey || keyboardEvent.ctrlKey || keyboardEvent.metaKey || keyboardEvent.shiftKey) { return; } const focused = this.getFocused(); let focusNext: HTMLElement|null = null; const focusable = Array.from((this.propertiesTreeOutline.shadowRoot.querySelectorAll('[tabindex]') as NodeListOf<HTMLElement>)) .filter(e => e.checkVisibility()); if (focusable.length === 0) { return; } const focusedIndex = focused ? focusable.indexOf(focused) : -1; if (keyboardEvent.key === 'ArrowLeft') { focusNext = focusable[focusedIndex - 1] || this.element; } else if (keyboardEvent.key === 'ArrowRight') { focusNext = focusable[focusedIndex + 1] || this.element; } else if (keyboardEvent.key === 'ArrowUp' || keyboardEvent.key === 'ArrowDown') { this.focusNext(this.element); return; } if (focusNext) { this.focusNext(focusNext); keyboardEvent.consume(true); } } private onKeyDown(event: KeyboardEvent): void { const keyboardEvent = event; if (UI.UIUtils.isEditing() || !this.editable || keyboardEvent.altKey || keyboardEvent.ctrlKey || keyboardEvent.metaKey) { return; } // Only handle section-level edit shortcuts when the event originated from // the section itself. Child editors dispatch key events that bubble up to // the section, and we must not treat those as section shortcuts. const isSectionLevelShortcut = keyboardEvent.target === this.element; switch (keyboardEvent.key) { case 'Enter': case ' ': if (!isSectionLevelShortcut) { return; } this.startEditingAtFirstPosition(); keyboardEvent.consume(true); break; case 'ArrowLeft': case 'ArrowRight': case 'ArrowUp': case 'ArrowDown': this.ruleNavigation(keyboardEvent); break; default: // Filter out non-printable key strokes. if (isSectionLevelShortcut && keyboardEvent.key.length === 1) { this.addNewBlankProperty(0).startEditingName(); } break; } } private setSectionHovered(isHovered: boolean): void { this.element.classList.toggle('styles-panel-hovered', isHovered); this.propertiesTreeOutline.element.classList.toggle('styles-panel-hovered', isHovered); if (this.hoverableSelectorsMode !== isHovered) { this.hoverableSelectorsMode = isHovered; this.markSelectorMatches(); } } private onMouseLeave(_event: Event): void { this.setSectionHovered(false); this.stylesContainer.setActiveProperty(null); } private onMouseMove(event: MouseEvent): void { const hasCtrlOrMeta = UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event); this.setSectionHovered(hasCtrlOrMeta); const treeElement = this.propertiesTreeOutline.treeElementFromEvent(event); if (treeElement instanceof StylePropertyTreeElement) { this.stylesContainer.setActiveProperty((treeElement)); } else { this.stylesContainer.setActiveProperty(null); } const selection = this.element.getComponentSelection(); if (!this.selectedSinceMouseDown && selection?.toString()) { this.selectedSinceMouseDown = true; } } private onFontEditorButtonClicked(): void { if (this.fontEditorSectionManager && this.fontEditorButton) { void this.fontEditorSectionManager.showPopover(this.fontEditorButton.element, this.stylesContainer); } } style(): SDK.CSSStyleDeclaration.CSSStyleDeclaration { return this.styleInternal; } headerText(): string { if (this.#customHeaderText) { return this.#customHeaderText; } const node = this.matchedStyles.nodeForStyle(this.styleInternal); if (this.styleInternal.type === SDK.CSSStyleDeclaration.Type.Inline) { return this.matchedStyles.isInherited(this.styleInternal) ? i18nString(UIStrings.styleAttribute) : 'element.style'; } if (this.styleInternal.type === SDK.CSSStyleDeclaration.Type.Transition) { return 'transitions style'; } if (this.styleInternal.type === SDK.CSSStyleDeclaration.Type.Animation) { return this.styleInternal.animationName() ? `${this.styleInternal.animationName()} animation` : 'animation style'; } if (node && this.styleInternal.type === SDK.CSSStyleDeclaration.Type.Attributes) { return i18nString(UIStrings.sattributesStyle, {PH1: node.nodeNameInCorrectCase()}); } if (this.styleInternal.parentRule instanceof SDK.CSSRule.CSSStyleRule) { return this.styleInternal.parentRule.selectorText(); } if (this.styleInternal.parentRule instanceof SDK.CSSRule.CSSAtRule) { if (this.styleInternal.parentRule.subsection()) { return '@' + this.styleInternal.parentRule.subsection(); } const atRule = '@' + this.styleInternal.parentRule.type(); const name = this.styleInternal.parentRule.name(); if (name) { return atRule + ' ' + name.text; } return atRule; } return ''; } private onMouseOutSelector(): void { if (this.hoverTimer) { clearTimeout(this.hoverTimer); } SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(); } private onMouseEnterSelector(): void { if (this.hoverTimer) { clearTimeout(this.hoverTimer); } this.hoverTimer = window.setTimeout(this.highlight.bind(this), 300); } highlight(mode: string|undefined = 'all'): void { SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(); const node = this.stylesContainer.node(); if (!node) { return; } const selectorList = this.styleInternal.parentRule && this.styleInternal.parentRule instanceof SDK.CSSRule.CSSStyleRule ? this.styleInternal.parentRule.selectorText() : undefined; node.domModel().overlayModel().highlightInOverlay({node, selectorList}, mode); } firstSibling(): StylePropertiesSection|null { const parent = this.element.parentElement; if (!parent) { return null; } let childElement: (ChildNode|null) = parent.firstChild; while (childElement) { const childSection = this.stylesContainer.sectionByElement.get(childElement); if (childSection) { return childSection; } childElement = childElement.nextSibling; } return null; } findCurrentOrNextVisible(willIterateForward: boolean, originalSection?: StylePropertiesSection): StylePropertiesSection|null { if (!this.isHidden()) { return this; } if (this === originalSection) { return null; } if (!originalSection) { originalSection = this; } let visibleSibling: (StylePropertiesSection|null)|null = null; const nextSibling = willIterateForward ? this.nextSibling() : this.previousSibling(); if (nextSibling) { visibleSibling = nextSibling.findCurrentOrNextVisible(willIterateForward, originalSection); } else { const loopSibling = willIterateForward ? this.firstSibling() : this.lastSibling(); if (loopSibling) { visibleSibling = loopSibling.findCurrentOrNextVisible(willIterateForward, originalSection); } } return visibleSibling; } lastSibling(): StylePropertiesSection|null { const parent = this.element.parentElement; if (!parent) { return null; } let childElement: (ChildNode|null) = parent.lastChild; while (childElement) { const childSection = this.stylesContainer.sectionByElement.get(childElement); if (childSection) { return childSection; } childElement = childElement.previousSibling; } return null; } nextSibling(): StylePropertiesSection|undefined { let curElement: (ChildNode|null)|HTMLDivElement = this.element; do { curElement = curElement.nextSibling; } while (curElement && !this.stylesContainer.sectionByElement.has(curElement)); if (curElement) { return this.stylesContainer.sectionByElement.get(curElement); } return; } previousSibling(): StylePropertiesSection|undefined { let curElement: (ChildNode|null)|HTMLDivElement = this.element; do { curElement = curElement.previousSibling; } while (curElement && !this.stylesContainer.sectionByElement.has(curElement)); if (curElement) { return this.stylesContainer.sectionByElement.get(curElement); } return; } set activeAiSuggestion(activeAiSuggestion: ActiveAiSuggestion|undefined) { this.#clearActiveAiSuggestion(); this.#activeAiSuggestion = activeAiSuggestion; if (this.#activeAiSuggestion) { this.#renderActiveAiSuggestion(); } } get activeAiSuggestion(): ActiveAiSuggestion|undefined { return this.#activeAiSuggestion; } async commitActiveAiSuggestion(): Promise<void> { if (!this.#activeAiSuggestion) { return; } const sourceTreeElement = this.#getAiSuggestionSourceTreeElement(); // Apply everything atomically await sourceTreeElement?.commitAiSuggestion(this.#activeAiSuggestion.text); } #clearActiveAiSuggestion(): void { this.#getAiSuggestionSourceTreeElement()?.clearActiveAiSuggestion(); // Clear existing ghost elements for (const ghost of this.ghostStyleTreeElements) { this.propertiesTreeOutline.removeChild(ghost); } this.ghostStyleTreeElements = []; } #renderActiveAiSuggestion(): void { if (!this.#activeAiSuggestion) { return; } const sourceTreeElement = this.#getAiSuggestionSourceTreeElement(); if (!sourceTreeElement || !this.#activeAiSuggestion.properties.length) { return; } sourceTreeElement.renderActiveAiSuggestion(this.#activeAiSuggestion.properties[0]); if (this.#activeAiSuggestion.properties.length <= 1) { return; } const index = this.propertiesTreeOutline.rootElement().children().indexOf(sourceTreeElement); const properties = this.#activeAiSuggestion.properties.slice(1); for (let i = 0; i < properties.length; i++) { const property = properties[i]; // Create a fake property attached to this style const fakeProperty = new SDK.CSSProperty.CSSProperty( this.styleInternal, this.styleInternal.allProperties().length, property.name, property.value, false, false, true, false, ); const ghost = new GhostStylePropertyTreeElement(this.stylesContainer, this, this.matchedStyles, fakeProperty); this.propertiesTreeOutline.insertChild(ghost, index + i + 1); this.ghostStyleTreeElements.push(ghost); } } #getAiSuggestionSourceTreeElement(): StylePropertyTreeElement|undefined { if (!this.#activeAiSuggestion) { return; } const sourceTreeElement = this.closestPropertyForEditing(this.#activeAiSuggestion.cssProperty.index); if (!(sourceTreeElement instanceof StylePropertyTreeElement) || sourceTreeElement.property !== this.#activeAiSuggestion.cssProperty) { return; } return sourceTreeElement; } private onNewRuleClick(event: Common.EventTarget.EventTargetEvent<Event>): void { event.data.consume(); const rule = this.styleInternal.parentRule; if (!rule?.style.range || !rule.header) { return; } const range = TextUtils.TextRange.TextRange.createFromLocation(rule.style.range.endLine, rule.style.range.endColumn + 1); this.stylesContainer.addBlankSection(this, rule.header, range); } styleSheetEdited(edit: SDK.CSSModel.Edit): void { const rule = this.styleInternal.parentRule; if (rule) { rule.rebase(edit); } else { this.styleInternal.rebase(edit); } this.updateAncestorRuleList(); this.updateRuleOrigin(); } protected createAncestorRules(rule: SDK.CSSRule.CSSStyleRule): void { let mediaIndex = 0; let containerIndex = 0; let scopeIndex = 0; let supportsIndex = 0; let nestingIndex = 0; let navigationsIndex = 0; this.nestingLevel = 0; for (const ruleType of rule.ruleTypes) { let ancestorRuleElement; switch (ruleType) { case Protocol.CSS.CSSRuleType.MediaRule: ancestorRuleElement = this.createMediaElement(rule.media[mediaIndex++]); break; case Protocol.CSS.CSSRuleType.ContainerRule: ancestorRuleElement = this.createContainerQueryElement(rule.containerQueries[containerIndex++]); break; case Protocol.CSS.CSSRuleType.ScopeRule: ancestorRuleElement = this.createScopeElement(rule.scopes[scopeIndex++]); break; case Protocol.CSS.CSSRuleType.SupportsRule: ancestorRuleElement = this.createSupportsElement(rule.supports[supportsIndex++]); break; case Protocol.CSS.CSSRuleType.StyleRule: ancestorRuleElement = this.createNestingElement(rule.nestingSelectors?.[nestingIndex++]); break; case Protocol.CSS.CSSRuleType.StartingStyleRule: ancestorRuleElement = this.createStartingStyleElement(); break; case Protocol.CSS.CSSRuleType.NavigationRule: ancestorRuleElement = this.createNavigationElement(rule.navigations[navigationsIndex++]); break; } if (ancestorRuleElement) { this.#ancestorRuleListElement.prepend(ancestorRuleElement); this.#ancestorClosingBracesElement.prepend(this.indentElement(this.createClosingBrace(), this.nestingLevel)); this.nestingLevel++; } } if (this.headerText().length === 0) { // We reduce one level since no selector means one less pair of braces are added for declarations. this.nestingLevel--; } } protected createAtRuleAncestor(rule: SDK.CSSRule.CSSAtRule): void { if (rule.subsection()) { const atRuleElement = new ElementsComponents.CSSQuery.CSSQuery(); atRuleElement.data = { queryPrefix: '@' + rule.type(), queryText: rule.name()?.text ?? '', jslogContext: 'at-rule-' + rule.type(), }; this.#ancestorRuleListElement.prepend(atRuleElement); this.#ancestorClosingBracesElement.prepend(this.indentElement(this.createClosingBrace(), 0)); this.nestingLevel = 1; } } protected maybeCreateAncestorRules(style: SDK.CSSStyleDeclaration.CSSStyleDeclaration): void { if (style.parentRule) { if (style.parentRule instanceof SDK.CSSRule.CSSStyleRule) { this.createAncestorRules(style.parentRule); } else if (style.parentRule instanceof SDK.CSSRule.CSSAtRule) { this.createAtRuleAncestor(style.parentRule); } let curNestingLevel = 0; for (const element of this.#ancestorRuleListElement.children) { this.indentElement(element as HTMLElement, curNestingLevel); curNestingLevel++; } } } protected createClosingBrace(): HTMLElement { const closingBrace = document.createElement('div'); closingBrace.append('}'); return closingBrace; } protected indentElement(element: HTMLElement, nestingLevel: number, clipboardOnly?: boolean): HTMLElement { const indent = Common.Settings.Settings.instance().moduleSetting('text-editor-indent').get(); const indentElement = document.createElement('span'); indentElement.classList.add('styles-clipboard-only'); indentElement.setAttribute('slot', 'indent'); indentElement.textContent = indent.repeat(nestingLevel); element.prepend(indentElement); if (!clipboardOnly) { element.style.paddingLeft = `${nestingLevel}ch`; } return element; } protected createMediaElement(media: SDK.CSSMedia.CSSMedia): ElementsComponents.CSSQuery.CSSQuery|undefined { // Don't display trivial non-print media types. const isMedia = !media.text || !media.text.includes('(') && media.text !== 'print'; if (isMedia) { return; } let queryPrefix = ''; let queryText = ''; let onQueryTextClick; switch (media.source) { case SDK.CSSMedia.Source.LINKED_SHEET: case SDK.CSSMedia.Source.INLINE_SHEET: { queryText = `media="${media.text}"`; break; } case SDK.CSSMedia.Source.MEDIA_RULE: { queryPrefix = '@media'; queryText = media.text; if (media.styleSheetId) { onQueryTextClick = this.handleQueryRuleClick.bind(this, media); } break; } case SDK.CSSMedia.Source.IMPORT_RULE: { queryText = `@import ${media.text}`; break; } } const mediaQueryElement = new ElementsComponents.CSSQuery.CSSQuery(); mediaQueryElement.data = { queryPrefix, queryText, onQueryTextClick, jslogContext: 'media-query', }; return mediaQueryElement; } protected createContainerQueryElement(containerQuery: SDK.CSSContainerQuery.CSSContainerQuery): ElementsComponents.CSSQuery.CSSQuery|undefined { let onQueryTextClick; if (containerQuery.styleSheetId) { onQueryTextClick = this.handleQueryRuleClick.bind(this, containerQuery); } const containerQueryElement = new ElementsComponents.CSSQuery.CSSQuery(); containerQueryElement.data = { queryPrefix: '@container', queryName: containerQuery.name, queryText: containerQuery.text, onQueryTextClick, jslogContext: 'container-query', }; if (!/^style\(.*\)/.test(containerQuery.text)) { // We only add container element for non-style queries. void this.addContainerForContainerQuery(containerQuery); } return containerQueryElement; } protected createScopeElement(scope: SDK.CSSScope.CSSScope): ElementsComponents.CSSQuery.CSSQuery|undefined { let onQueryTextClick; if (scope.styleSheetId) { onQueryTextClick = this.handleQueryRuleClick.bind(this, scope); } const scopeElement = new ElementsComponents.CSSQuery.CSSQuery(); scopeElement.data = { queryPrefix: '@scope', queryText: scope.text, onQueryTextClick, jslogContext: 'scope', }; return scopeElement; } protected createStartingStyleElement(/* startingStyle: SDK.CSSStartingStyle.CSSStartingStyle*/): ElementsComponents.CSSQuery.CSSQuery|undefined { const startingStyleElement = new ElementsComponents.CSSQuery.CSSQuery(); startingStyleElement.data = { queryPrefix: '@starting-style', queryText: '', jslogContext: 'starting-style', }; return startingStyleElement; } protected createSupportsElement(supports: SDK.CSSSupports.CSSSupports): ElementsComponents.CSSQuery.CSSQuery |undefined { if (!supports.text) { return; } let onQueryTextClick; if (supports.styleSheetId) { onQueryTextClick = this.handleQueryRuleClick.bind(this, supports); } const supportsElement = new ElementsComponents.CSSQuery.CSSQuery(); supportsElement.data = { queryPrefix: '@supports', queryText: supports.text, onQueryTextClick, jslogContext: 'supports', }; return supportsElement; } protected createNavigationElement(navigation: SDK.CSSNavigation.CSSNavigation): ElementsComponents.CSSQuery.CSSQuery |undefined { if (!navigation.text) { return; } let onQueryTextClick; if (navigation.styleSheetId) { onQueryTextClick = this.handleQueryRuleClick.bind(this, navigation); } const navigationElement = new ElementsComponents.CSSQuery.CSSQuery(); navigationElement.data = { queryPrefix: '@navigation', queryText: navigation.text, onQueryTextClick, jslogContext: 'navigation', }; return navigationElement; } protected createNestingElement(nestingSelector?: string): HTMLElement|undefined { if (!nestingSelector) { return; } const nestingElement = document.createElement('div'); nestingElement.textContent = `${nestingSelector} {`; return nestingElement; } private async addContainerForContainerQuery(containerQuery: SDK.CSSContainerQuery.CSSContainerQuery): Promise<void> { const container = await containerQuery.getContainerForNode(this.matchedStyles.node().id); if (!container) { return; } const containerElement = new ElementsComponents.QueryContainer.QueryContainer(); containerElement.data = { container: ElementsComponents.Helper.legacyNodeToElementsComponentsNode(container.containerNode), queryName: containerQuery.name, onContainerLinkClick: event => { event.preventDefault(); void ElementsPanel.instance().revealAndSelectNode( container.containerNode, {showPanel: true, focusNode: true, highlightInOverlay: false}); void container.containerNode.scrollIntoView(); }, }; containerElement.addEventListener('queriedsizerequested', async () => { const details = await container.getContainerSizeDetails(); if (details) { containerElement.updateContainerQueriedSizeDetails(details); } }); this.#ancestorRuleListElement.prepend(containerElement); } private updateAncestorRuleList(): void { this.#ancestorRuleListElement.removeChildren(); this.#ancestorClosingBracesElement.removeChildren(); this.maybeCreateAncestorRules(this.styleInternal); this.#styleRuleElement.style.paddingLeft = `${this.nestingLevel}ch`; } isPropertyInherited(propertyName: string): boolean { if (this.matchedStyles.isInherited(this.styleInternal)) { // While rendering inherited stylesheet, reverse meaning of this property. // Render truly inherited properties with black, i.e. return them as non-inherited. return !SDK.CSSMetadata.cssMetadata().isPropertyInherited(propertyName); } return false; } nextEditableSibling(): StylePropertiesSection|null { let curSection: (StylePropertiesSection|undefined)|(StylePropertiesSection | null)|this = this; do { curSection = curSection.nextSibling(); } while (curSection && !curSection.editable); if (!curSection) { curSection = this.firstSibling(); while (curSection && !curSection.editable) { curSection = curSection.nextSibling(); } } return (curSection?.editable) ? curSection : null; } previousEditableSibling(): StylePropertiesSection|null { let curSection: (StylePropertiesSection|undefined)|(StylePropertiesSection | null)|this = this; do { curSection = curSection.previousSibling(); } while (curSection && !curSection.editable); if (!curSection) { curSection = this.lastSibling(); while (curSection && !curSection.editable) { curSection = curSection.previousSibling(); } } return (curSection?.editable) ? curSection : null; } refreshUpdate(editedTreeElement: StylePropertyTreeElement): void { this.stylesContainer.refreshUpdate(this, editedTreeElement); } updateVarFunctions(editedTreeElement: StylePropertyTreeElement): void { if (!editedTreeElement.property.name.startsWith('--')) { return; } let child = this.propertiesTreeOutline.firstChild(); while (child) { if (child !== editedTreeElement && child instanceof StylePropertyTreeElement) { child.refreshIfComputedValueChanged(); } child = child.traverseNextTreeElement(false /* skipUnrevealed */, null /* stayWithin */, true /* dontPopulate */); } } update(full: boolean): void { const headerText = this.headerText(); this.selectorElement.textContent = headerText; this.titleElement.classList.toggle('hidden', headerText.length === 0); this.markSelectorMatches(); if (full) { this.onpopulate(); } else { let child = this.propertiesTreeOutline.firstChild(); while (child && child instanceof StylePropertyTreeElement) { child.setOverloaded(this.isPropertyOverloaded(child.property)); child = child.traverseNextTreeElement(false /* skipUnrevealed */, null /* stayWithin */, true /* dontPopulate */); } } } showAllItems(event?: Event): void { if (event) { event.consume(); } if (this.forceShowAll) { return; } this.forceShowAll = true; this.onpopulate(); } onpopulate(): void { this.stylesContainer.setActiveProperty(null); this.nextEditorTriggerButtonIdx = 1; this.propertiesTreeOutline.removeChildren(); this.customPopulateCallback(); } populateStyle(style: SDK.CSSStyleDeclaration.CSSStyleDeclaration, parent: TreeElementParent): void { let count = 0; const properties = style.leadingProperties(); const maxProperties = DEFAULT_MAX_PROPERTIES + properties.length - this.originalPropertiesCount; for (const property of properties) { if (!this.forceShowAll && count >= maxProperties) { break; } count++; const isShorthand = property.getLonghandProperties().length > 0; const inherited = this.isPropertyInherited(property.name); const overloaded = this.isPropertyOverloaded(property); if (style.parentRule && style.parentRule.isUserAgent() && inherited) { continue; } const item = new StylePropertyTreeElement({ stylesContainer: this.stylesContai