chrome-devtools-frontend
Version:
Chrome DevTools UI
1,337 lines (1,172 loc) • 77.9 kB
text/typescript
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/*
* Copyright (C) 2007 Apple Inc. All rights reserved.
* Copyright (C) 2009 Joseph Pecoraro
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of
* its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import * as Bindings from '../../models/bindings/bindings.js';
import type * as Formatter from '../../models/formatter/formatter.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import * as Workspace from '../../models/workspace/workspace.js';
import * as WorkspaceDiff from '../../models/workspace_diff/workspace_diff.js';
import {formatCSSChangesFromDiff} from '../../panels/utils/utils.js';
import * as DiffView from '../../ui/components/diff_view/diff_view.js';
import * as IconButton from '../../ui/components/icon_button/icon_button.js';
import * as InlineEditor from '../../ui/legacy/components/inline_editor/inline_editor.js';
import * as Components from '../../ui/legacy/components/utils/utils.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as ElementsComponents from './components/components.js';
import type {ComputedStyleChangedEvent} from './ComputedStyleModel.js';
import {ComputedStyleModel} from './ComputedStyleModel.js';
import {ElementsPanel} from './ElementsPanel.js';
import {ElementsSidebarPane} from './ElementsSidebarPane.js';
import {ImagePreviewPopover} from './ImagePreviewPopover.js';
import {StyleEditorWidget} from './StyleEditorWidget.js';
import {StylePropertyHighlighter} from './StylePropertyHighlighter.js';
import stylesSidebarPaneStyles from './stylesSidebarPane.css.js';
import type {StylePropertyTreeElement} from './StylePropertyTreeElement.js';
import {
StylePropertiesSection,
BlankStylePropertiesSection,
KeyframePropertiesSection,
HighlightPseudoStylePropertiesSection,
} from './StylePropertiesSection.js';
import * as LayersWidget from './LayersWidget.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 in Styles Sidebar Pane of the Elements panel
*/
invalidPropertyValue: 'Invalid property value',
/**
*@description Text in Styles Sidebar Pane of the Elements panel
*/
unknownPropertyName: 'Unknown property name',
/**
*@description Text to filter result items
*/
filter: 'Filter',
/**
*@description ARIA accessible name in Styles Sidebar Pane of the Elements panel
*/
filterStyles: 'Filter Styles',
/**
*@description Separator element text content in Styles Sidebar Pane of the Elements panel
*@example {scrollbar-corner} PH1
*/
pseudoSElement: 'Pseudo ::{PH1} element',
/**
*@description Text of a DOM element in Styles Sidebar Pane of the Elements panel
*/
inheritedFroms: 'Inherited from ',
/**
*@description Text of an inherited psuedo element in Styles Sidebar Pane of the Elements panel
*@example {highlight} PH1
*/
inheritedFromSPseudoOf: 'Inherited from ::{PH1} pseudo of ',
/**
*@description Title of in styles sidebar pane of the elements panel
*@example {Ctrl} PH1
*/
incrementdecrementWithMousewheelOne:
'Increment/decrement with mousewheel or up/down keys. {PH1}: R ±1, Shift: G ±1, Alt: B ±1',
/**
*@description Title of in styles sidebar pane of the elements panel
*@example {Ctrl} PH1
*/
incrementdecrementWithMousewheelHundred:
'Increment/decrement with mousewheel or up/down keys. {PH1}: ±100, Shift: ±10, Alt: ±0.1',
/**
*@description Announcement string for invalid properties.
*@example {Invalid property value} PH1
*@example {font-size} PH2
*@example {invalidValue} PH3
*/
invalidString: '{PH1}, property name: {PH2}, property value: {PH3}',
/**
*@description Tooltip text that appears when hovering over the largeicon add button in the Styles Sidebar Pane of the Elements panel
*/
newStyleRule: 'New Style Rule',
/**
*@description Text that is announced by the screen reader when the user focuses on an input field for entering the name of a CSS property in the Styles panel
*@example {margin} PH1
*/
cssPropertyName: '`CSS` property name: {PH1}',
/**
*@description Text that is announced by the screen reader when the user focuses on an input field for entering the value of a CSS property in the Styles panel
*@example {10px} PH1
*/
cssPropertyValue: '`CSS` property value: {PH1}',
/**
*@description Tooltip text that appears when hovering over the rendering button in the Styles Sidebar Pane of the Elements panel
*/
toggleRenderingEmulations: 'Toggle common rendering emulations',
/**
*@description Rendering emulation option for toggling the automatic dark mode
*/
automaticDarkMode: 'Automatic dark mode',
/**
*@description Tooltip text that appears when hovering over the css changes button in the Styles Sidebar Pane of the Elements panel
*/
copyAllCSSChanges: 'Copy all the CSS changes',
/**
*@description Tooltip text that appears after clicking on the copy CSS changes button
*/
copiedToClipboard: 'Copied to clipboard',
/**
*@description Text displayed on layer separators in the styles sidebar pane.
*/
layer: 'Layer',
/**
*@description Tooltip text for the link in the sidebar pane layer separators that reveals the layer in the layer tree view.
*/
clickToRevealLayer: 'Click to reveal layer in layer tree',
};
const str_ = i18n.i18n.registerUIStrings('panels/elements/StylesSidebarPane.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
// Highlightable properties are those that can be hovered in the sidebar to trigger a specific
// highlighting mode on the current element.
const HIGHLIGHTABLE_PROPERTIES = [
{mode: 'padding', properties: ['padding']},
{mode: 'border', properties: ['border']},
{mode: 'margin', properties: ['margin']},
{mode: 'gap', properties: ['gap', 'grid-gap']},
{mode: 'column-gap', properties: ['column-gap', 'grid-column-gap']},
{mode: 'row-gap', properties: ['row-gap', 'grid-row-gap']},
{mode: 'grid-template-columns', properties: ['grid-template-columns']},
{mode: 'grid-template-rows', properties: ['grid-template-rows']},
{mode: 'grid-template-areas', properties: ['grid-areas']},
{mode: 'justify-content', properties: ['justify-content']},
{mode: 'align-content', properties: ['align-content']},
{mode: 'align-items', properties: ['align-items']},
{mode: 'flexibility', properties: ['flex', 'flex-basis', 'flex-grow', 'flex-shrink']},
];
let stylesSidebarPaneInstance: StylesSidebarPane;
export class StylesSidebarPane extends Common.ObjectWrapper.eventMixin<EventTypes, typeof ElementsSidebarPane>(
ElementsSidebarPane) {
private currentToolbarPane: UI.Widget.Widget|null;
private animatedToolbarPane: UI.Widget.Widget|null;
private pendingWidget: UI.Widget.Widget|null;
private pendingWidgetToggle: UI.Toolbar.ToolbarToggle|null;
private toolbar: UI.Toolbar.Toolbar|null;
private toolbarPaneElement: HTMLElement;
private noMatchesElement: HTMLElement;
private sectionsContainer: HTMLElement;
sectionByElement: WeakMap<Node, StylePropertiesSection>;
private readonly swatchPopoverHelperInternal: InlineEditor.SwatchPopoverHelper.SwatchPopoverHelper;
readonly linkifier: Components.Linkifier.Linkifier;
private readonly decorator: StylePropertyHighlighter;
private lastRevealedProperty: SDK.CSSProperty.CSSProperty|null;
private userOperation: boolean;
isEditingStyle: boolean;
private filterRegexInternal: RegExp|null;
private isActivePropertyHighlighted: boolean;
private initialUpdateCompleted: boolean;
hasMatchedStyles: boolean;
private sectionBlocks: SectionBlock[];
private idleCallbackManager: IdleCallbackManager|null;
private needsForceUpdate: boolean;
private readonly resizeThrottler: Common.Throttler.Throttler;
private readonly imagePreviewPopover: ImagePreviewPopover;
activeCSSAngle: InlineEditor.CSSAngle.CSSAngle|null;
#urlToChangeTracker: Map<Platform.DevToolsPath.UrlString, ChangeTracker> = new Map();
#copyChangesButton?: UI.Toolbar.ToolbarButton;
static instance(): StylesSidebarPane {
if (!stylesSidebarPaneInstance) {
stylesSidebarPaneInstance = new StylesSidebarPane();
}
return stylesSidebarPaneInstance;
}
private constructor() {
super(true /* delegatesFocus */);
this.setMinimumSize(96, 26);
this.registerCSSFiles([stylesSidebarPaneStyles]);
Common.Settings.Settings.instance().moduleSetting('colorFormat').addChangeListener(this.update.bind(this));
Common.Settings.Settings.instance().moduleSetting('textEditorIndent').addChangeListener(this.update.bind(this));
this.currentToolbarPane = null;
this.animatedToolbarPane = null;
this.pendingWidget = null;
this.pendingWidgetToggle = null;
this.toolbar = null;
this.toolbarPaneElement = this.createStylesSidebarToolbar();
this.computedStyleModelInternal = new ComputedStyleModel();
this.noMatchesElement = this.contentElement.createChild('div', 'gray-info-message hidden');
this.noMatchesElement.textContent = i18nString(UIStrings.noMatchingSelectorOrStyle);
this.sectionsContainer = this.contentElement.createChild('div');
UI.ARIAUtils.markAsList(this.sectionsContainer);
this.sectionsContainer.addEventListener('keydown', this.sectionsContainerKeyDown.bind(this), false);
this.sectionsContainer.addEventListener('focusin', this.sectionsContainerFocusChanged.bind(this), false);
this.sectionsContainer.addEventListener('focusout', this.sectionsContainerFocusChanged.bind(this), false);
this.sectionByElement = new WeakMap();
this.swatchPopoverHelperInternal = new InlineEditor.SwatchPopoverHelper.SwatchPopoverHelper();
this.swatchPopoverHelperInternal.addEventListener(
InlineEditor.SwatchPopoverHelper.Events.WillShowPopover, this.hideAllPopovers, this);
this.linkifier = new Components.Linkifier.Linkifier(MAX_LINK_LENGTH, /* useLinkDecorator */ true);
this.decorator = new StylePropertyHighlighter(this);
this.lastRevealedProperty = null;
this.userOperation = false;
this.isEditingStyle = false;
this.filterRegexInternal = null;
this.isActivePropertyHighlighted = false;
this.initialUpdateCompleted = false;
this.hasMatchedStyles = false;
this.contentElement.classList.add('styles-pane');
this.sectionBlocks = [];
this.idleCallbackManager = null;
this.needsForceUpdate = false;
stylesSidebarPaneInstance = this;
UI.Context.Context.instance().addFlavorChangeListener(SDK.DOMModel.DOMNode, this.forceUpdate, this);
this.contentElement.addEventListener('copy', this.clipboardCopy.bind(this));
this.resizeThrottler = new Common.Throttler.Throttler(100);
this.imagePreviewPopover = new ImagePreviewPopover(this.contentElement, event => {
const link = event.composedPath()[0];
if (link instanceof Element) {
return link;
}
return null;
}, () => this.node());
this.activeCSSAngle = null;
}
swatchPopoverHelper(): InlineEditor.SwatchPopoverHelper.SwatchPopoverHelper {
return this.swatchPopoverHelperInternal;
}
setUserOperation(userOperation: boolean): void {
this.userOperation = userOperation;
}
static createExclamationMark(property: SDK.CSSProperty.CSSProperty, title: string|null): Element {
const exclamationElement = (document.createElement('span', {is: 'dt-icon-label'}) as UI.UIUtils.DevToolsIconLabel);
exclamationElement.className = 'exclamation-mark';
if (!StylesSidebarPane.ignoreErrorsForProperty(property)) {
exclamationElement.type = 'smallicon-warning';
}
let invalidMessage: string|Common.UIString.LocalizedString;
if (title) {
UI.Tooltip.Tooltip.install(exclamationElement, title);
invalidMessage = title;
} else {
invalidMessage = SDK.CSSMetadata.cssMetadata().isCSSPropertyName(property.name) ?
i18nString(UIStrings.invalidPropertyValue) :
i18nString(UIStrings.unknownPropertyName);
UI.Tooltip.Tooltip.install(exclamationElement, invalidMessage);
}
const invalidString =
i18nString(UIStrings.invalidString, {PH1: invalidMessage, PH2: property.name, PH3: property.value});
// Storing the invalidString for future screen reader support when editing the property
property.setDisplayedStringForInvalidProperty(invalidString);
return exclamationElement;
}
static ignoreErrorsForProperty(property: SDK.CSSProperty.CSSProperty): boolean {
function hasUnknownVendorPrefix(string: string): boolean {
return !string.startsWith('-webkit-') && /^[-_][\w\d]+-\w/.test(string);
}
const name = property.name.toLowerCase();
// IE hack.
if (name.charAt(0) === '_') {
return true;
}
// IE has a different format for this.
if (name === 'filter') {
return true;
}
// Common IE-specific property prefix.
if (name.startsWith('scrollbar-')) {
return true;
}
if (hasUnknownVendorPrefix(name)) {
return true;
}
const value = property.value.toLowerCase();
// IE hack.
if (value.endsWith('\\9')) {
return true;
}
if (hasUnknownVendorPrefix(value)) {
return true;
}
return false;
}
static createPropertyFilterElement(
placeholder: string, container: Element, filterCallback: (arg0: RegExp|null) => void): Element {
const input = document.createElement('input');
input.type = 'search';
input.classList.add('custom-search-input');
input.placeholder = placeholder;
function searchHandler(): void {
const regex = input.value ? new RegExp(Platform.StringUtilities.escapeForRegExp(input.value), 'i') : null;
filterCallback(regex);
}
input.addEventListener('input', searchHandler, false);
function keydownHandler(event: Event): void {
const keyboardEvent = (event as KeyboardEvent);
if (keyboardEvent.key !== Platform.KeyboardUtilities.ESCAPE_KEY || !input.value) {
return;
}
keyboardEvent.consume(true);
input.value = '';
searchHandler();
}
input.addEventListener('keydown', keydownHandler, false);
return input;
}
static formatLeadingProperties(section: StylePropertiesSection): {
allDeclarationText: string,
ruleText: string,
} {
const selectorText = section.headerText();
const indent = Common.Settings.Settings.instance().moduleSetting('textEditorIndent').get();
const style = section.style();
const lines: string[] = [];
// Invalid property should also be copied.
// For example: *display: inline.
for (const property of style.leadingProperties()) {
if (property.disabled) {
lines.push(`${indent}/* ${property.name}: ${property.value}; */`);
} else {
lines.push(`${indent}${property.name}: ${property.value};`);
}
}
const allDeclarationText: string = lines.join('\n');
const ruleText: string = `${selectorText} {\n${allDeclarationText}\n}`;
return {
allDeclarationText,
ruleText,
};
}
revealProperty(cssProperty: SDK.CSSProperty.CSSProperty): void {
this.decorator.highlightProperty(cssProperty);
this.lastRevealedProperty = cssProperty;
this.update();
}
jumpToProperty(propertyName: string): void {
this.decorator.findAndHighlightPropertyName(propertyName);
}
forceUpdate(): void {
this.needsForceUpdate = true;
this.swatchPopoverHelperInternal.hide();
this.resetCache();
this.update();
}
private sectionsContainerKeyDown(event: Event): void {
const activeElement = Platform.DOMUtilities.deepActiveElement(this.sectionsContainer.ownerDocument);
if (!activeElement) {
return;
}
const section = this.sectionByElement.get(activeElement);
if (!section) {
return;
}
let sectionToFocus: (StylePropertiesSection|null)|null = null;
let willIterateForward = false;
switch ((event as KeyboardEvent).key) {
case 'ArrowUp':
case 'ArrowLeft': {
sectionToFocus = section.previousSibling() || section.lastSibling();
willIterateForward = false;
break;
}
case 'ArrowDown':
case 'ArrowRight': {
sectionToFocus = section.nextSibling() || section.firstSibling();
willIterateForward = true;
break;
}
case 'Home': {
sectionToFocus = section.firstSibling();
willIterateForward = true;
break;
}
case 'End': {
sectionToFocus = section.lastSibling();
willIterateForward = false;
break;
}
}
if (sectionToFocus && this.filterRegexInternal) {
sectionToFocus = sectionToFocus.findCurrentOrNextVisible(/* willIterateForward= */ willIterateForward);
}
if (sectionToFocus) {
sectionToFocus.element.focus();
event.consume(true);
}
}
private sectionsContainerFocusChanged(): void {
this.resetFocus();
}
resetFocus(): void {
// When a styles section is focused, shift+tab should leave the section.
// Leaving tabIndex = 0 on the first element would cause it to be focused instead.
if (!this.noMatchesElement.classList.contains('hidden')) {
return;
}
if (this.sectionBlocks[0] && this.sectionBlocks[0].sections[0]) {
const firstVisibleSection =
this.sectionBlocks[0].sections[0].findCurrentOrNextVisible(/* willIterateForward= */ true);
if (firstVisibleSection) {
firstVisibleSection.element.tabIndex = this.sectionsContainer.hasFocus() ? -1 : 0;
}
}
}
onAddButtonLongClick(event: Event): void {
const cssModel = this.cssModel();
if (!cssModel) {
return;
}
const headers = cssModel.styleSheetHeaders().filter(styleSheetResourceHeader);
const contextMenuDescriptors: {
text: string,
handler: () => Promise<void>,
}[] = [];
for (let i = 0; i < headers.length; ++i) {
const header = headers[i];
const handler = this.createNewRuleInStyleSheet.bind(this, header);
contextMenuDescriptors.push({text: Bindings.ResourceUtils.displayNameForURL(header.resourceURL()), handler});
}
contextMenuDescriptors.sort(compareDescriptors);
const contextMenu = new UI.ContextMenu.ContextMenu(event);
for (let i = 0; i < contextMenuDescriptors.length; ++i) {
const descriptor = contextMenuDescriptors[i];
contextMenu.defaultSection().appendItem(descriptor.text, descriptor.handler);
}
contextMenu.footerSection().appendItem(
'inspector-stylesheet', this.createNewRuleInViaInspectorStyleSheet.bind(this));
void contextMenu.show();
function compareDescriptors(
descriptor1: {
text: string,
handler: () => Promise<void>,
},
descriptor2: {
text: string,
handler: () => Promise<void>,
}): number {
return Platform.StringUtilities.naturalOrderComparator(descriptor1.text, descriptor2.text);
}
function styleSheetResourceHeader(header: SDK.CSSStyleSheetHeader.CSSStyleSheetHeader): boolean {
return !header.isViaInspector() && !header.isInline && Boolean(header.resourceURL());
}
}
private onFilterChanged(regex: RegExp|null): void {
this.filterRegexInternal = regex;
this.updateFilter();
this.resetFocus();
}
refreshUpdate(editedSection: StylePropertiesSection, editedTreeElement?: StylePropertyTreeElement): void {
if (editedTreeElement) {
for (const section of this.allSections()) {
if (section instanceof BlankStylePropertiesSection && section.isBlank) {
continue;
}
section.updateVarFunctions(editedTreeElement);
}
}
if (this.isEditingStyle) {
return;
}
const node = this.node();
if (!node) {
return;
}
for (const section of this.allSections()) {
if (section instanceof BlankStylePropertiesSection && section.isBlank) {
continue;
}
section.update(section === editedSection);
}
if (this.filterRegexInternal) {
this.updateFilter();
}
this.swatchPopoverHelper().reposition();
this.nodeStylesUpdatedForTest(node, false);
}
async doUpdate(): Promise<void> {
if (!this.initialUpdateCompleted) {
window.setTimeout(() => {
if (!this.initialUpdateCompleted) {
// the spinner will get automatically removed when innerRebuildUpdate is called
this.sectionsContainer.createChild('span', 'spinner');
}
}, 200 /* only spin for loading time > 200ms to avoid unpleasant render flashes */);
}
const node = this.node();
// TODO: Fetch the parent node id using CDP command
const parentNode = node ? node.parentNode : null;
const [computedStyles, parentsComputedStyles] =
await Promise.all([this.fetchComputedStylesFor(node), this.fetchComputedStylesFor(parentNode)]);
const matchedStyles = await this.fetchMatchedCascade();
await this.innerRebuildUpdate(matchedStyles, computedStyles, parentsComputedStyles);
if (!this.initialUpdateCompleted) {
this.initialUpdateCompleted = true;
this.appendToolbarItem(this.createRenderingShortcuts());
if (Root.Runtime.experiments.isEnabled(Root.Runtime.ExperimentName.STYLES_PANE_CSS_CHANGES)) {
this.#copyChangesButton = this.createCopyAllChangesButton();
this.appendToolbarItem(this.#copyChangesButton);
this.#copyChangesButton.element.classList.add('hidden');
}
this.dispatchEventToListeners(Events.InitialUpdateCompleted);
}
this.dispatchEventToListeners(Events.StylesUpdateCompleted, {hasMatchedStyles: this.hasMatchedStyles});
}
private async fetchComputedStylesFor(node: SDK.DOMModel.DOMNode|null): Promise<Map<string, string>|null> {
if (!node) {
return null;
}
return await node.domModel().cssModel().getComputedStyle(node.id);
}
onResize(): void {
void this.resizeThrottler.schedule(this.innerResize.bind(this));
}
private innerResize(): Promise<void> {
const width = this.contentElement.getBoundingClientRect().width + 'px';
this.allSections().forEach(section => {
section.propertiesTreeOutline.element.style.width = width;
});
return Promise.resolve();
}
private resetCache(): void {
const cssModel = this.cssModel();
if (cssModel) {
cssModel.discardCachedMatchedCascade();
}
}
private fetchMatchedCascade(): Promise<SDK.CSSMatchedStyles.CSSMatchedStyles|null> {
const node = this.node();
if (!node || !this.cssModel()) {
return Promise.resolve((null as SDK.CSSMatchedStyles.CSSMatchedStyles | null));
}
const cssModel = this.cssModel();
if (!cssModel) {
return Promise.resolve(null);
}
return cssModel.cachedMatchedCascadeForNode(node).then(validateStyles.bind(this));
function validateStyles(this: StylesSidebarPane, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles|null):
SDK.CSSMatchedStyles.CSSMatchedStyles|null {
return matchedStyles && matchedStyles.node() === this.node() ? matchedStyles : null;
}
}
setEditingStyle(editing: boolean, _treeElement?: StylePropertyTreeElement): void {
if (this.isEditingStyle === editing) {
return;
}
this.contentElement.classList.toggle('is-editing-style', editing);
this.isEditingStyle = editing;
this.setActiveProperty(null);
}
setActiveProperty(treeElement: StylePropertyTreeElement|null): void {
if (this.isActivePropertyHighlighted) {
SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight();
}
this.isActivePropertyHighlighted = false;
if (!this.node()) {
return;
}
if (!treeElement || treeElement.overloaded() || treeElement.inherited()) {
return;
}
const rule = treeElement.property.ownerStyle.parentRule;
const selectorList = (rule instanceof SDK.CSSRule.CSSStyleRule) ? rule.selectorText() : undefined;
for (const {properties, mode} of HIGHLIGHTABLE_PROPERTIES) {
if (!properties.includes(treeElement.name)) {
continue;
}
const node = this.node();
if (!node) {
continue;
}
node.domModel().overlayModel().highlightInOverlay(
{node: (this.node() as SDK.DOMModel.DOMNode), selectorList}, mode);
this.isActivePropertyHighlighted = true;
break;
}
}
override onCSSModelChanged(event: Common.EventTarget.EventTargetEvent<ComputedStyleChangedEvent>): void {
const edit = event?.data && 'edit' in event.data ? event.data.edit : null;
if (edit) {
for (const section of this.allSections()) {
section.styleSheetEdited(edit);
}
return;
}
if (this.userOperation || this.isEditingStyle) {
return;
}
this.resetCache();
this.update();
}
focusedSectionIndex(): number {
let index = 0;
for (const block of this.sectionBlocks) {
for (const section of block.sections) {
if (section.element.hasFocus()) {
return index;
}
index++;
}
}
return -1;
}
continueEditingElement(sectionIndex: number, propertyIndex: number): void {
const section = this.allSections()[sectionIndex];
if (section) {
const element = (section.closestPropertyForEditing(propertyIndex) as StylePropertyTreeElement | null);
if (!element) {
section.element.focus();
return;
}
element.startEditing();
}
}
private async innerRebuildUpdate(
matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles|null, computedStyles: Map<string, string>|null,
parentsComputedStyles: Map<string, string>|null): Promise<void> {
// ElementsSidebarPane's throttler schedules this method. Usually,
// rebuild is suppressed while editing (see onCSSModelChanged()), but we need a
// 'force' flag since the currently running throttler process cannot be canceled.
if (this.needsForceUpdate) {
this.needsForceUpdate = false;
} else if (this.isEditingStyle || this.userOperation) {
return;
}
const focusedIndex = this.focusedSectionIndex();
this.linkifier.reset();
const prevSections = this.sectionBlocks.map(block => block.sections).flat();
this.sectionBlocks = [];
const node = this.node();
this.hasMatchedStyles = matchedStyles !== null && node !== null;
if (!this.hasMatchedStyles) {
this.sectionsContainer.removeChildren();
this.noMatchesElement.classList.remove('hidden');
return;
}
this.sectionBlocks = await this.rebuildSectionsForMatchedStyleRules(
(matchedStyles as SDK.CSSMatchedStyles.CSSMatchedStyles), computedStyles, parentsComputedStyles);
// Style sections maybe re-created when flexbox editor is activated.
// With the following code we re-bind the flexbox editor to the new
// section with the same index as the previous section had.
const newSections = this.sectionBlocks.map(block => block.sections).flat();
const styleEditorWidget = StyleEditorWidget.instance();
const boundSection = styleEditorWidget.getSection();
if (boundSection) {
styleEditorWidget.unbindContext();
for (const [index, prevSection] of prevSections.entries()) {
if (boundSection === prevSection && index < newSections.length) {
styleEditorWidget.bindContext(this, newSections[index]);
}
}
}
this.sectionsContainer.removeChildren();
const fragment = document.createDocumentFragment();
let index = 0;
let elementToFocus: HTMLDivElement|null = null;
for (const block of this.sectionBlocks) {
const titleElement = block.titleElement();
if (titleElement) {
fragment.appendChild(titleElement);
}
for (const section of block.sections) {
fragment.appendChild(section.element);
if (index === focusedIndex) {
elementToFocus = section.element;
}
index++;
}
}
this.sectionsContainer.appendChild(fragment);
if (elementToFocus) {
elementToFocus.focus();
}
if (focusedIndex >= index) {
this.sectionBlocks[0].sections[0].element.focus();
}
this.sectionsContainerFocusChanged();
if (this.filterRegexInternal) {
this.updateFilter();
} else {
this.noMatchesElement.classList.toggle('hidden', this.sectionBlocks.length > 0);
}
this.nodeStylesUpdatedForTest((node as SDK.DOMModel.DOMNode), true);
if (this.lastRevealedProperty) {
this.decorator.highlightProperty(this.lastRevealedProperty);
this.lastRevealedProperty = null;
}
this.swatchPopoverHelper().reposition();
// Record the elements tool load time after the sidepane has loaded.
Host.userMetrics.panelLoaded('elements', 'DevTools.Launch.Elements');
this.dispatchEventToListeners(Events.StylesUpdateCompleted, {hasMatchedStyles: false});
}
private nodeStylesUpdatedForTest(_node: SDK.DOMModel.DOMNode, _rebuild: boolean): void {
// For sniffing in tests.
}
private async rebuildSectionsForMatchedStyleRules(
matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, computedStyles: Map<string, string>|null,
parentsComputedStyles: Map<string, string>|null): Promise<SectionBlock[]> {
if (this.idleCallbackManager) {
this.idleCallbackManager.discard();
}
this.idleCallbackManager = new IdleCallbackManager();
const blocks = [new SectionBlock(null)];
let sectionIdx = 0;
let lastParentNode: SDK.DOMModel.DOMNode|null = null;
let lastLayers: SDK.CSSLayer.CSSLayer[]|null = null;
let sawLayers: boolean = false;
const layersExperimentEnabled = Root.Runtime.experiments.isEnabled(Root.Runtime.ExperimentName.CSS_LAYERS);
const addLayerSeparator = (style: SDK.CSSStyleDeclaration.CSSStyleDeclaration): void => {
if (!layersExperimentEnabled) {
return;
}
const parentRule = style.parentRule;
if (parentRule instanceof SDK.CSSRule.CSSStyleRule) {
const layers = parentRule.layers;
if ((layers.length || lastLayers) && lastLayers !== layers) {
const block = SectionBlock.createLayerBlock(parentRule);
blocks.push(block);
sawLayers = true;
lastLayers = layers;
}
}
};
// We disable the layer widget initially. If we see a layer in
// the matched styles we reenable the button.
LayersWidget.ButtonProvider.instance().item().setVisible(false);
const refreshedURLs = new Set<string>();
for (const style of matchedStyles.nodeStyles()) {
if (Root.Runtime.experiments.isEnabled(Root.Runtime.ExperimentName.STYLES_PANE_CSS_CHANGES) && style.parentRule) {
const url = style.parentRule.resourceURL();
if (url && !refreshedURLs.has(url)) {
await this.trackURLForChanges(url);
refreshedURLs.add(url);
}
}
const parentNode = matchedStyles.isInherited(style) ? matchedStyles.nodeForStyle(style) : null;
if (parentNode && parentNode !== lastParentNode) {
lastParentNode = parentNode;
const block = await SectionBlock.createInheritedNodeBlock(lastParentNode);
blocks.push(block);
}
addLayerSeparator(style);
const lastBlock = blocks[blocks.length - 1];
if (lastBlock) {
this.idleCallbackManager.schedule(() => {
const section =
new StylePropertiesSection(this, matchedStyles, style, sectionIdx, computedStyles, parentsComputedStyles);
sectionIdx++;
lastBlock.sections.push(section);
});
}
}
const customHighlightPseudoRulesets: {
highlightName: string|null,
pseudoType: Protocol.DOM.PseudoType,
pseudoStyles: SDK.CSSStyleDeclaration.CSSStyleDeclaration[],
}[] = Array.from(matchedStyles.customHighlightPseudoNames()).map(highlightName => {
return {
'highlightName': highlightName,
'pseudoType': Protocol.DOM.PseudoType.Highlight,
'pseudoStyles': matchedStyles.customHighlightPseudoStyles(highlightName),
};
});
const otherPseudoRulesets: {
highlightName: string|null,
pseudoType: Protocol.DOM.PseudoType,
pseudoStyles: SDK.CSSStyleDeclaration.CSSStyleDeclaration[],
}[] = [...matchedStyles.pseudoTypes()].map(pseudoType => {
return {'highlightName': null, 'pseudoType': pseudoType, 'pseudoStyles': matchedStyles.pseudoStyles(pseudoType)};
});
const pseudoRulesets = customHighlightPseudoRulesets.concat(otherPseudoRulesets).sort((a, b) => {
// We want to show the ::before pseudos first, followed by the remaining pseudos
// in alphabetical order.
if (a.pseudoType === Protocol.DOM.PseudoType.Before && b.pseudoType !== Protocol.DOM.PseudoType.Before) {
return -1;
}
if (a.pseudoType !== Protocol.DOM.PseudoType.Before && b.pseudoType === Protocol.DOM.PseudoType.Before) {
return 1;
}
if (a.pseudoType < b.pseudoType) {
return -1;
}
if (a.pseudoType > b.pseudoType) {
return 1;
}
return 0;
});
for (const pseudo of pseudoRulesets) {
lastParentNode = null;
for (let i = 0; i < pseudo.pseudoStyles.length; ++i) {
const style = pseudo.pseudoStyles[i];
const parentNode = matchedStyles.isInherited(style) ? matchedStyles.nodeForStyle(style) : null;
// Start a new SectionBlock if this is the first rule for this pseudo type, or if this
// rule is inherited from a different parent than the previous rule.
if (i === 0 || parentNode !== lastParentNode) {
lastLayers = null;
if (parentNode) {
const block =
await SectionBlock.createInheritedPseudoTypeBlock(pseudo.pseudoType, pseudo.highlightName, parentNode);
blocks.push(block);
} else {
const block = SectionBlock.createPseudoTypeBlock(pseudo.pseudoType, pseudo.highlightName);
blocks.push(block);
}
}
lastParentNode = parentNode;
addLayerSeparator(style);
const lastBlock = blocks[blocks.length - 1];
this.idleCallbackManager.schedule(() => {
const section = new HighlightPseudoStylePropertiesSection(
this, matchedStyles, style, sectionIdx, computedStyles, parentsComputedStyles);
sectionIdx++;
lastBlock.sections.push(section);
});
}
}
for (const keyframesRule of matchedStyles.keyframes()) {
const block = SectionBlock.createKeyframesBlock(keyframesRule.name().text);
for (const keyframe of keyframesRule.keyframes()) {
this.idleCallbackManager.schedule(() => {
block.sections.push(new KeyframePropertiesSection(this, matchedStyles, keyframe.style, sectionIdx));
sectionIdx++;
});
}
blocks.push(block);
}
if (layersExperimentEnabled) {
// If we have seen a layer in matched styles we enable
// the layer widget button.
if (sawLayers) {
LayersWidget.ButtonProvider.instance().item().setVisible(true);
} else if (LayersWidget.LayersWidget.instance().isShowing()) {
// Since the button for toggling the layers view is now hidden
// we ensure that the layers view is not currently toggled.
ElementsPanel.instance().showToolbarPane(null, LayersWidget.ButtonProvider.instance().item());
}
}
await this.idleCallbackManager.awaitDone();
return blocks;
}
async createNewRuleInViaInspectorStyleSheet(): Promise<void> {
const cssModel = this.cssModel();
const node = this.node();
if (!cssModel || !node) {
return;
}
this.setUserOperation(true);
const styleSheetHeader = await cssModel.requestViaInspectorStylesheet((node as SDK.DOMModel.DOMNode));
this.setUserOperation(false);
await this.createNewRuleInStyleSheet(styleSheetHeader);
}
private async createNewRuleInStyleSheet(styleSheetHeader: SDK.CSSStyleSheetHeader.CSSStyleSheetHeader|
null): Promise<void> {
if (!styleSheetHeader) {
return;
}
const text = (await styleSheetHeader.requestContent()).content || '';
const lines = text.split('\n');
const range = TextUtils.TextRange.TextRange.createFromLocation(lines.length - 1, lines[lines.length - 1].length);
if (this.sectionBlocks && this.sectionBlocks.length > 0) {
this.addBlankSection(this.sectionBlocks[0].sections[0], styleSheetHeader.id, range);
}
}
addBlankSection(
insertAfterSection: StylePropertiesSection, styleSheetId: Protocol.CSS.StyleSheetId,
ruleLocation: TextUtils.TextRange.TextRange): void {
const node = this.node();
const blankSection = new BlankStylePropertiesSection(
this, insertAfterSection.matchedStyles, node ? node.simpleSelector() : '', styleSheetId, ruleLocation,
insertAfterSection.style(), 0);
this.sectionsContainer.insertBefore(blankSection.element, insertAfterSection.element.nextSibling);
for (const block of this.sectionBlocks) {
const index = block.sections.indexOf(insertAfterSection);
if (index === -1) {
continue;
}
block.sections.splice(index + 1, 0, blankSection);
blankSection.startEditingSelector();
}
let sectionIdx = 0;
for (const block of this.sectionBlocks) {
for (const section of block.sections) {
section.setSectionIdx(sectionIdx);
sectionIdx++;
}
}
}
removeSection(section: StylePropertiesSection): void {
for (const block of this.sectionBlocks) {
const index = block.sections.indexOf(section);
if (index === -1) {
continue;
}
block.sections.splice(index, 1);
section.element.remove();
}
}
filterRegex(): RegExp|null {
return this.filterRegexInternal;
}
private updateFilter(): void {
let hasAnyVisibleBlock = false;
for (const block of this.sectionBlocks) {
hasAnyVisibleBlock = block.updateFilter() || hasAnyVisibleBlock;
}
this.noMatchesElement.classList.toggle('hidden', Boolean(hasAnyVisibleBlock));
}
willHide(): void {
this.hideAllPopovers();
super.willHide();
}
hideAllPopovers(): void {
this.swatchPopoverHelperInternal.hide();
this.imagePreviewPopover.hide();
if (this.activeCSSAngle) {
this.activeCSSAngle.minify();
this.activeCSSAngle = null;
}
}
allSections(): StylePropertiesSection[] {
let sections: StylePropertiesSection[] = [];
for (const block of this.sectionBlocks) {
sections = sections.concat(block.sections);
}
return sections;
}
async trackURLForChanges(url: Platform.DevToolsPath.UrlString): Promise<void> {
const currentTracker = this.#urlToChangeTracker.get(url);
if (currentTracker) {
WorkspaceDiff.WorkspaceDiff.workspaceDiff().unsubscribeFromDiffChange(
currentTracker.uiSourceCode, currentTracker.diffChangeCallback);
}
// We get a refreshed uiSourceCode each time because the underlying instance may be recreated.
const uiSourceCode = Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL(url);
if (!uiSourceCode) {
return;
}
const diffChangeCallback = this.refreshChangedLines.bind(this, uiSourceCode);
WorkspaceDiff.WorkspaceDiff.workspaceDiff().subscribeToDiffChange(uiSourceCode, diffChangeCallback);
const newTracker = {
uiSourceCode,
changedLines: new Set<number>(),
diffChangeCallback,
};
this.#urlToChangeTracker.set(url, newTracker);
await this.refreshChangedLines(newTracker.uiSourceCode);
}
isPropertyChanged(property: SDK.CSSProperty.CSSProperty): boolean {
const url = property.ownerStyle.parentRule?.resourceURL();
if (!url) {
return false;
}
const changeTracker = this.#urlToChangeTracker.get(url);
if (!changeTracker) {
return false;
}
const {changedLines, formattedCurrentMapping} = changeTracker;
const uiLocation = Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance().propertyUILocation(property, true);
if (!uiLocation) {
return false;
}
if (!formattedCurrentMapping) {
// UILocation's lineNumber starts at 0, but changedLines start at 1.
return changedLines.has(uiLocation.lineNumber + 1);
}
const formattedLineNumber =
formattedCurrentMapping.originalToFormatted(uiLocation.lineNumber, uiLocation.columnNumber)[0];
return changedLines.has(formattedLineNumber + 1);
}
updateChangeStatus(): void {
if (!this.#copyChangesButton) {
return;
}
let hasChangedStyles = false;
for (const changeTracker of this.#urlToChangeTracker.values()) {
if (changeTracker.changedLines.size > 0) {
hasChangedStyles = true;
break;
}
}
this.#copyChangesButton.element.classList.toggle('hidden', !hasChangedStyles);
}
private async refreshChangedLines(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
const changeTracker = this.#urlToChangeTracker.get(uiSourceCode.url());
if (!changeTracker) {
return;
}
const diffResponse =
await WorkspaceDiff.WorkspaceDiff.workspaceDiff().requestDiff(uiSourceCode, {shouldFormatDiff: true});
const changedLines = new Set<number>();
changeTracker.changedLines = changedLines;
if (!diffResponse) {
return;
}
const {diff, formattedCurrentMapping} = diffResponse;
const {rows} = DiffView.DiffView.buildDiffRows(diff);
for (const row of rows) {
if (row.type === DiffView.DiffView.RowType.Addition) {
changedLines.add(row.currentLineNumber);
}
}
changeTracker.formattedCurrentMapping = formattedCurrentMapping;
}
async getFormattedChanges(): Promise<string> {
let allChanges = '';
for (const [url, {uiSourceCode}] of this.#urlToChangeTracker) {
const diffResponse =
await WorkspaceDiff.WorkspaceDiff.workspaceDiff().requestDiff(uiSourceCode, {shouldFormatDiff: true});
// Diff array with real diff will contain at least 2 lines.
if (!diffResponse || diffResponse?.diff.length < 2) {
continue;
}
const changes = await formatCSSChangesFromDiff(diffResponse.diff);
if (changes.length > 0) {
allChanges += `/* ${escapeUrlAsCssComment(url)} */\n\n${changes}\n\n`;
}
}
return allChanges;
}
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');
const hbox = container.createChild('div', 'hbox styles-sidebar-pane-toolbar');
const filterContainerElement = hbox.createChild('div', 'styles-sidebar-pane-filter-box');
const filterInput = StylesSidebarPane.createPropertyFilterElement(
i18nString(UIStrings.filter), hbox, this.onFilterChanged.bind(this));
UI.ARIAUtils.setAccessibleName(filterInput, i18nString(UIStrings.filterStyles));
filterContainerElement.appendChild(filterInput);
const toolbar = new UI.Toolbar.Toolbar('styles-pane-toolbar', hbox);
toolbar.makeToggledGray();
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') as HTMLElement);
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>('emulatedCSSMediaFeaturePrefersColorScheme');
const autoDarkModeSetting = Common.Settings.Settings.instance().moduleSetting('emulateAutoDarkMode');
const decorateStatus = (condition: boolean, title: string): string => `${condition ? '✓ ' : ''}${title}`;
const icon = new IconButton.Icon.Icon();
icon.data = {
iconName: 'ic_rendering',
color: 'var(--color-text-secondary)',
width: '18px',
height: '18px',
};
const button = new UI.Toolbar.ToolbarToggle(i18nString(UIStrings.toggleRenderingEmulations), icon);
button.setToggleWithDot(true);
button.element.addEventListener('click', event => {
const menu = new UI.ContextMenu.ContextMenu(event);
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()));
});
menu.defaultSection().appendItem(darkColorSchemeOption, () => {
autoDarkModeSetting.set(false);
prefersColorSchemeSetting.set(isDarkColorScheme ? '' : 'dark');
button.setToggled(Boolean(prefersColorSchemeSetting.get()));
});
menu.defaultSection().appendItem(autoDarkModeOption, () => {
autoDarkModeSetting.set(!isAutoDarkEnabled);
button.setToggled(Boolean(prefersColorSchemeSetting.get()));
});
v