chrome-devtools-frontend
Version:
Chrome DevTools UI
1,323 lines (1,151 loc) • 61.8 kB
text/typescript
// Copyright 2018 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.
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 Bindings from '../../models/bindings/bindings.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import * as ColorPicker from '../../ui/legacy/components/color_picker/color_picker.js';
import * as InlineEditor from '../../ui/legacy/components/inline_editor/inline_editor.js';
import * as UI from '../../ui/legacy/legacy.js';
import {BezierPopoverIcon, ColorSwatchPopoverIcon, ShadowSwatchPopoverHelper} from './ColorSwatchPopoverIcon.js';
import * as ElementsComponents from './components/components.js';
import {ElementsPanel} from './ElementsPanel.js';
import {StyleEditorWidget} from './StyleEditorWidget.js';
import type {StylePropertiesSection} from './StylePropertiesSection.js';
import {CSSPropertyPrompt, StylesSidebarPane, StylesSidebarPropertyRenderer} from './StylesSidebarPane.js';
import {getCssDeclarationAsJavascriptProperty} from './StylePropertyUtils.js';
import {cssRuleValidatorsMap} from './CSSRuleValidator.js';
import type {AuthoringHint} from './CSSRuleValidator.js';
const FlexboxEditor = ElementsComponents.StylePropertyEditor.FlexboxEditor;
const GridEditor = ElementsComponents.StylePropertyEditor.GridEditor;
const UIStrings = {
/**
*@description Text in Color Swatch Popover Icon of the Elements panel
*/
shiftClickToChangeColorFormat: 'Shift + Click to change color format.',
/**
*@description Swatch icon element title in Color Swatch Popover Icon of the Elements panel
*@example {Shift + Click to change color format.} PH1
*/
openColorPickerS: 'Open color picker. {PH1}',
/**
*@description The warning text shown in Elements panel when font-variation-settings don't match allowed values
*@example {wdth} PH1
*@example {100} PH2
*@example {10} PH3
*@example {20} PH4
*@example {Arial} PH5
*/
valueForSettingSSIsOutsideThe:
'Value for setting “{PH1}” {PH2} is outside the supported range [{PH3}, {PH4}] for font-family “{PH5}”.',
/**
*@description Context menu item for style property in edit mode
*/
togglePropertyAndContinueEditing: 'Toggle property and continue editing',
/**
*@description Context menu item for style property in edit mode
*/
revealInSourcesPanel: 'Reveal in Sources panel',
/**
*@description A context menu item in Styles panel to copy CSS declaration
*/
copyDeclaration: 'Copy declaration',
/**
*@description A context menu item in Styles panel to copy CSS property
*/
copyProperty: 'Copy property',
/**
*@description A context menu item in the Watch Expressions Sidebar Pane of the Sources panel and Network pane request.
*/
copyValue: 'Copy value',
/**
*@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 A context menu item in Styles panel to copy all the CSS changes
*/
copyAllCSSChanges: 'Copy all CSS changes',
/**
*@description A context menu item in Styles panel to view the computed CSS property value.
*/
viewComputedValue: 'View computed value',
/**
* @description Title of the button that opens the flexbox editor in the Styles panel.
*/
flexboxEditorButton: 'Open `flexbox` editor',
/**
* @description Title of the button that opens the CSS Grid editor in the Styles panel.
*/
gridEditorButton: 'Open `grid` editor',
/**
*@description A context menu item in Styles panel to copy CSS declaration as JavaScript property.
*/
copyCssDeclarationAsJs: 'Copy declaration as JS',
/**
*@description A context menu item in Styles panel to copy all declarations of CSS rule as JavaScript properties.
*/
copyAllCssDeclarationsAsJs: 'Copy all declarations as JS',
};
const str_ = i18n.i18n.registerUIStrings('panels/elements/StylePropertyTreeElement.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const parentMap = new WeakMap<StylesSidebarPane, StylePropertyTreeElement>();
export class StylePropertyTreeElement extends UI.TreeOutline.TreeElement {
private readonly style: SDK.CSSStyleDeclaration.CSSStyleDeclaration;
private matchedStylesInternal: SDK.CSSMatchedStyles.CSSMatchedStyles;
property: SDK.CSSProperty.CSSProperty;
private readonly inheritedInternal: boolean;
private overloadedInternal: boolean;
private parentPaneInternal: StylesSidebarPane;
isShorthand: boolean;
private readonly applyStyleThrottler: Common.Throttler.Throttler;
private newProperty: boolean;
private expandedDueToFilter: boolean;
valueElement: HTMLElement|null;
nameElement: HTMLElement|null;
private expandElement: UI.Icon.Icon|null;
private originalPropertyText: string;
private hasBeenEditedIncrementally: boolean;
private prompt: CSSPropertyPrompt|null;
private lastComputedValue: string|null;
private computedStyles: Map<string, string>|null = null;
private parentsComputedStyles: Map<string, string>|null = null;
private contextForTest!: Context|undefined;
#propertyTextFromSource: string;
constructor(
stylesPane: StylesSidebarPane, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
property: SDK.CSSProperty.CSSProperty, isShorthand: boolean, inherited: boolean, overloaded: boolean,
newProperty: boolean) {
// Pass an empty title, the title gets made later in onattach.
super('', isShorthand);
this.style = property.ownerStyle;
this.matchedStylesInternal = matchedStyles;
this.property = property;
this.inheritedInternal = inherited;
this.overloadedInternal = overloaded;
this.selectable = false;
this.parentPaneInternal = stylesPane;
this.isShorthand = isShorthand;
this.applyStyleThrottler = new Common.Throttler.Throttler(0);
this.newProperty = newProperty;
if (this.newProperty) {
this.listItemElement.textContent = '';
}
this.expandedDueToFilter = false;
this.valueElement = null;
this.nameElement = null;
this.expandElement = null;
this.originalPropertyText = '';
this.hasBeenEditedIncrementally = false;
this.prompt = null;
this.lastComputedValue = null;
this.#propertyTextFromSource = property.propertyText || '';
}
matchedStyles(): SDK.CSSMatchedStyles.CSSMatchedStyles {
return this.matchedStylesInternal;
}
private editable(): boolean {
return Boolean(this.style.styleSheetId && this.style.range);
}
inherited(): boolean {
return this.inheritedInternal;
}
overloaded(): boolean {
return this.overloadedInternal;
}
setOverloaded(x: boolean): void {
if (x === this.overloadedInternal) {
return;
}
this.overloadedInternal = x;
this.updateState();
}
setComputedStyles(computedStyles: Map<string, string>|null): void {
this.computedStyles = computedStyles;
}
setParentsComputedStyles(parentsComputedStyles: Map<string, string>|null): void {
this.parentsComputedStyles = parentsComputedStyles;
}
get name(): string {
return this.property.name;
}
get value(): string {
return this.property.value;
}
updateFilter(): boolean {
const regex = this.parentPaneInternal.filterRegex();
const matches = regex !== null && (regex.test(this.property.name) || regex.test(this.property.value));
this.listItemElement.classList.toggle('filter-match', matches);
void this.onpopulate();
let hasMatchingChildren = false;
for (let i = 0; i < this.childCount(); ++i) {
const child = (this.childAt(i) as StylePropertyTreeElement | null);
if (!child || (child && !child.updateFilter())) {
continue;
}
hasMatchingChildren = true;
}
if (!regex) {
if (this.expandedDueToFilter) {
this.collapse();
}
this.expandedDueToFilter = false;
} else if (hasMatchingChildren && !this.expanded) {
this.expand();
this.expandedDueToFilter = true;
} else if (!hasMatchingChildren && this.expanded && this.expandedDueToFilter) {
this.collapse();
this.expandedDueToFilter = false;
}
return matches;
}
private processColor(text: string, valueChild?: Node|null): Node {
const useUserSettingFormat = this.editable();
const shiftClickMessage = i18nString(UIStrings.shiftClickToChangeColorFormat);
const tooltip =
this.editable() ? i18nString(UIStrings.openColorPickerS, {PH1: shiftClickMessage}) : shiftClickMessage;
const swatch = new InlineEditor.ColorSwatch.ColorSwatch();
swatch.renderColor(text, useUserSettingFormat, tooltip);
if (!valueChild) {
valueChild = swatch.createChild('span');
const color = swatch.getColor();
valueChild.textContent = color ? color.asString(swatch.getFormat()) : text;
}
swatch.appendChild(valueChild);
const onFormatchanged = (event: InlineEditor.ColorSwatch.FormatChangedEvent): void => {
const {data} = event;
swatch.firstElementChild && swatch.firstElementChild.remove();
swatch.createChild('span').textContent = data.text;
};
swatch.addEventListener(InlineEditor.ColorSwatch.FormatChangedEvent.eventName, onFormatchanged);
if (this.editable()) {
void this.addColorContrastInfo(swatch);
}
return swatch;
}
private processVar(text: string): Node {
const computedSingleValue = this.matchedStylesInternal.computeSingleVariableValue(this.style, text);
if (!computedSingleValue) {
return document.createTextNode(text);
}
const {computedValue, fromFallback} = computedSingleValue;
const varSwatch = new InlineEditor.CSSVarSwatch.CSSVarSwatch();
UI.UIUtils.createTextChild(varSwatch, text);
varSwatch.data = {text, computedValue, fromFallback, onLinkActivate: this.handleVarDefinitionActivate.bind(this)};
if (!computedValue || !Common.Color.Color.parse(computedValue)) {
return varSwatch;
}
return this.processColor(computedValue, varSwatch);
}
private handleVarDefinitionActivate(variableName: string): void {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.CustomPropertyLinkClicked);
this.parentPaneInternal.jumpToProperty(variableName);
}
private async addColorContrastInfo(swatch: InlineEditor.ColorSwatch.ColorSwatch): Promise<void> {
const swatchPopoverHelper = this.parentPaneInternal.swatchPopoverHelper();
const swatchIcon = new ColorSwatchPopoverIcon(this, swatchPopoverHelper, swatch);
if (this.property.name !== 'color' || !this.parentPaneInternal.cssModel() || !this.node()) {
return;
}
const cssModel = this.parentPaneInternal.cssModel();
const node = this.node();
if (cssModel && node && typeof node.id !== 'undefined') {
const contrastInfo = new ColorPicker.ContrastInfo.ContrastInfo(await cssModel.getBackgroundColors(node.id));
swatchIcon.setContrastInfo(contrastInfo);
}
}
renderedPropertyText(): string {
if (!this.nameElement || !this.valueElement) {
return '';
}
return this.nameElement.textContent + ': ' + this.valueElement.textContent;
}
private processBezier(text: string): Node {
if (!this.editable() || !UI.Geometry.CubicBezier.parse(text)) {
return document.createTextNode(text);
}
const swatchPopoverHelper = this.parentPaneInternal.swatchPopoverHelper();
const swatch = InlineEditor.Swatches.BezierSwatch.create();
swatch.setBezierText(text);
new BezierPopoverIcon(this, swatchPopoverHelper, swatch);
return swatch;
}
private processFont(text: string): Node {
const section = this.section();
if (section) {
section.registerFontProperty(this);
}
return document.createTextNode(text);
}
private processShadow(propertyValue: string, propertyName: string): Node {
if (!this.editable()) {
return document.createTextNode(propertyValue);
}
let shadows;
if (propertyName === 'text-shadow') {
shadows = InlineEditor.CSSShadowModel.CSSShadowModel.parseTextShadow(propertyValue);
} else {
shadows = InlineEditor.CSSShadowModel.CSSShadowModel.parseBoxShadow(propertyValue);
}
if (!shadows.length) {
return document.createTextNode(propertyValue);
}
const container = document.createDocumentFragment();
const swatchPopoverHelper = this.parentPaneInternal.swatchPopoverHelper();
for (let i = 0; i < shadows.length; i++) {
if (i !== 0) {
container.appendChild(document.createTextNode(', '));
} // Add back commas and spaces between each shadow.
// TODO(flandy): editing the property value should use the original value with all spaces.
const cssShadowSwatch = InlineEditor.Swatches.CSSShadowSwatch.create();
cssShadowSwatch.setCSSShadow(shadows[i]);
new ShadowSwatchPopoverHelper(this, swatchPopoverHelper, cssShadowSwatch);
const colorSwatch = cssShadowSwatch.colorSwatch();
if (colorSwatch) {
new ColorSwatchPopoverIcon(this, swatchPopoverHelper, colorSwatch);
}
container.appendChild(cssShadowSwatch);
}
return container;
}
private processGrid(propertyValue: string, _propertyName: string): Node {
const splitResult =
TextUtils.TextUtils.Utils.splitStringByRegexes(propertyValue, [SDK.CSSMetadata.GridAreaRowRegex]);
if (splitResult.length <= 1) {
return document.createTextNode(propertyValue);
}
const indent = Common.Settings.Settings.instance().moduleSetting('textEditorIndent').get();
const container = document.createDocumentFragment();
for (const result of splitResult) {
const value = result.value.trim();
const content = UI.Fragment.html`<br /><span class='styles-clipboard-only'>${indent.repeat(2)}</span>${value}`;
container.appendChild(content);
}
return container;
}
private processAngle(angleText: string): Text|InlineEditor.CSSAngle.CSSAngle {
if (!this.editable()) {
return document.createTextNode(angleText);
}
const cssAngle = new InlineEditor.CSSAngle.CSSAngle();
const valueElement = document.createElement('span');
valueElement.textContent = angleText;
const computedPropertyValue =
this.matchedStylesInternal.computeValue(this.property.ownerStyle, this.property.value) || '';
cssAngle.data = {
propertyName: this.property.name,
propertyValue: computedPropertyValue,
angleText,
containingPane:
(this.parentPaneInternal.element.enclosingNodeOrSelfWithClass('style-panes-wrapper') as HTMLElement),
};
cssAngle.append(valueElement);
const popoverToggled = (event: Event): void => {
const section = this.section();
if (!section) {
return;
}
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const {data} = (event as any);
if (data.open) {
this.parentPaneInternal.hideAllPopovers();
this.parentPaneInternal.activeCSSAngle = cssAngle;
}
section.element.classList.toggle('has-open-popover', data.open);
this.parentPaneInternal.setEditingStyle(data.open);
};
const valueChanged = async(event: Event): Promise<void> => {
const {data} = (event as InlineEditor.InlineEditorUtils.ValueChangedEvent);
valueElement.textContent = data.value;
await this.applyStyleText(this.renderedPropertyText(), false);
const computedPropertyValue =
this.matchedStylesInternal.computeValue(this.property.ownerStyle, this.property.value) || '';
cssAngle.updateProperty(this.property.name, computedPropertyValue);
};
const unitChanged = async(event: Event): Promise<void> => {
const {data} = (event as InlineEditor.CSSAngle.UnitChangedEvent);
valueElement.textContent = data.value;
};
cssAngle.addEventListener('popovertoggled', popoverToggled);
cssAngle.addEventListener('valuechanged', valueChanged);
cssAngle.addEventListener('unitchanged', unitChanged);
return cssAngle;
}
private processLength(lengthText: string): Text|InlineEditor.CSSLength.CSSLength {
if (!this.editable()) {
return document.createTextNode(lengthText);
}
const cssLength = new InlineEditor.CSSLength.CSSLength();
const valueElement = document.createElement('span');
valueElement.textContent = lengthText;
cssLength.data = {
lengthText,
};
cssLength.append(valueElement);
const onValueChanged = (event: Event): void => {
const {data} = (event as InlineEditor.InlineEditorUtils.ValueChangedEvent);
valueElement.textContent = data.value;
this.parentPaneInternal.setEditingStyle(true);
void this.applyStyleText(this.renderedPropertyText(), false);
};
const onDraggingFinished = (): void => {
this.parentPaneInternal.setEditingStyle(false);
};
cssLength.addEventListener('valuechanged', onValueChanged);
cssLength.addEventListener('draggingfinished', onDraggingFinished);
return cssLength;
}
private updateState(): void {
if (!this.listItemElement) {
return;
}
if (this.style.isPropertyImplicit(this.name)) {
this.listItemElement.classList.add('implicit');
} else {
this.listItemElement.classList.remove('implicit');
}
const hasIgnorableError = !this.property.parsedOk && StylesSidebarPane.ignoreErrorsForProperty(this.property);
if (hasIgnorableError) {
this.listItemElement.classList.add('has-ignorable-error');
} else {
this.listItemElement.classList.remove('has-ignorable-error');
}
if (this.inherited()) {
this.listItemElement.classList.add('inherited');
} else {
this.listItemElement.classList.remove('inherited');
}
if (this.overloaded()) {
this.listItemElement.classList.add('overloaded');
} else {
this.listItemElement.classList.remove('overloaded');
}
if (this.property.disabled) {
this.listItemElement.classList.add('disabled');
} else {
this.listItemElement.classList.remove('disabled');
}
this.listItemElement.classList.toggle('changed', this.isPropertyChanged(this.property));
}
node(): SDK.DOMModel.DOMNode|null {
return this.parentPaneInternal.node();
}
parentPane(): StylesSidebarPane {
return this.parentPaneInternal;
}
section(): StylePropertiesSection|null {
if (!this.treeOutline) {
return null;
}
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (this.treeOutline as any).section;
}
private updatePane(): void {
const section = this.section();
if (section) {
section.refreshUpdate(this);
}
}
private async toggleDisabled(disabled: boolean): Promise<void> {
const oldStyleRange = this.style.range;
if (!oldStyleRange) {
return;
}
this.parentPaneInternal.setUserOperation(true);
const success = await this.property.setDisabled(disabled);
this.parentPaneInternal.setUserOperation(false);
if (!success) {
return;
}
this.matchedStylesInternal.resetActiveProperties();
this.updatePane();
this.styleTextAppliedForTest();
}
private isPropertyChanged(property: SDK.CSSProperty.CSSProperty): boolean {
if (!Root.Runtime.experiments.isEnabled(Root.Runtime.ExperimentName.STYLES_PANE_CSS_CHANGES)) {
return false;
}
// Check local cache first, then check against diffs from the workspace.
return this.#propertyTextFromSource !== property.propertyText || this.parentPane().isPropertyChanged(property);
}
async onpopulate(): Promise<void> {
// Only populate once and if this property is a shorthand.
if (this.childCount() || !this.isShorthand) {
return;
}
const longhandProperties = this.style.longhandProperties(this.name);
const leadingProperties = this.style.leadingProperties();
for (let i = 0; i < longhandProperties.length; ++i) {
const name = longhandProperties[i].name;
let inherited = false;
let overloaded = false;
const section = this.section();
if (section) {
inherited = section.isPropertyInherited(name);
overloaded = this.matchedStylesInternal.propertyState(longhandProperties[i]) ===
SDK.CSSMatchedStyles.PropertyState.Overloaded;
}
const leadingProperty = leadingProperties.find(property => property.name === name && property.activeInStyle());
if (leadingProperty) {
overloaded = true;
}
const item = new StylePropertyTreeElement(
this.parentPaneInternal, this.matchedStylesInternal, longhandProperties[i], false, inherited, overloaded,
false);
item.setComputedStyles(this.computedStyles);
item.setParentsComputedStyles(this.parentsComputedStyles);
this.appendChild(item);
}
}
onattach(): void {
this.updateTitle();
this.listItemElement.addEventListener('mousedown', event => {
if (event.button === 0) {
parentMap.set(this.parentPaneInternal, this);
}
}, false);
this.listItemElement.addEventListener('mouseup', this.mouseUp.bind(this));
this.listItemElement.addEventListener('click', event => {
if (!event.target) {
return;
}
const node = (event.target as HTMLElement);
if (!node.hasSelection() && event.target !== this.listItemElement) {
event.consume(true);
}
});
// Copy context menu.
this.listItemElement.addEventListener('contextmenu', this.handleCopyContextMenuEvent.bind(this));
}
onexpand(): void {
this.updateExpandElement();
}
oncollapse(): void {
this.updateExpandElement();
}
private updateExpandElement(): void {
if (!this.expandElement) {
return;
}
if (this.expanded) {
this.expandElement.setIconType('smallicon-triangle-down');
} else {
this.expandElement.setIconType('smallicon-triangle-right');
}
}
updateTitleIfComputedValueChanged(): void {
const computedValue = this.matchedStylesInternal.computeValue(this.property.ownerStyle, this.property.value);
if (computedValue === this.lastComputedValue) {
return;
}
this.lastComputedValue = computedValue;
this.innerUpdateTitle();
}
updateTitle(): void {
this.lastComputedValue = this.matchedStylesInternal.computeValue(this.property.ownerStyle, this.property.value);
this.innerUpdateTitle();
}
private innerUpdateTitle(): void {
this.updateState();
if (this.isExpandable()) {
this.expandElement = UI.Icon.Icon.create('smallicon-triangle-right', 'expand-icon');
} else {
this.expandElement = null;
}
const propertyRenderer =
new StylesSidebarPropertyRenderer(this.style.parentRule, this.node(), this.name, this.value);
if (this.property.parsedOk) {
propertyRenderer.setVarHandler(this.processVar.bind(this));
propertyRenderer.setColorHandler(this.processColor.bind(this));
propertyRenderer.setBezierHandler(this.processBezier.bind(this));
propertyRenderer.setFontHandler(this.processFont.bind(this));
propertyRenderer.setShadowHandler(this.processShadow.bind(this));
propertyRenderer.setGridHandler(this.processGrid.bind(this));
propertyRenderer.setAngleHandler(this.processAngle.bind(this));
propertyRenderer.setLengthHandler(this.processLength.bind(this));
}
this.listItemElement.removeChildren();
this.nameElement = (propertyRenderer.renderName() as HTMLElement);
if (this.property.name.startsWith('--') && this.nameElement) {
UI.Tooltip.Tooltip.install(
this.nameElement, this.matchedStylesInternal.computeCSSVariable(this.style, this.property.name) || '');
}
this.valueElement = (propertyRenderer.renderValue() as HTMLElement);
if (!this.treeOutline) {
return;
}
const indent = Common.Settings.Settings.instance().moduleSetting('textEditorIndent').get();
UI.UIUtils.createTextChild(
this.listItemElement.createChild('span', 'styles-clipboard-only'),
indent + (this.property.disabled ? '/* ' : ''));
if (this.nameElement) {
this.listItemElement.appendChild(this.nameElement);
}
if (this.valueElement) {
const lineBreakValue =
this.valueElement.firstElementChild && this.valueElement.firstElementChild.tagName === 'BR';
const separator = lineBreakValue ? ':' : ': ';
this.listItemElement.createChild('span', 'styles-name-value-separator').textContent = separator;
if (this.expandElement) {
this.listItemElement.appendChild(this.expandElement);
}
this.listItemElement.appendChild(this.valueElement);
UI.UIUtils.createTextChild(this.listItemElement, ';');
if (this.property.disabled) {
UI.UIUtils.createTextChild(this.listItemElement.createChild('span', 'styles-clipboard-only'), ' */');
}
}
const section = this.section();
if (this.valueElement && section && section.editable && this.property.name === 'display') {
const propertyValue = this.property.trimmedValueWithoutImportant();
const isFlex = propertyValue === 'flex' || propertyValue === 'inline-flex';
const isGrid = propertyValue === 'grid' || propertyValue === 'inline-grid';
if (isFlex || isGrid) {
const key = `${section.getSectionIdx()}_${section.nextEditorTriggerButtonIdx}`;
const button = StyleEditorWidget.createTriggerButton(
this.parentPaneInternal, section, isFlex ? FlexboxEditor : GridEditor,
isFlex ? i18nString(UIStrings.flexboxEditorButton) : i18nString(UIStrings.gridEditorButton), key);
section.nextEditorTriggerButtonIdx++;
this.listItemElement.appendChild(button);
const helper = this.parentPaneInternal.swatchPopoverHelper();
if (helper.isShowing(StyleEditorWidget.instance()) && StyleEditorWidget.instance().getTriggerKey() === key) {
helper.setAnchorElement(button);
}
}
}
const authoringHint = this.getAuthoringHint(this.computedStyles, this.parentsComputedStyles);
if (authoringHint !== null) {
const hintIcon = UI.Icon.Icon.create('mediumicon-info', 'hint');
const hintPopover =
new UI.PopoverHelper.PopoverHelper(hintIcon, event => this.handleHintPopoverRequest(authoringHint, event));
hintPopover.setHasPadding(true);
hintPopover.setTimeout(0, 100);
this.listItemElement.append(hintIcon);
}
if (!this.property.parsedOk) {
// Avoid having longhands under an invalid shorthand.
this.listItemElement.classList.add('not-parsed-ok');
// Add a separate exclamation mark IMG element with a tooltip.
this.listItemElement.insertBefore(
StylesSidebarPane.createExclamationMark(this.property, null), this.listItemElement.firstChild);
} else {
void this.updateFontVariationSettingsWarning();
}
if (!this.property.activeInStyle()) {
this.listItemElement.classList.add('inactive');
}
this.updateFilter();
if (this.property.parsedOk && this.section() && this.parent && this.parent.root) {
const enabledCheckboxElement = document.createElement('input');
enabledCheckboxElement.className = 'enabled-button';
enabledCheckboxElement.type = 'checkbox';
enabledCheckboxElement.checked = !this.property.disabled;
enabledCheckboxElement.addEventListener('mousedown', event => event.consume(), false);
enabledCheckboxElement.addEventListener('click', event => {
void this.toggleDisabled(!this.property.disabled);
event.consume();
}, false);
if (this.nameElement && this.valueElement) {
UI.ARIAUtils.setAccessibleName(
enabledCheckboxElement, `${this.nameElement.textContent} ${this.valueElement.textContent}`);
}
const copyIcon = UI.Icon.Icon.create('largeicon-copy', 'copy');
UI.Tooltip.Tooltip.install(copyIcon, i18nString(UIStrings.copyDeclaration));
copyIcon.addEventListener('click', () => {
const propertyText = `${this.property.name}: ${this.property.value};`;
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(propertyText);
Host.userMetrics.styleTextCopied(Host.UserMetrics.StyleTextCopied.DeclarationViaChangedLine);
});
this.listItemElement.append(copyIcon);
this.listItemElement.insertBefore(enabledCheckboxElement, this.listItemElement.firstChild);
}
}
private async updateFontVariationSettingsWarning(): Promise<void> {
if (this.property.name !== 'font-variation-settings') {
return;
}
const value = this.property.value;
const cssModel = this.parentPaneInternal.cssModel();
if (!cssModel) {
return;
}
const computedStyleModel = this.parentPaneInternal.computedStyleModel();
const styles = await computedStyleModel.fetchComputedStyle();
if (!styles) {
return;
}
const fontFamily = styles.computedStyle.get('font-family');
if (!fontFamily) {
return;
}
const fontFamilies = new Set<string>(SDK.CSSPropertyParser.parseFontFamily(fontFamily));
const matchingFontFaces = cssModel.fontFaces().filter(f => fontFamilies.has(f.getFontFamily()));
const variationSettings = SDK.CSSPropertyParser.parseFontVariationSettings(value);
const warnings = [];
for (const elementSetting of variationSettings) {
for (const font of matchingFontFaces) {
const fontSetting = font.getVariationAxisByTag(elementSetting.tag);
if (!fontSetting) {
continue;
}
if (elementSetting.value < fontSetting.minValue || elementSetting.value > fontSetting.maxValue) {
warnings.push(i18nString(UIStrings.valueForSettingSSIsOutsideThe, {
PH1: elementSetting.tag,
PH2: elementSetting.value,
PH3: fontSetting.minValue,
PH4: fontSetting.maxValue,
PH5: font.getFontFamily(),
}));
}
}
}
if (!warnings.length) {
return;
}
this.listItemElement.classList.add('has-warning');
this.listItemElement.insertBefore(
StylesSidebarPane.createExclamationMark(this.property, warnings.join(' ')), this.listItemElement.firstChild);
}
private getAuthoringHint(computedStyles: Map<string, string>|null, parentComputedStyles: Map<string, string>|null):
AuthoringHint|null {
const propertyName = this.property.name;
if (!Root.Runtime.experiments.isEnabled(Root.Runtime.ExperimentName.CSS_AUTHORING_HINTS) ||
!cssRuleValidatorsMap.has(propertyName)) {
return null;
}
for (const validator of cssRuleValidatorsMap.get(propertyName) || []) {
if (!validator.isRuleValid(computedStyles, parentComputedStyles)) {
return validator.getAuthoringHint(propertyName, this.parentsComputedStyles);
}
}
return null;
}
private handleHintPopoverRequest(authoringHint: AuthoringHint, event: Event): UI.PopoverHelper.PopoverRequest|null {
const link = event.composedPath()[0];
Platform.DCHECK(() => link instanceof Element, 'Link is not an instance of Element');
return {
box: (link as Element).boxInWindow(),
show: async(popover: UI.GlassPane.GlassPane): Promise<boolean> => {
const node = this.node();
if (!node) {
return false;
}
const popupElement = new ElementsComponents.CSSHintDetailsView.CSSHintDetailsView(authoringHint);
popover.contentElement.insertAdjacentElement('beforeend', popupElement);
return true;
},
};
}
private mouseUp(event: MouseEvent): void {
const activeTreeElement = parentMap.get(this.parentPaneInternal);
parentMap.delete(this.parentPaneInternal);
if (!activeTreeElement) {
return;
}
if (this.listItemElement.hasSelection()) {
return;
}
if (UI.UIUtils.isBeingEdited((event.target as Node))) {
return;
}
event.consume(true);
if (event.target === this.listItemElement) {
return;
}
const section = this.section();
if (UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event) && section && section.navigable) {
this.navigateToSource((event.target as Element));
return;
}
this.startEditing((event.target as Element));
}
private handleContextMenuEvent(context: Context, event: Event): void {
const contextMenu = new UI.ContextMenu.ContextMenu(event);
if (this.property.parsedOk && this.section() && this.parent && this.parent.root) {
const sectionIndex = this.parentPaneInternal.focusedSectionIndex();
contextMenu.defaultSection().appendCheckboxItem(
i18nString(UIStrings.togglePropertyAndContinueEditing), async () => {
if (this.treeOutline) {
const propertyIndex = this.treeOutline.rootElement().indexOfChild(this);
// order matters here: this.editingCancelled may invalidate this.treeOutline.
this.editingCancelled(null, context);
await this.toggleDisabled(!this.property.disabled);
event.consume();
this.parentPaneInternal.continueEditingElement(sectionIndex, propertyIndex);
}
}, !this.property.disabled);
}
const revealCallback = this.navigateToSource.bind(this) as () => void;
contextMenu.defaultSection().appendItem(i18nString(UIStrings.revealInSourcesPanel), revealCallback);
void contextMenu.show();
}
private handleCopyContextMenuEvent(event: Event): void {
const target = (event.target as Element | null);
if (!target) {
return;
}
const contextMenu = this.createCopyContextMenu(event);
void contextMenu.show();
}
createCopyContextMenu(event: Event): UI.ContextMenu.ContextMenu {
const contextMenu = new UI.ContextMenu.ContextMenu(event);
contextMenu.headerSection().appendItem(i18nString(UIStrings.copyDeclaration), () => {
const propertyText = `${this.property.name}: ${this.property.value};`;
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(propertyText);
Host.userMetrics.styleTextCopied(Host.UserMetrics.StyleTextCopied.DeclarationViaContextMenu);
});
contextMenu.headerSection().appendItem(i18nString(UIStrings.copyProperty), () => {
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(this.property.name);
Host.userMetrics.styleTextCopied(Host.UserMetrics.StyleTextCopied.PropertyViaContextMenu);
});
contextMenu.headerSection().appendItem(i18nString(UIStrings.copyValue), () => {
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(this.property.value);
Host.userMetrics.styleTextCopied(Host.UserMetrics.StyleTextCopied.ValueViaContextMenu);
});
contextMenu.headerSection().appendItem(i18nString(UIStrings.copyRule), () => {
const section = (this.section() as StylePropertiesSection);
const ruleText = StylesSidebarPane.formatLeadingProperties(section).ruleText;
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(ruleText);
Host.userMetrics.styleTextCopied(Host.UserMetrics.StyleTextCopied.RuleViaContextMenu);
});
contextMenu.headerSection().appendItem(
i18nString(UIStrings.copyCssDeclarationAsJs), this.copyCssDeclarationAsJs.bind(this));
contextMenu.clipboardSection().appendItem(i18nString(UIStrings.copyAllDeclarations), () => {
const section = (this.section() as StylePropertiesSection);
const allDeclarationText = StylesSidebarPane.formatLeadingProperties(section).allDeclarationText;
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(allDeclarationText);
Host.userMetrics.styleTextCopied(Host.UserMetrics.StyleTextCopied.AllDeclarationsViaContextMenu);
});
contextMenu.clipboardSection().appendItem(
i18nString(UIStrings.copyAllCssDeclarationsAsJs), this.copyAllCssDeclarationAsJs.bind(this));
// TODO(changhaohan): conditionally add this item only when there are changes to copy
contextMenu.defaultSection().appendItem(i18nString(UIStrings.copyAllCSSChanges), async () => {
const allChanges = await this.parentPane().getFormattedChanges();
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(allChanges);
Host.userMetrics.styleTextCopied(Host.UserMetrics.StyleTextCopied.AllChangesViaStylesPane);
});
contextMenu.footerSection().appendItem(i18nString(UIStrings.viewComputedValue), () => {
void this.viewComputedValue();
});
return contextMenu;
}
private async viewComputedValue(): Promise<void> {
const computedStyleWidget = ElementsPanel.instance().getComputedStyleWidget();
if (!computedStyleWidget.isShowing()) {
await UI.ViewManager.ViewManager.instance().showView('Computed');
}
let propertyNamePattern = '';
if (this.isShorthand) {
propertyNamePattern = '^' + this.property.name + '-';
} else {
propertyNamePattern = '^' + this.property.name + '$';
}
const regex = new RegExp(propertyNamePattern, 'i');
await computedStyleWidget.filterComputedStyles(regex);
const filterInput = (computedStyleWidget.input as HTMLInputElement);
filterInput.value = this.property.name;
filterInput.focus();
}
private copyCssDeclarationAsJs(): void {
const cssDeclarationValue = getCssDeclarationAsJavascriptProperty(this.property);
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(cssDeclarationValue);
Host.userMetrics.styleTextCopied(Host.UserMetrics.StyleTextCopied.DeclarationAsJSViaContextMenu);
}
private copyAllCssDeclarationAsJs(): void {
const section = this.section() as StylePropertiesSection;
const leadingProperties = (section.style()).leadingProperties();
const cssDeclarationsAsJsProperties =
leadingProperties.filter(property => !property.disabled).map(getCssDeclarationAsJavascriptProperty);
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(cssDeclarationsAsJsProperties.join(',\n'));
Host.userMetrics.styleTextCopied(Host.UserMetrics.StyleTextCopied.AllDeclarationsAsJSViaContextMenu);
}
private navigateToSource(element: Element, omitFocus?: boolean): void {
const section = this.section();
if (!section || !section.navigable) {
return;
}
const propertyNameClicked = element === this.nameElement;
const uiLocation = Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance().propertyUILocation(
this.property, propertyNameClicked);
if (uiLocation) {
void Common.Revealer.reveal(uiLocation, omitFocus);
}
}
startEditing(selectElement?: Element|null): void {
// FIXME: we don't allow editing of longhand properties under a shorthand right now.
if (this.parent instanceof StylePropertyTreeElement && this.parent.isShorthand) {
return;
}
if (this.expandElement && selectElement === this.expandElement) {
return;
}
const section = this.section();
if (section && !section.editable) {
return;
}
if (selectElement) {
selectElement = selectElement.enclosingNodeOrSelfWithClass('webkit-css-property') ||
selectElement.enclosingNodeOrSelfWithClass('value');
}
if (!selectElement) {
selectElement = this.nameElement;
}
if (UI.UIUtils.isBeingEdited(selectElement)) {
return;
}
const isEditingName = selectElement === this.nameElement;
if (!isEditingName && this.valueElement) {
if (SDK.CSSMetadata.cssMetadata().isGridAreaDefiningProperty(this.name)) {
this.valueElement.textContent = restoreGridIndents(this.value);
}
this.valueElement.textContent = restoreURLs(this.valueElement.textContent || '', this.value);
}
function restoreGridIndents(value: string): string {
const splitResult = TextUtils.TextUtils.Utils.splitStringByRegexes(value, [SDK.CSSMetadata.GridAreaRowRegex]);
return splitResult.map(result => result.value.trim()).join('\n');
}
function restoreURLs(fieldValue: string, modelValue: string): string {
const splitFieldValue = fieldValue.split(SDK.CSSMetadata.URLRegex);
if (splitFieldValue.length === 1) {
return fieldValue;
}
const modelUrlRegex = new RegExp(SDK.CSSMetadata.URLRegex);
for (let i = 1; i < splitFieldValue.length; i += 2) {
const match = modelUrlRegex.exec(modelValue);
if (match) {
splitFieldValue[i] = match[0];
}
}
return splitFieldValue.join('');
}
const previousContent = selectElement ? (selectElement.textContent || '') : '';
const context: Context = {
expanded: this.expanded,
hasChildren: this.isExpandable(),
isEditingName: isEditingName,
originalProperty: this.property,
previousContent: previousContent,
originalName: undefined,
originalValue: undefined,
};
this.contextForTest = context;
// Lie about our children to prevent expanding on double click and to collapse shorthands.
this.setExpandable(false);
if (selectElement) {
if (selectElement.parentElement) {
selectElement.parentElement.classList.add('child-editing');
}
selectElement.textContent = selectElement.textContent; // remove color swatch and the like
}
function pasteHandler(this: StylePropertyTreeElement, context: Context, event: Event): void {
const clipboardEvent = (event as ClipboardEvent);
const clipboardData = clipboardEvent.clipboardData;
if (!clipboardData) {
return;
}
const data = clipboardData.getData('Text');
if (!data) {
return;
}
const colonIdx = data.indexOf(':');
if (colonIdx < 0) {
return;
}
const name = data.substring(0, colonIdx).trim();
const value = data.substring(colonIdx + 1).trim();
event.preventDefault();
if (typeof context.originalName === 'undefined') {
if (this.nameElement) {
context.originalName = this.nameElement.textContent || '';
}
if (this.valueElement) {
context.originalValue = this.valueElement.textContent || '';
}
}
this.property.name = name;
this.property.value = value;
if (this.nameElement) {
this.nameElement.textContent = name;
this.nameElement.normalize();
}
if (this.valueElement) {
this.valueElement.textContent = value;
this.valueElement.normalize();
}
const target = (event.target as HTMLElement);
void this.editingCommitted(target.textContent || '', context, 'forward');
}
function blurListener(this: StylePropertyTreeElement, context: Context, event: Event): void {
const target = (event.target as HTMLElement);
let text: (string|null) = target.textContent;
if (!context.isEditingName) {
text = this.value || text;
}
void this.editingCommitted(text || '', context, '');
}
this.originalPropertyText = this.property.propertyText || '';
this.parentPaneInternal.setEditingStyle(true, this);
if (selectElement && selectElement.parentElement) {
selectElement.parentElement.scrollIntoViewIfNeeded(false);
}
this.prompt = new CSSPropertyPrompt(this, isEditingName);
this.prompt.setAutocompletionTimeout(0);
this.prompt.addEventListener(UI.TextPrompt.Events.TextChanged, _event => {
void this.applyFreeFlowStyleTextEdit(context);
});
const invalidString = this.property.getInvalidStringForInvalidProperty();
if (invalidString && selectElement) {
UI.ARIAUtils.alert(invalidString);
}
if (selectElement) {
const proxyElement = this.prompt.attachAndStartEditing(selectElement, blurListener.bind(this, context));
this.navigateToSource(selectElement, true);
proxyElement.addEventListener('keydown', this.editingNameValueKeyDown.bind(this, context), false);
proxyElement.addEventListener('keypress', this.editingNameValueKeyPress.bind(this, context), false);
if (isEditingName) {
proxyElement.addEventListener('paste', pasteHandler.bind(this, context), false);
proxyElement.addEventListener('contextmenu', this.handleContextMenuEvent.bind(this, context), false);
}
const componentSelection = selectElement.getComponentSelection();
if (componentSelection) {
componentSelection.selectAllChildren(selectElement);
}
}
}
private editingNameValueKeyDown(context: Context, event: Event): void {
if (event.handled) {
return;
}
const keyboardEvent = (event as KeyboardEvent);
const target = (keyboardEvent.target as HTMLElement);
let result;
if (keyboardEvent.key === 'Enter' && !keyboardEvent.shiftKey) {
result = 'forward';
} else if (
keyboardEvent.keyCode === UI.KeyboardShortcut.Keys.Esc.code ||
keyboardEvent.key === Platform.KeyboardUtilities.ESCAPE_KEY) {
result = 'cancel';
} else if (
!context.isEditingName && this.newProperty &&
keyboardEvent.keyCode === UI.KeyboardShortcut.Keys.Backspace.code) {
// For a new property, when Backspace is pressed at the beginning of new property value, move back to the property name.
const selection = target.getComponentSelection();
if (selection && selection.isCollapsed && !selection.focusOffset) {
event.preventDefault();
result = 'backward';
}
} else if (keyboardEvent.key === 'Tab') {
result = keyboardEvent.shiftKey ? 'backward' : 'forward';
event.preventDefault();
}
if (result) {
switch (result) {
case 'cancel':
this.editingCancelled(null, context);
break;
case 'forward':
case 'backward':
void this.editingCommitted(target.textContent || '', context, result);
break;
}
event.consume();
return;
}
}
private editingNameValueKeyPress(context: Context, event: Event): void {
function shouldCommitValueSemicolon(text: string, cursorPosition: number): boolean {
// FIXME: should this account for semicolons inside comments?
let openQuote = '';
for (let i = 0; i < cursorPosition; ++i) {
const ch = text[i];
if (ch === '\\' && openQuote !== '') {
++i;
} // skip next character inside string
else if (!openQuote && (ch === '"' || ch === '\'')) {
openQuote = ch;
} else if (openQuote === ch) {
openQuote = '';
}
}
return !openQuote;
}
const keyboardEvent = (event as KeyboardEvent);
const target = (keyboardEvent.target as HTMLElement);
const keyChar = String.fromCharCode(keyboardEvent.charCode);
const selectionLeftOffset = target.selectionLeftOffset();
const isFieldInputTerminated =
(context.isEditingName ? keyChar === ':' :
keyChar === ';' && selectionLeftOffset !== null &&
shouldCommitValueSemicolon(target.textContent || '', selectionLeftOffset));
if (isFieldInputTerminated) {
// Enter or colon (for name)/semicolon outside of string (for value).
event.consume(true);
void this.editingCommitted(target.textContent || '', context, 'forward');
return;
}
}
private async applyFreeFlowStyleTextEdit(context: Context): Promise<void> {
if (!this.prompt || !this.parentPaneInternal.node()) {
return;
}
const enteredText = this.prompt.text();
if (context.isEditingName && enteredText.includes(':')) {
void this.editingCommitted(enteredText, context, 'forward');
return;
}
const valueText = this.prompt.textWithCurrentSuggestion();
if (valueText.includes(';')) {
return;
}
// Prevent destructive side-effects during live-edit. crbug.com/433889
const parentNode = this.parentPaneInternal.node();
if (parentNode) {
const isPseudo = Boolean(parentNode.pseudoType());
if (isPseudo) {
if (this.name.toLowerCase() === 'content') {
return;
}
const lowerValueText = valueText.trim().toLowerCase();
if (lowerValueText.startsWith('content:') || lowerValueText === 'display: none') {
return;
}
}
}
if (context.isEditingName) {
if (valueText.includes(':')) {
await this.applyStyleText(valueText, false);
} else if (this.hasBeenEditedIncrementally) {
await this.applyOriginalStyle(context);
}
} else {
if (this.nameElement) {
await this.applyStyleText(`${this.nameElement.textContent}: ${valueText}`, false);
}
}
}
kickFreeFlowStyleEditForTest(): Promise<void> {
const context = this.contextForTest;
return this.applyFreeFlowStyleTextEdit((context as Context));
}
editingEnded(context: Context): void {
this.setExpandable(context.hasChildren);
if (context.expanded) {
this.expand();
}
const editedElement = context.isEditingName ? this.nameElement : this.valueElement;
// The proxyElement has been deleted, no need to remove listener.
if (editedElement && editedElement.parentElement) {
editedElement.parentElement.classList.remove('child-editing');
}
th