blob: 8b93b98c6a75e77a30ee7226b52ccd1cb1408324 [file] [log] [blame]
// 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 type * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
import * as IconButton from '../../ui/components/icon_button/icon_button.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 Components from '../../ui/legacy/components/utils/utils.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import {
BezierPopoverIcon,
ColorSwatchPopoverIcon,
ColorSwatchPopoverIconEvents,
ShadowSwatchPopoverHelper,
} from './ColorSwatchPopoverIcon.js';
import * as ElementsComponents from './components/components.js';
import {cssRuleValidatorsMap, type Hint} from './CSSRuleValidator.js';
import {ElementsPanel} from './ElementsPanel.js';
import {ImagePreviewPopover} from './ImagePreviewPopover.js';
import {
AngleMatch,
AngleMatcher,
BezierMatch,
BezierMatcher,
type BottomUpTreeMatching,
children,
ColorMatch,
ColorMatcher,
ColorMixMatch,
ColorMixMatcher,
type CSSControlMap,
LinkableNameMatch,
LinkableNameMatcher,
LinkableNameProperties,
Renderer,
type RenderingContext,
URLMatch,
URLMatcher,
VariableMatch,
VariableMatcher,
} from './PropertyParser.js';
import {StyleEditorWidget} from './StyleEditorWidget.js';
import {type StylePropertiesSection} from './StylePropertiesSection.js';
import {getCssDeclarationAsJavascriptProperty} from './StylePropertyUtils.js';
import {
CSSPropertyPrompt,
REGISTERED_PROPERTY_SECTION_NAME,
StylesSidebarPane,
StylesSidebarPropertyRenderer,
} from './StylesSidebarPane.js';
const FlexboxEditor = ElementsComponents.StylePropertyEditor.FlexboxEditor;
const GridEditor = ElementsComponents.StylePropertyEditor.GridEditor;
export const activeHints = new WeakMap<Element, Hint>();
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 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>();
interface StylePropertyTreeElementParams {
stylesPane: StylesSidebarPane;
section: StylePropertiesSection;
matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles;
property: SDK.CSSProperty.CSSProperty;
isShorthand: boolean;
inherited: boolean;
overloaded: boolean;
newProperty: boolean;
}
export class VariableRenderer extends VariableMatch {
readonly #treeElement: StylePropertyTreeElement;
readonly #style: SDK.CSSStyleDeclaration.CSSStyleDeclaration;
constructor(
treeElement: StylePropertyTreeElement, style: SDK.CSSStyleDeclaration.CSSStyleDeclaration, text: string,
name: string, fallback: CodeMirror.SyntaxNode[], matching: BottomUpTreeMatching) {
super(text, name, fallback, matching);
this.#treeElement = treeElement;
this.#style = style;
}
resolveVariable(): SDK.CSSMatchedStyles.CSSVariableValue|null {
return this.#matchedStyles.computeCSSVariable(this.#style, this.name);
}
fallbackValue(): string|null {
if (this.fallback.length === 0 || this.fallback.some(node => this.matching.hasUnresolvedVars(node))) {
return null;
}
return this.fallback.map(node => this.matching.getComputedText(node)).join(' ');
}
computedText(): string|null {
return this.resolveVariable()?.value ?? this.fallbackValue();
}
render(node: CodeMirror.SyntaxNode, context: RenderingContext): Node[] {
const renderedFallback = this.fallback.length > 0 ? Renderer.render(this.fallback, context) : undefined;
const {declaration, value: variableValue} = this.resolveVariable() ?? {};
const fromFallback = !variableValue;
const computedValue = variableValue ?? this.fallbackValue();
const varSwatch = new InlineEditor.LinkSwatch.CSSVarSwatch();
varSwatch.data = {
computedValue,
variableName: this.name,
fromFallback,
fallbackText: this.fallback.map(n => context.ast.text(n)).join(' '),
onLinkActivate: name => this.#handleVarDefinitionActivate(declaration ?? name),
};
if (renderedFallback?.nodes.length) {
// When slotting someting into the fallback slot, also emit text children so that .textContent produces the
// correct var value.
varSwatch.appendChild(document.createTextNode(`var(${this.name}`));
const span = varSwatch.appendChild(document.createElement('span'));
span.appendChild(document.createTextNode(', '));
span.slot = 'fallback';
renderedFallback.nodes.forEach(n => span.appendChild(n));
varSwatch.appendChild(document.createTextNode(')'));
} else {
UI.UIUtils.createTextChild(varSwatch, this.text);
}
if (varSwatch.link) {
this.#pane.addPopover(varSwatch.link, {
contents: () => this.#treeElement.getVariablePopoverContents(this.name, variableValue ?? null),
jslogContext: 'elements.css-var',
});
}
if (!computedValue || !Common.Color.parse(computedValue)) {
return [varSwatch];
}
const colorSwatch = new ColorRenderer(this.#treeElement, computedValue).renderColorSwatch(varSwatch);
context.addControl('color', colorSwatch);
return [colorSwatch];
}
static matcher(treeElement: StylePropertyTreeElement, style: SDK.CSSStyleDeclaration.CSSStyleDeclaration):
VariableMatcher {
return new VariableMatcher(
(text, name, fallback, matching) => new VariableRenderer(treeElement, style, text, name, fallback, matching));
}
get #pane(): StylesSidebarPane {
return this.#treeElement.parentPane();
}
get #matchedStyles(): SDK.CSSMatchedStyles.CSSMatchedStyles {
return this.#treeElement.matchedStyles();
}
#handleVarDefinitionActivate(variable: string|SDK.CSSProperty.CSSProperty|
SDK.CSSMatchedStyles.CSSRegisteredProperty): void {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.CustomPropertyLinkClicked);
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.VarLink);
if (variable instanceof SDK.CSSProperty.CSSProperty) {
this.#pane.revealProperty(variable);
} else if (variable instanceof SDK.CSSMatchedStyles.CSSRegisteredProperty) {
this.#pane.jumpToProperty('initial-value', variable.propertyName(), REGISTERED_PROPERTY_SECTION_NAME);
} else {
this.#pane.jumpToProperty(variable) ||
this.#pane.jumpToProperty('initial-value', variable, REGISTERED_PROPERTY_SECTION_NAME);
}
}
}
export class ColorRenderer extends ColorMatch {
constructor(private readonly treeElement: StylePropertyTreeElement, text: string) {
super(text);
}
static matcher(treeElement: StylePropertyTreeElement): ColorMatcher {
return new ColorMatcher(text => new ColorRenderer(treeElement, text));
}
#getValueChild(node: CodeMirror.SyntaxNode, context: RenderingContext):
{valueChild: HTMLSpanElement, cssControls?: CSSControlMap} {
const valueChild = document.createElement('span');
if (node.name === 'ColorLiteral' || (node.name === 'ValueName' && Common.Color.Nicknames.has(this.text))) {
valueChild.appendChild(document.createTextNode(this.text));
return {valueChild};
}
const {cssControls} = Renderer.renderInto(children(node), context, valueChild);
return {valueChild, cssControls};
}
render(node: CodeMirror.SyntaxNode, context: RenderingContext): Node[] {
const {valueChild, cssControls} = this.#getValueChild(node, context);
const swatch = this.renderColorSwatch(valueChild, context.matchedResult.getComputedText(node));
context.addControl('color', swatch);
if (cssControls && node.name === 'CallExpression' &&
context.ast.text(node.getChild('Callee')).match(/^(hsla?|hwba?)/)) {
const angles = cssControls.get('angle');
if (angles?.length === 1 && angles[0] instanceof InlineEditor.CSSAngle.CSSAngle) {
angles[0].addEventListener(InlineEditor.InlineEditorUtils.ValueChangedEvent.eventName, ev => {
const hue = Common.Color.parseHueNumeric(ev.data.value);
const color = swatch.getColor();
if (!hue || !color) {
return;
}
if (color.is(Common.Color.Format.HSL) || color.is(Common.Color.Format.HSLA)) {
swatch.renderColor(new Common.Color.HSL(hue, color.s, color.l, color.alpha));
} else if (color.is(Common.Color.Format.HWB) || color.is(Common.Color.Format.HWBA)) {
swatch.renderColor(new Common.Color.HWB(hue, color.w, color.b, color.alpha));
}
});
}
}
return [swatch];
}
renderColorSwatch(valueChild?: Node, text?: string): InlineEditor.ColorSwatch.ColorSwatch {
const editable = this.treeElement.editable();
const shiftClickMessage = i18nString(UIStrings.shiftClickToChangeColorFormat);
const tooltip = editable ? i18nString(UIStrings.openColorPickerS, {PH1: shiftClickMessage}) : '';
const swatch = new InlineEditor.ColorSwatch.ColorSwatch();
swatch.setReadonly(!editable);
swatch.renderColor(text ?? this.text, editable, tooltip);
if (!valueChild) {
valueChild = swatch.createChild('span');
const color = swatch.getColor();
valueChild.textContent =
color ? (color.getAuthoredText() ?? color.asString(swatch.getFormat() ?? undefined)) : this.text;
}
swatch.appendChild(valueChild);
const onColorChanged = (event: InlineEditor.ColorSwatch.ColorChangedEvent): void => {
const {data} = event;
swatch.firstElementChild && swatch.firstElementChild.remove();
swatch.createChild('span').textContent = data.text;
void this.treeElement.applyStyleText(this.treeElement.renderedPropertyText(), false);
};
swatch.addEventListener(InlineEditor.ColorSwatch.ClickEvent.eventName, () => {
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.Color);
});
swatch.addEventListener(InlineEditor.ColorSwatch.ColorChangedEvent.eventName, onColorChanged);
if (editable) {
const swatchIcon =
new ColorSwatchPopoverIcon(this.treeElement, this.treeElement.parentPane().swatchPopoverHelper(), swatch);
swatchIcon.addEventListener(ColorSwatchPopoverIconEvents.ColorChanged, ev => {
// TODO(crbug.com/1402233): Is it really okay to dispatch an event from `Swatch` here?
// This needs consideration as current structure feels a bit different:
// There are: ColorSwatch, ColorSwatchPopoverIcon, and Spectrum
// * Our entry into the Spectrum is `ColorSwatch` and `ColorSwatch` is able to
// update the color too. (its format at least, don't know the difference)
// * ColorSwatchPopoverIcon is a helper to show/hide the Spectrum popover
// * Spectrum is the color picker
//
// My idea is: merge `ColorSwatch` and `ColorSwatchPopoverIcon`
// and emit `ColorChanged` event whenever color is changed.
// Until then, this is a hack to kind of emulate the behavior described above
// `swatch` is dispatching its own ColorChangedEvent with the changed
// color text whenever the color changes.
swatch.dispatchEvent(new InlineEditor.ColorSwatch.ColorChangedEvent(ev.data));
});
void this.#addColorContrastInfo(swatchIcon);
}
return swatch;
}
async #addColorContrastInfo(swatchIcon: ColorSwatchPopoverIcon): Promise<void> {
const cssModel = this.treeElement.parentPane().cssModel();
const node = this.treeElement.node();
if (this.treeElement.property.name !== 'color' || !cssModel || !node || typeof node.id === 'undefined') {
return;
}
const contrastInfo = new ColorPicker.ContrastInfo.ContrastInfo(await cssModel.getBackgroundColors(node.id));
swatchIcon.setContrastInfo(contrastInfo);
}
}
export class ColorMixRenderer extends ColorMixMatch {
#pane: StylesSidebarPane;
constructor(
pane: StylesSidebarPane, text: string, space: CodeMirror.SyntaxNode[], color1: CodeMirror.SyntaxNode[],
color2: CodeMirror.SyntaxNode[]) {
super(text, space, color1, color2);
this.#pane = pane;
}
override render(node: CodeMirror.SyntaxNode, context: RenderingContext): Node[] {
const hookUpColorArg = (node: Node, onChange: (newColorText: string) => void): boolean => {
if (node instanceof InlineEditor.ColorMixSwatch.ColorMixSwatch ||
node instanceof InlineEditor.ColorSwatch.ColorSwatch) {
if (node instanceof InlineEditor.ColorSwatch.ColorSwatch) {
node.addEventListener(InlineEditor.ColorSwatch.ColorChangedEvent.eventName, ev => onChange(ev.data.text));
} else {
node.addEventListener(InlineEditor.ColorMixSwatch.Events.ColorChanged, ev => onChange(ev.data.text));
}
const color = node.getText();
if (color) {
onChange(color);
return true;
}
}
return false;
};
const contentChild = document.createElement('span');
contentChild.appendChild(document.createTextNode('color-mix('));
Renderer.renderInto(this.space, context, contentChild);
contentChild.appendChild(document.createTextNode(', '));
const color1 = Renderer.renderInto(this.color1, context, contentChild).cssControls.get('color') ?? [];
contentChild.appendChild(document.createTextNode(', '));
const color2 = Renderer.renderInto(this.color2, context, contentChild).cssControls.get('color') ?? [];
contentChild.appendChild(document.createTextNode(')'));
if (context.matchedResult.hasUnresolvedVars(node) || color1.length !== 1 || color2.length !== 1) {
return [contentChild];
}
const swatch = new InlineEditor.ColorMixSwatch.ColorMixSwatch();
if (!hookUpColorArg(color1[0], text => swatch.setFirstColor(text)) ||
!hookUpColorArg(color2[0], text => swatch.setSecondColor(text))) {
return [contentChild];
}
const space = this.space.map(space => context.matchedResult.getComputedText(space)).join(' ');
const color1Text = this.color1.map(color => context.matchedResult.getComputedText(color)).join(' ');
const color2Text = this.color2.map(color => context.matchedResult.getComputedText(color)).join(' ');
swatch.appendChild(contentChild);
swatch.setColorMixText(`color-mix(${space}, ${color1Text}, ${color2Text})`);
swatch.setRegisterPopoverCallback(swatch => {
if (swatch.icon) {
this.#pane.addPopover(swatch.icon, {
contents: () => {
const color = swatch.mixedColor();
if (!color) {
return undefined;
}
const span = document.createElement('span');
span.style.padding = '11px 7px';
const rgb = color.as(Common.Color.Format.HEX);
const text = rgb.isGamutClipped() ? color.asString() : rgb.asString();
if (!text) {
return undefined;
}
span.appendChild(document.createTextNode(text));
return span;
},
jslogContext: 'elements.css-color-mix',
});
}
});
context.addControl('color', swatch);
return [swatch];
}
static matcher(pane: StylesSidebarPane): ColorMixMatcher {
return new ColorMixMatcher(
(text, space, color1, color2) => new ColorMixRenderer(pane, text, space, color1, color2));
}
}
export class URLRenderer extends URLMatch {
constructor(
private readonly rule: SDK.CSSRule.CSSRule|null, private readonly node: SDK.DOMModel.DOMNode|null,
url: Platform.DevToolsPath.UrlString, text: string) {
super(url, text);
}
override render(): Node[] {
const container = document.createDocumentFragment();
UI.UIUtils.createTextChild(container, 'url(');
let hrefUrl: Platform.DevToolsPath.UrlString|null = null;
if (this.rule && this.rule.resourceURL()) {
hrefUrl = Common.ParsedURL.ParsedURL.completeURL(this.rule.resourceURL(), this.url);
} else if (this.node) {
hrefUrl = this.node.resolveURL(this.url);
}
const link = ImagePreviewPopover.setImageUrl(
Components.Linkifier.Linkifier.linkifyURL(hrefUrl || this.url, {
text: this.url,
preventClick: false,
// crbug.com/1027168
// We rely on CSS text-overflow: ellipsis to hide long URLs in the Style panel,
// so that we don't have to keep two versions (original vs. trimmed) of URL
// at the same time, which complicates both StylesSidebarPane and StylePropertyTreeElement.
bypassURLTrimming: true,
showColumnNumber: false,
inlineFrameIndex: 0,
}),
hrefUrl || this.url);
container.appendChild(link);
UI.UIUtils.createTextChild(container, ')');
return [container];
}
static matcher(rule: SDK.CSSRule.CSSRule|null, node: SDK.DOMModel.DOMNode|null): URLMatcher {
return new URLMatcher((url, text) => new URLRenderer(rule, node, url, text));
}
}
export class AngleRenderer extends AngleMatch {
#treeElement: StylePropertyTreeElement;
constructor(text: string, treeElement: StylePropertyTreeElement) {
super(text);
this.#treeElement = treeElement;
}
override render(_: unknown, context: RenderingContext): Node[] {
const angleText = this.text;
if (!this.#treeElement.editable()) {
return [document.createTextNode(angleText)];
}
const cssAngle = new InlineEditor.CSSAngle.CSSAngle();
cssAngle.setAttribute('jslog', `${VisualLogging.showStyleEditor().track({click: true}).context('css-angle')}`);
const valueElement = document.createElement('span');
valueElement.textContent = angleText;
const computedPropertyValue = this.#treeElement.matchedStyles().computeValue(
this.#treeElement.property.ownerStyle, this.#treeElement.property.value) ||
'';
cssAngle.data = {
propertyName: this.#treeElement.property.name,
propertyValue: computedPropertyValue,
angleText,
containingPane:
(this.#treeElement.parentPane().element.enclosingNodeOrSelfWithClass('style-panes-wrapper') as HTMLElement),
};
cssAngle.append(valueElement);
cssAngle.addEventListener('popovertoggled', ({data}) => {
const section = this.#treeElement.section();
if (!section) {
return;
}
if (data.open) {
this.#treeElement.parentPane().hideAllPopovers();
this.#treeElement.parentPane().activeCSSAngle = cssAngle;
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.Angle);
}
section.element.classList.toggle('has-open-popover', data.open);
this.#treeElement.parentPane().setEditingStyle(data.open);
});
cssAngle.addEventListener('valuechanged', async ({data}) => {
valueElement.textContent = data.value;
await this.#treeElement.applyStyleText(this.#treeElement.renderedPropertyText(), false);
const computedPropertyValue = this.#treeElement.matchedStyles().computeValue(
this.#treeElement.property.ownerStyle, this.#treeElement.property.value) ||
'';
cssAngle.updateProperty(this.#treeElement.property.name, computedPropertyValue);
});
cssAngle.addEventListener('unitchanged', ({data}) => {
valueElement.textContent = data.value;
});
context.addControl('angle', cssAngle);
return [cssAngle];
}
static matcher(treeElement: StylePropertyTreeElement): AngleMatcher {
return new AngleMatcher(text => new AngleRenderer(text, treeElement));
}
}
export class LinkableNameRenderer extends LinkableNameMatch {
readonly #treeElement: StylePropertyTreeElement;
constructor(treeElement: StylePropertyTreeElement, text: string, propertyName: LinkableNameProperties) {
super(text, propertyName);
this.#treeElement = treeElement;
}
#getLinkData():
{jslogContext: string, metric: null|Host.UserMetrics.SwatchType, ruleBlock: string, isDefined: boolean} {
switch (this.properyName) {
case LinkableNameProperties.AnimationName:
return {
jslogContext: 'css-animation-name',
metric: Host.UserMetrics.SwatchType.AnimationNameLink,
ruleBlock: '@keyframes',
isDefined: Boolean(this.#treeElement.matchedStyles().keyframes().find(kf => kf.name().text === this.text)),
};
case LinkableNameProperties.FontPalette:
return {
jslogContext: 'css-font-palette',
metric: null,
ruleBlock: '@font-palette-values',
isDefined: this.#treeElement.matchedStyles().fontPaletteValuesRule()?.name().text === this.text,
};
case LinkableNameProperties.PositionFallback:
return {
jslogContext: 'css-position-fallback',
metric: Host.UserMetrics.SwatchType.PositionFallbackLink,
ruleBlock: '@position-fallback',
isDefined: Boolean(
this.#treeElement.matchedStyles().positionFallbackRules().find(pf => pf.name().text === this.text)),
};
}
}
override render(): Node[] {
const swatch = new InlineEditor.LinkSwatch.LinkSwatch();
UI.UIUtils.createTextChild(swatch, this.text);
const {metric, jslogContext, ruleBlock, isDefined} = this.#getLinkData();
swatch.data = {
text: this.text,
isDefined,
onLinkActivate: (): void => {
metric && Host.userMetrics.swatchActivated(metric);
this.#treeElement.parentPane().jumpToSectionBlock(`${ruleBlock} ${this.text}`);
},
jslogContext,
};
return [swatch];
}
static matcher(treeElement: StylePropertyTreeElement): LinkableNameMatcher {
return new LinkableNameMatcher((text, propertyName) => new LinkableNameRenderer(treeElement, text, propertyName));
}
}
export class BezierRenderer extends BezierMatch {
readonly #treeElement: StylePropertyTreeElement;
constructor(treeElement: StylePropertyTreeElement, text: string) {
super(text);
this.#treeElement = treeElement;
}
override render(): Node[] {
return [this.renderSwatch()];
}
renderSwatch(): Node {
if (!this.#treeElement.editable()) {
return document.createTextNode(this.text);
}
const swatchPopoverHelper = this.#treeElement.parentPane().swatchPopoverHelper();
const swatch = InlineEditor.Swatches.BezierSwatch.create();
swatch.iconElement().addEventListener('click', () => {
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.AnimationTiming);
});
swatch.setBezierText(this.text);
new BezierPopoverIcon({treeElement: this.#treeElement, swatchPopoverHelper, swatch});
return swatch;
}
static matcher(treeElement: StylePropertyTreeElement): BezierMatcher {
return new BezierMatcher(text => new BezierRenderer(treeElement, text));
}
}
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;
#parentSection: StylePropertiesSection;
isShorthand: boolean;
private readonly applyStyleThrottler: Common.Throttler.Throttler;
private newProperty: boolean;
private expandedDueToFilter: boolean;
valueElement: HTMLElement|null;
nameElement: HTMLElement|null;
private expandElement: IconButton.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, section, matchedStyles, property, isShorthand, inherited, overloaded, newProperty}:
StylePropertyTreeElementParams,
) {
// 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.#parentSection = section;
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;
}
editable(): boolean {
const isLonghandInsideShorthand = this.parent instanceof StylePropertyTreeElement && this.parent.isShorthand;
const hasSourceData = Boolean(this.style.styleSheetId && this.style.range);
return !isLonghandInsideShorthand && hasSourceData;
}
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 processAnimation(animationPropertyValue: string): Node {
const animationNameProperty =
this.property.getLonghandProperties().find(longhand => longhand.name === 'animation-name');
if (!animationNameProperty) {
return document.createTextNode(animationPropertyValue);
}
const animationNames = animationNameProperty.value.split(',').map(name => name.trim());
const cssAnimationModel =
InlineEditor.CSSAnimationModel.CSSAnimationModel.parse(animationPropertyValue, animationNames);
const contentChild = document.createElement('span');
for (let i = 0; i < cssAnimationModel.parts.length; i++) {
const part = cssAnimationModel.parts[i];
switch (part.type) {
case InlineEditor.CSSAnimationModel.PartType.Text:
contentChild.appendChild(document.createTextNode(part.value));
break;
case InlineEditor.CSSAnimationModel.PartType.EasingFunction:
contentChild.appendChild(
this.editable() && InlineEditor.AnimationTimingModel.AnimationTimingModel.parse(part.value) ?
new BezierRenderer(this, part.value).renderSwatch() :
document.createTextNode(part.value));
break;
case InlineEditor.CSSAnimationModel.PartType.AnimationName:
contentChild.appendChild(
new LinkableNameRenderer(this, part.value, LinkableNameProperties.AnimationName).render()[0]);
break;
case InlineEditor.CSSAnimationModel.PartType.Variable:
contentChild.appendChild(this.processVar(part.value));
break;
}
if (cssAnimationModel.parts[i + 1]?.value !== ',' && i !== cssAnimationModel.parts.length - 1) {
contentChild.appendChild(document.createTextNode(' '));
}
}
return contentChild;
}
// TODO(chromium:1504820) Delete once callers are migrated
private processVar(text: string, {shouldShowColorSwatch = true}: {shouldShowColorSwatch?: boolean} = {}): HTMLElement
|Text {
// The regex that matches to variables in `StylesSidebarPropertyRenderer`
// uses a lazy match. Because of this, when there are multiple right parantheses inside the
// var() function, it stops the match. So, for a match like `var(--a, var(--b))`, the text
// corresponds to `var(--a, var(--b)`; before processing it, we make sure that parantheses
// are matched.
const parenthesesBalancedText = text + ')'.repeat(Platform.StringUtilities.countUnmatchedLeftParentheses(text));
const computedSingleValue =
this.matchedStylesInternal.computeSingleVariableValue(this.style, parenthesesBalancedText);
const {variableName, fallback} = SDK.CSSMatchedStyles.parseCSSVariableNameAndFallback(parenthesesBalancedText);
if (!computedSingleValue || !variableName) {
return document.createTextNode(parenthesesBalancedText);
}
const {declaration} = this.matchedStylesInternal.computeCSSVariable(this.style, variableName) ?? {};
const {computedValue, fromFallback} = computedSingleValue;
let fallbackHtml: Array<HTMLElement|Text>|null = null;
if (fromFallback && fallback?.startsWith('var(')) {
fallbackHtml = [this.processVar(fallback, {shouldShowColorSwatch: false})];
} else if (fallback) {
fallbackHtml = [document.createTextNode(fallback)];
}
const varSwatch = new InlineEditor.LinkSwatch.CSSVarSwatch();
varSwatch.data = {
computedValue,
variableName,
fromFallback,
fallbackText: fallbackHtml ? fallbackHtml[0].textContent : null,
onLinkActivate: name => this.handleVarDefinitionActivate(declaration ?? name),
};
if (varSwatch.link?.linkElement) {
const {textContent} = varSwatch.link.linkElement;
if (textContent) {
const computedValueOfLink = textContent ?
this.matchedStylesInternal.computeSingleVariableValue(this.style, `var(${textContent})`) :
null;
this.parentPaneInternal.addPopover(varSwatch.link, {
contents: () => this.getVariablePopoverContents(textContent, computedValueOfLink?.computedValue ?? null),
jslogContext: 'elements.css-var',
});
}
}
if (fallbackHtml) {
// When slotting someting into the fallback slot, also emit text children so that .textContent produces the
// correct var value.
varSwatch.appendChild(document.createTextNode(`var(${this.name}`));
const span = varSwatch.appendChild(document.createElement('span'));
span.appendChild(document.createTextNode(', '));
span.slot = 'fallback';
span.appendChild(fallbackHtml[0]);
varSwatch.appendChild(document.createTextNode(')'));
} else {
UI.UIUtils.createTextChild(varSwatch, text);
}
if (!computedValue || !Common.Color.parse(computedValue) || !shouldShowColorSwatch) {
return varSwatch;
}
return new ColorRenderer(this, computedValue).renderColorSwatch(varSwatch);
}
private handleVarDefinitionActivate(variable: string|SDK.CSSProperty.CSSProperty|
SDK.CSSMatchedStyles.CSSRegisteredProperty): void {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.CustomPropertyLinkClicked);
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.VarLink);
if (variable instanceof SDK.CSSProperty.CSSProperty) {
this.parentPaneInternal.revealProperty(variable);
} else if (variable instanceof SDK.CSSMatchedStyles.CSSRegisteredProperty) {
this.parentPaneInternal.jumpToProperty(
'initial-value', variable.propertyName(), REGISTERED_PROPERTY_SECTION_NAME);
} else {
this.parentPaneInternal.jumpToProperty(variable) ||
this.parentPaneInternal.jumpToProperty('initial-value', variable, REGISTERED_PROPERTY_SECTION_NAME);
}
}
renderedPropertyText(): string {
if (!this.nameElement || !this.valueElement) {
return '';
}
return this.nameElement.textContent + ': ' + this.valueElement.textContent;
}
private processFont(text: string): Node {
this.#parentSection.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.setAttribute('jslog', `${VisualLogging.showStyleEditor('css-shadow').track({click: true})}`);
cssShadowSwatch.setCSSShadow(shadows[i]);
cssShadowSwatch.iconElement().addEventListener('click', () => {
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.Shadow);
});
new ShadowSwatchPopoverHelper(this, swatchPopoverHelper, cssShadowSwatch);
const colorSwatch = cssShadowSwatch.colorSwatch();
if (colorSwatch) {
colorSwatch.addEventListener(InlineEditor.ColorSwatch.ClickEvent.eventName, () => {
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.Color);
});
const swatchIcon = new ColorSwatchPopoverIcon(this, swatchPopoverHelper, colorSwatch);
swatchIcon.addEventListener(ColorSwatchPopoverIconEvents.ColorChanged, ev => {
// TODO(crbug.com/1402233): Is it really okay to dispatch an event from `Swatch` here?
colorSwatch.dispatchEvent(new InlineEditor.ColorSwatch.ColorChangedEvent(ev.data));
});
colorSwatch.addEventListener(InlineEditor.ColorSwatch.ColorChangedEvent.eventName, () => {
void this.applyStyleText(this.renderedPropertyText(), false);
});
}
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('text-editor-indent').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 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,
overloaded: this.overloadedInternal,
};
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 {
return this.#parentSection;
}
private updatePane(): void {
this.#parentSection.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);
}
override async onpopulate(): Promise<void> {
// Only populate once and if this property is a shorthand.
if (this.childCount() || !this.isShorthand) {
return;
}
const longhandProperties = this.property.getLonghandProperties();
const leadingProperties = this.style.leadingProperties();
for (const property of longhandProperties) {
const name = property.name;
let inherited = false;
let overloaded = false;
inherited = this.#parentSection.isPropertyInherited(name);
overloaded = this.matchedStylesInternal.propertyState(property) === SDK.CSSMatchedStyles.PropertyState.Overloaded;
const leadingProperty = leadingProperties.find(property => property.name === name && property.activeInStyle());
if (leadingProperty) {
overloaded = true;
}
const item = new StylePropertyTreeElement({
stylesPane: this.parentPaneInternal,
section: this.#parentSection,
matchedStyles: this.matchedStylesInternal,
property,
isShorthand: false,
inherited,
overloaded,
newProperty: false,
});
item.setComputedStyles(this.computedStyles);
item.setParentsComputedStyles(this.parentsComputedStyles);
this.appendChild(item);
}
}
override 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));
}
override onexpand(): void {
this.updateExpandElement();
}
override oncollapse(): void {
this.updateExpandElement();
}
private updateExpandElement(): void {
if (!this.expandElement) {
return;
}
if (this.expanded) {
this.expandElement.name = 'triangle-down';
} else {
this.expandElement.name = 'triangle-right';
}
}
#getRegisteredPropertyDetails(variableName: string): ElementsComponents.CSSVariableValueView.RegisteredPropertyDetails
|undefined {
const registration = this.matchedStyles().getRegisteredProperty(variableName);
const goToDefinition = (): void =>
this.parentPaneInternal.jumpToSection(variableName, REGISTERED_PROPERTY_SECTION_NAME);
return registration ? {registration, goToDefinition} : undefined;
}
getVariablePopoverContents(variableName: string, computedValue: string|null): HTMLElement|undefined {
return new ElementsComponents.CSSVariableValueView.CSSVariableValueView({
variableName,
value: computedValue ?? undefined,
details: this.#getRegisteredPropertyDetails(variableName),
});
}
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 = IconButton.Icon.create('triangle-right', 'expand-icon');
this.expandElement.setAttribute('jslog', `${VisualLogging.expand().track({click: true})}`);
}
const propertyRenderer =
new StylesSidebarPropertyRenderer(this.style.parentRule, this.node(), this.name, this.value, [
VariableRenderer.matcher(this, this.style),
ColorRenderer.matcher(this),
ColorMixRenderer.matcher(this.parentPaneInternal),
URLRenderer.matcher(this.style.parentRule, this.node()),
AngleRenderer.matcher(this),
LinkableNameRenderer.matcher(this),
BezierRenderer.matcher(this),
]);
if (this.property.parsedOk) {
propertyRenderer.setAnimationHandler(this.processAnimation.bind(this));
propertyRenderer.setFontHandler(this.processFont.bind(this));
propertyRenderer.setShadowHandler(this.processShadow.bind(this));
propertyRenderer.setGridHandler(this.processGrid.bind(this));
propertyRenderer.setLengthHandler(this.processLength.bind(this));
}
this.listItemElement.removeChildren();
this.nameElement = (propertyRenderer.renderName() as HTMLElement);
if (this.property.name.startsWith('--') && this.nameElement) {
this.parentPaneInternal.addPopover(this.nameElement, {
contents: () => this.getVariablePopoverContents(
this.property.name,
this.matchedStylesInternal.computeCSSVariable(this.style, this.property.name)?.value ?? null),
jslogContext: 'elements.css-var',
});
}
this.valueElement = (propertyRenderer.renderValue() as HTMLElement);
if (!this.treeOutline) {
return;
}
const indent = Common.Settings.Settings.instance().moduleSetting('text-editor-indent').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);
const semicolon = this.listItemElement.createChild('span', 'styles-semicolon');
semicolon.textContent = ';';
semicolon.onmouseup = this.mouseUp.bind(this);
if (this.property.disabled) {
UI.UIUtils.createTextChild(this.listItemElement.createChild('span', 'styles-clipboard-only'), ' */');
}
}
if (this.valueElement && this.#parentSection.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 = `${this.#parentSection.getSectionIdx()}_${this.#parentSection.nextEditorTriggerButtonIdx}`;
const button = StyleEditorWidget.createTriggerButton(
this.parentPaneInternal, this.#parentSection, isFlex ? FlexboxEditor : GridEditor,
isFlex ? i18nString(UIStrings.flexboxEditorButton) : i18nString(UIStrings.gridEditorButton), key);
button.setAttribute(
'jslog', `${VisualLogging.showStyleEditor().track({click: true}).context(isFlex ? 'flex' : 'grid')}`);
this.#parentSection.nextEditorTriggerButtonIdx++;
button.addEventListener('click', () => {
Host.userMetrics.swatchActivated(
isFlex ? Host.UserMetrics.SwatchType.Flex : Host.UserMetrics.SwatchType.Grid);
});
this.listItemElement.appendChild(button);
const helper = this.parentPaneInternal.swatchPopoverHelper();
if (helper.isShowing(StyleEditorWidget.instance()) && StyleEditorWidget.instance().getTriggerKey() === key) {
helper.setAnchorElement(button);
}
}
}
if (this.property.parsedOk) {
this.updateAuthoringHint();
} else {
// Avoid having longhands under an invalid shorthand.
this.listItemElement.classList.add('not-parsed-ok');
const registrationDetails = this.#getRegisteredPropertyDetails(this.property.name);
const tooltip = registrationDetails ?
new ElementsComponents.CSSVariableValueView.CSSVariableParserError(registrationDetails) :
null;
// Add a separate exclamation mark IMG element with a tooltip.
this.listItemElement.insertBefore(
this.parentPaneInternal.createExclamationMark(this.property, tooltip), this.listItemElement.firstChild);
// When the property is valid but the property value is invalid,
// add line-through only to the property value.
const invalidPropertyValue = SDK.CSSMetadata.cssMetadata().isCSSPropertyName(this.property.name);
if (invalidPropertyValue) {
this.listItemElement.classList.add('invalid-property-value');
}
}
if (!this.property.activeInStyle()) {
this.listItemElement.classList.add('inactive');
}
this.updateFilter();
if (this.property.parsedOk && this.parent && this.parent.root) {
const enabledCheckboxElement = document.createElement('input');
enabledCheckboxElement.className = 'enabled-button';
enabledCheckboxElement.type = 'checkbox';
enabledCheckboxElement.checked = !this.property.disabled;
enabledCheckboxElement.setAttribute('jslog', `${VisualLogging.toggle().track({click: true})}`);
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.setLabel(
enabledCheckboxElement, `${this.nameElement.textContent} ${this.valueElement.textContent}`);
}
const copyIcon = IconButton.Icon.create('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);
}
}
updateAuthoringHint(): void {
this.listItemElement.classList.remove('inactive-property');
const existingElement = this.listItemElement.querySelector('.hint');
if (existingElement) {
activeHints.delete(existingElement);
existingElement?.closest('.hint-wrapper')?.remove();
}
const propertyName = this.property.name;
if (!cssRuleValidatorsMap.has(propertyName)) {
return;
}
// Different rules apply to SVG nodes altogether. We currently don't have SVG-specific hints.
if (this.node()?.isSVGNode()) {
return;
}
const cssModel = this.parentPaneInternal.cssModel();
const fontFaces = cssModel?.fontFaces() || [];
const localName = this.node()?.localName();
for (const validator of cssRuleValidatorsMap.get(propertyName) || []) {
const hint = validator.getHint(
propertyName, this.computedStyles || undefined, this.parentsComputedStyles || undefined,
localName?.toLowerCase(), fontFaces);
if (hint) {
Host.userMetrics.cssHintShown(validator.getMetricType());
const wrapper = document.createElement('span');
wrapper.classList.add('hint-wrapper');
const hintIcon = new IconButton.Icon.Icon();
hintIcon.data = {iconName: 'info', color: 'var(--icon-default)', width: '14px', height: '14px'};
hintIcon.classList.add('hint');
wrapper.append(hintIcon);
activeHints.set(hintIcon, hint);
this.listItemElement.append(wrapper);
this.listItemElement.classList.add('inactive-property');
break;
}
}
}
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;
}
let selectedElement = event.target as Element;
if (UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event) && this.#parentSection.navigable) {
this.navigateToSource(selectedElement);
return;
}
if (this.expandElement && selectedElement === this.expandElement) {
return;
}
if (!this.#parentSection.editable) {
return;
}
selectedElement = selectedElement.enclosingNodeOrSelfWithClass('webkit-css-property') ||
selectedElement.enclosingNodeOrSelfWithClass('value') ||
selectedElement.enclosingNodeOrSelfWithClass('styles-semicolon');
if (!selectedElement || selectedElement === this.nameElement) {
this.startEditingName();
} else {
this.startEditingValue();
}
}
private handleContextMenuEvent(context: Context, event: Event): void {
const contextMenu = new UI.ContextMenu.ContextMenu(event);
if (this.property.parsedOk && 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);
}
}, {checked: !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 ruleText = StylesSidebarPane.formatLeadingProperties(this.#parentSection).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 allDeclarationText = StylesSidebarPane.formatLeadingProperties(this.#parentSection).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);
computedStyleWidget.input.setValue(this.property.name);
computedStyleWidget.input.element.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 leadingProperties = this.#parentSection.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 {
if (!this.#parentSection.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);
}
}
startEditingValue(): void {
const context: Context = {
expanded: this.expanded,
hasChildren: this.isExpandable(),
isEditingName: false,
originalProperty: this.property,
previousContent: this.value,
};
// Grid definitions are often multiline. Instead of showing the authored text reformat it a little bit nicer.
if (SDK.CSSMetadata.cssMetadata().isGridAreaDefiningProperty(this.name)) {
const splitResult =
TextUtils.TextUtils.Utils.splitStringByRegexes(this.value, [SDK.CSSMetadata.GridAreaRowRegex]);
context.previousContent = splitResult.map(result => result.value.trim()).join('\n');
}
this.#startEditing(context);
}
startEditingName(): void {
const context: Context = {
expanded: this.expanded,
hasChildren: this.isExpandable(),
isEditingName: true,
originalProperty: this.property,
previousContent: this.name.split('\n').map(l => l.trim()).join('\n'),
};
this.#startEditing(context);
}
#startEditing(context: Context): void {
this.contextForTest = context;
// FIXME: we don't allow editing of longhand properties under a shorthand right now.
if (this.parent instanceof StylePropertyTreeElement && this.parent.isShorthand) {
return;
}
const selectedElement = context.isEditingName ? this.nameElement : this.valueElement;
if (!selectedElement) {
return;
}
if (UI.UIUtils.isBeingEdited(selectedElement)) {
return;
}
// Lie about our children to prevent expanding on double click and to collapse shorthands.
this.setExpandable(false);
selectedElement.parentElement?.classList.add('child-editing');
selectedElement.textContent = context.previousContent; // 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);
selectedElement.parentElement?.scrollIntoViewIfNeeded(false);
this.prompt = new CSSPropertyPrompt(this, context.isEditingName);
this.prompt.setAutocompletionTimeout(0);
this.prompt.addEventListener(UI.TextPrompt.Events.TextChanged, () => {
void this.applyFreeFlowStyleTextEdit(context);
});
const invalidString = this.property.getInvalidStringForInvalidProperty();
if (invalidString) {
UI.ARIAUtils.alert(invalidString);
}
const proxyElement = this.prompt.attachAndStartEditing(selectedElement, blurListener.bind(this, context));
this.navigateToSource(selectedElement, true);
proxyElement.addEventListener('keydown', this.editingNameValueKeyDown.bind(this, context), false);
proxyElement.addEventListener('keypress', this.editingNameValueKeyPress.bind(this, context), false);
if (context.isEditingName) {
proxyElement.addEventListener('paste', pasteHandler.bind(this, context), false);
proxyElement.addEventListener('contextmenu', this.handleContextMenuEvent.bind(this, context), false);
}
selectedElement.getComponentSelection()?.selectAllChildren(selectedElement);
}
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) ||
(context.isEditingName && keyboardEvent.key === ' ')) {
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 = this.#selectionLeftOffset(target);
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;
}
}
/** @returns Selection offset relative to `element` */
#selectionLeftOffset(element: HTMLElement): number|null {
const selection = element.getComponentSelection();
if (!selection?.containsNode(element, true)) {
return null;
}
let leftOffset = selection.anchorOffset;
let node: ChildNode|(Node | null) = selection.anchorNode;
while (node !== element) {
while (node?.previousSibling) {
node = node.previousSibling;
leftOffset += node.textContent?.length ?? 0;
}
node = node?.parentNodeOrShadowHost() ?? null;
}
return leftOffset;
}
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');
}
this.parentPaneInternal.setEditingStyle(false);
}
editingCancelled(element: Element|null, context: Context): void {
this.removePrompt();
if (this.hasBeenEditedIncrementally) {
void this.applyOriginalStyle(context);
} else if (this.newProperty && this.treeOutline) {
this.treeOutline.removeChild(this);
}
this.updateTitle();
// This should happen last, as it clears the info necessary to restore the property value after [Page]Up/Down changes.
this.editingEnded(context);
}
private async applyOriginalStyle(context: Context): Promise<void> {
await this.applyStyleText(this.originalPropertyText, false, context.originalProperty);
}
private findSibling(moveDirection: string): StylePropertyTreeElement|null {
let target: (StylePropertyTreeElement|null)|this = this;
do {
const sibling: UI.TreeOutline.TreeElement|null =
moveDirection === 'forward' ? target.nextSibling : target.previousSibling;
target = sibling instanceof StylePropertyTreeElement ? sibling : null;
} while (target && target.inherited());
return target;
}
private async editingCommitted(userInput: string, context: Context, moveDirection: string): Promise<void> {
this.removePrompt();
this.editingEnded(context);
const isEditingName = context.isEditingName;
// If the underlying property has been ripped out, always assume that the value having been entered was
// a name-value pair and attempt to process it via the SDK.
if (!this.nameElement || !this.valueElement) {
return;
}
const nameElementValue = this.nameElement.textContent || '';
const nameValueEntered = (isEditingName && nameElementValue.includes(':')) || !this.property;
// Determine where to move to before making changes
let createNewProperty = false;
let moveToSelector = false;
const isDataPasted = typeof context.originalName !== 'undefined';
const isDirtyViaPaste = isDataPasted &&
(this.nameElement.textContent !== context.originalName ||
this.valueElement.textContent !== context.originalValue);
const isPropertySplitPaste =
isDataPasted && isEditingName && this.valueElement.textContent !== context.originalValue;
let moveTo: (StylePropertyTreeElement|null)|this = this;
const moveToOther = (isEditingName !== (moveDirection === 'forward'));
const abandonNewProperty = this.newProperty && !userInput && (moveToOther || isEditingName);
if (moveDirection === 'forward' && (!isEditingName || isPropertySplitPaste) ||
moveDirection === 'backward' && isEditingName) {
moveTo = moveTo.findSibling(moveDirection);
if (!moveTo) {
if (moveDirection === 'forward' && (!this.newProperty || userInput)) {
createNewProperty = true;
} else if (moveDirection === 'backward') {
moveToSelector = true;
}
}
}
// Make the Changes and trigger the moveToNextCallback after updating.
let moveToIndex = -1;
if (moveTo !== null && this.treeOutline) {
moveToIndex = this.treeOutline.rootElement().indexOfChild((moveTo as UI.TreeOutline.TreeElement));
}
const blankInput = Platform.StringUtilities.isWhitespace(userInput);
const shouldCommitNewProperty = this.newProperty &&
(isPropertySplitPaste || moveToOther || (!moveDirection && !isEditingName) || (isEditingName && blankInput) ||
nameValueEntered);
if (((userInput !== context.previousContent || isDirtyViaPaste) && !this.newProperty) || shouldCommitNewProperty) {
let propertyText;
if (nameValueEntered) {
propertyText = this.nameElement.textContent;
} else if (
blankInput ||
(this.newProperty && Platform.StringUtilities.isWhitespace(this.valueElement.textContent || ''))) {
propertyText = '';
} else {
if (isEditingName) {
propertyText = userInput + ': ' + this.property.value;
} else {
propertyText = this.property.name + ': ' + userInput;
}
}
await this.applyStyleText(propertyText || '', true);
moveToNextCallback.call(this, this.newProperty, !blankInput, this.#parentSection);
} else {
if (isEditingName) {
this.property.name = userInput;
} else {
this.property.value = userInput;
}
if (!isDataPasted && !this.newProperty) {
this.updateTitle();
}
moveToNextCallback.call(this, this.newProperty, false, this.#parentSection);
}
/**
* The Callback to start editing the next/previous property/selector.
*/
function moveToNextCallback(
this: StylePropertyTreeElement, alreadyNew: boolean, valueChanged: boolean,
section: StylePropertiesSection): void {
if (!moveDirection) {
this.parentPaneInternal.resetFocus();
return;
}
// User just tabbed through without changes.
if (moveTo && moveTo.parent) {
if (isEditingName) {
moveTo.startEditingValue();
} else {
moveTo.startEditingName();
}
return;
}
// User has made a change then tabbed, wiping all the original treeElements.
// Recalculate the new treeElement for the same property we were going to edit next.
if (moveTo && !moveTo.parent) {
const rootElement = section.propertiesTreeOutline.rootElement();
if (moveDirection === 'forward' && blankInput && !isEditingName) {
--moveToIndex;
}
if (moveToIndex >= rootElement.childCount() && !this.newProperty) {
createNewProperty = true;
} else {
const treeElement =
(moveToIndex >= 0 ? rootElement.childAt(moveToIndex) : null) as StylePropertyTreeElement | null;
if (treeElement) {
if (alreadyNew && blankInput) {
if (moveDirection === 'forward') {
treeElement.startEditingName();
} else {
treeElement.startEditingValue();
}
} else if (!isEditingName || isPropertySplitPaste) {
treeElement.startEditingName();
} else {
treeElement.startEditingValue();
}
return;
}
if (!alreadyNew) {
moveToSelector = true;
}
}
}
// Create a new attribute in this section (or move to next editable selector if possible).
if (createNewProperty) {
if (alreadyNew && !valueChanged && (isEditingName !== (moveDirection === 'backward'))) {
return;
}
section.addNewBlankProperty().startEditingName();
return;
}
if (abandonNewProperty) {
moveTo = this.findSibling(moveDirection);
const sectionToEdit = (moveTo || moveDirection === 'backward') ? section : section.nextEditableSibling();
if (sectionToEdit) {
if (sectionToEdit.style().parentRule) {
sectionToEdit.startEditingSelector();
} else {
sectionToEdit.moveEditorFromSelector(moveDirection);
}
}
return;
}
if (moveToSelector) {
if (section.style().parentRule) {
section.startEditingSelector();
} else {
section.moveEditorFromSelector(moveDirection);
}
}
}
}
private removePrompt(): void {
// BUG 53242. This cannot go into editingEnded(), as it should always happen first for any editing outcome.
if (this.prompt) {
this.prompt.detach();
this.prompt = null;
}
}
styleTextAppliedForTest(): void {
}
applyStyleText(styleText: string, majorChange: boolean, property?: SDK.CSSProperty.CSSProperty|null): Promise<void> {
return this.applyStyleThrottler.schedule(this.innerApplyStyleText.bind(this, styleText, majorChange, property));
}
private async innerApplyStyleText(
styleText: string, majorChange: boolean, property?: SDK.CSSProperty.CSSProperty|null): Promise<void> {
// this.property might have been nulled at the end of the last innerApplyStyleText
if (!this.treeOutline || !this.property) {
return;
}
const oldStyleRange = this.style.range;
if (!oldStyleRange) {
return;
}
const hasBeenEditedIncrementally = this.hasBeenEditedIncrementally;
styleText = styleText.replace(/[\xA0\t]/g, ' ').trim(); // Replace &nbsp; with whitespace.
if (!styleText.length && majorChange && this.newProperty && !hasBeenEditedIncrementally) {
// The user deleted everything and never applied a new property value via Up/Down scrolling/live editing, so remove the tree element and update.
this.parent && this.parent.removeChild(this);
return;
}
const currentNode = this.parentPaneInternal.node();
this.parentPaneInternal.setUserOperation(true);
styleText += Platform.StringUtilities.findUnclosedCssQuote(styleText);
styleText += ')'.repeat(Platform.StringUtilities.countUnmatchedLeftParentheses(styleText));
// Append a ";" if the new text does not end in ";".
// FIXME: this does not handle trailing comments.
if (styleText.length && !/;\s*$/.test(styleText)) {
styleText += ';';
}
const overwriteProperty = !this.newProperty || hasBeenEditedIncrementally;
let success: boolean = await this.property.setText(styleText, majorChange, overwriteProperty);
// Revert to the original text if applying the new text failed
if (hasBeenEditedIncrementally && majorChange && !success) {
majorChange = false;
success = await this.property.setText(this.originalPropertyText, majorChange, overwriteProperty);
}
this.parentPaneInternal.setUserOperation(false);
// TODO: using this.property.index to access its containing StyleDeclaration's property will result in
// off-by-1 errors when the containing StyleDeclaration's respective property has already been deleted.
// These referencing logic needs to be updated to be more robust.
const updatedProperty = property || this.style.propertyAt(this.property.index);
const isPropertyWithinBounds = this.property.index < this.style.allProperties().length;
if (!success || (!updatedProperty && isPropertyWithinBounds)) {
if (majorChange) {
// It did not apply, cancel editing.
if (this.newProperty) {
this.treeOutline.removeChild(this);
} else {
this.updateTitle();
}
}
this.styleTextAppliedForTest();
return;
}
if (updatedProperty) {
this.listItemElement.classList.toggle('changed', this.isPropertyChanged(updatedProperty));
this.parentPane().updateChangeStatus();
}
this.matchedStylesInternal.resetActiveProperties();
this.hasBeenEditedIncrementally = true;
// null check for updatedProperty before setting this.property as the code never expects this.property to be undefined or null.
// This occurs when deleting the last index of a StylePropertiesSection as this.style._allProperties array gets updated
// before we index it when setting the value for updatedProperty
const deleteProperty = majorChange && !styleText.length;
if (deleteProperty) {
this.#parentSection.resetToolbars();
} else if (!deleteProperty && updatedProperty) {
this.property = updatedProperty;
}
if (currentNode === this.node()) {
this.updatePane();
}
this.styleTextAppliedForTest();
}
override ondblclick(): boolean {
return true; // handled
}
override isEventWithinDisclosureTriangle(event: Event): boolean {
return event.target === this.expandElement;
}
}
export interface Context {
expanded: boolean;
hasChildren: boolean;
isEditingName: boolean;
originalProperty?: SDK.CSSProperty.CSSProperty;
originalName?: string;
originalValue?: string;
previousContent: string;
}