blob: 7dbd8ccb65892e8585c5bf1ced78e41fd9a52c8f [file] [log] [blame]
// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */
/* eslint-disable @devtools/no-lit-render-outside-of-view */
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 Badges from '../../models/badges/badges.js';
import * as Bindings from '../../models/bindings/bindings.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import type * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
import * as Tooltips from '../../ui/components/tooltips/tooltips.js';
import {createIcon, Icon} from '../../ui/kit/kit.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 * as Lit from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import {
BezierPopoverIcon,
ColorSwatchPopoverIcon,
ColorSwatchPopoverIconEvents,
ShadowEvents,
ShadowSwatchPopoverHelper,
} from './ColorSwatchPopoverIcon.js';
import * as ElementsComponents from './components/components.js';
import {cssRuleValidatorsMap} from './CSSRuleValidator.js';
import {CSSValueTraceView} from './CSSValueTraceView.js';
import {ElementsPanel} from './ElementsPanel.js';
import {
BinOpRenderer,
type MatchRenderer,
Renderer,
rendererBase,
RenderingContext,
StringRenderer,
type TracingContext,
URLRenderer
} from './PropertyRenderer.js';
import {StyleEditorWidget} from './StyleEditorWidget.js';
import type {StylePropertiesSection} from './StylePropertiesSection.js';
import {getCssDeclarationAsJavascriptProperty} from './StylePropertyUtils.js';
import {
CSSPropertyPrompt,
REGISTERED_PROPERTY_SECTION_NAME,
StylesSidebarPane,
} from './StylesSidebarPane.js';
const {html, nothing, render, Directives: {classMap}} = Lit;
const ASTUtils = SDK.CSSPropertyParser.ASTUtils;
const FlexboxEditor = ElementsComponents.StylePropertyEditor.FlexboxEditor;
const GridEditor = ElementsComponents.StylePropertyEditor.GridEditor;
const GridLanesEditor = ElementsComponents.StylePropertyEditor.GridLanesEditor;
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
*/
openInSourcesPanel: 'Open 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 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 Title of the button that opens the CSS Grid Lanes editor in the Styles panel.
*/
gridLanesEditorButton: 'Open `grid-lanes` 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',
/**
* @description Title of the link in Styles panel to jump to the Animations panel.
*/
jumpToAnimationsPanel: 'Jump to Animations panel',
/**
* @description Text displayed in a tooltip shown when hovering over a CSS property value references a name that's not
* defined and can't be linked to.
* @example {--my-linkable-name} PH1
*/
sIsNotDefined: '{PH1} is not defined',
/**
* @description Text in Styles Sidebar Pane of the Elements panel
*/
invalidPropertyValue: 'Invalid property value',
/**
* @description Text in Styles Sidebar Pane of the Elements panel
*/
unknownPropertyName: 'Unknown property name',
/**
* @description Announcement string for invalid properties.
* @example {Invalid property value} PH1
* @example {font-size} PH2
* @example {invalidValue} PH3
*/
invalidString: '{PH1}, property name: {PH2}, property value: {PH3}',
/**
* @description Title in the styles tab for the icon button for jumping to the anchor node.
*/
jumpToAnchorNode: 'Jump to anchor node',
} as const;
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;
}
// clang-format off
export class EnvFunctionRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.EnvFunctionMatch) {
// clang-format on
constructor(
readonly treeElement: StylePropertyTreeElement|null,
readonly matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, readonly computedStyles: Map<string, string>) {
super();
}
override render(match: SDK.CSSPropertyParserMatchers.EnvFunctionMatch, context: RenderingContext): Node[] {
const [, fallbackNodes] = ASTUtils.callArgs(match.node);
if (match.value) {
const substitution = context.tracing?.substitution();
if (substitution) {
if (match.varNameIsValid) {
return [document.createTextNode(match.value)];
}
return Renderer.render(fallbackNodes, substitution.renderingContext(context)).nodes;
}
}
const span = document.createElement('span');
const func =
this.treeElement?.getTracingTooltip('env', match.node, this.matchedStyles, this.computedStyles, context) ??
'env';
const valueClass = classMap({'inactive-value': !match.varNameIsValid});
const fallbackClass = classMap({'inactive-value': match.varNameIsValid});
render(
html`${func}(<span class=${valueClass}>${match.varName}</span>${
fallbackNodes ?
html`, <span class=${fallbackClass}>${Renderer.render(fallbackNodes, context).nodes}</span>` :
nothing})`,
span, {host: span});
return [span];
}
}
// clang-format off
export class FlexGridRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.FlexGridGridLanesMatch) {
// clang-format on
readonly #treeElement: StylePropertyTreeElement|null;
readonly #stylesPane: StylesSidebarPane;
constructor(stylesPane: StylesSidebarPane, treeElement: StylePropertyTreeElement|null) {
super();
this.#treeElement = treeElement;
this.#stylesPane = stylesPane;
}
override render(match: SDK.CSSPropertyParserMatchers.FlexGridGridLanesMatch, context: RenderingContext): Node[] {
const children = Renderer.render(ASTUtils.siblings(ASTUtils.declValue(match.node)), context).nodes;
if (!this.#treeElement?.editable()) {
return children;
}
const key =
`${this.#treeElement.section().getSectionIdx()}_${this.#treeElement.section().nextEditorTriggerButtonIdx}`;
function getEditorClass(layoutType: SDK.CSSPropertyParserMatchers.LayoutType): typeof FlexboxEditor|
typeof GridEditor|typeof GridLanesEditor {
switch (layoutType) {
case SDK.CSSPropertyParserMatchers.LayoutType.FLEX:
return FlexboxEditor;
case SDK.CSSPropertyParserMatchers.LayoutType.GRID:
return GridEditor;
case SDK.CSSPropertyParserMatchers.LayoutType.GRID_LANES:
return GridLanesEditor;
}
}
function getButtonTitle(layoutType: SDK.CSSPropertyParserMatchers.LayoutType): string {
switch (layoutType) {
case SDK.CSSPropertyParserMatchers.LayoutType.FLEX:
return i18nString(UIStrings.flexboxEditorButton);
case SDK.CSSPropertyParserMatchers.LayoutType.GRID:
return i18nString(UIStrings.gridEditorButton);
case SDK.CSSPropertyParserMatchers.LayoutType.GRID_LANES:
return i18nString(UIStrings.gridLanesEditorButton);
}
}
function getSwatchType(layoutType: SDK.CSSPropertyParserMatchers.LayoutType): Host.UserMetrics.SwatchType {
switch (layoutType) {
case SDK.CSSPropertyParserMatchers.LayoutType.FLEX:
return Host.UserMetrics.SwatchType.FLEX;
case SDK.CSSPropertyParserMatchers.LayoutType.GRID:
return Host.UserMetrics.SwatchType.GRID;
case SDK.CSSPropertyParserMatchers.LayoutType.GRID_LANES:
return Host.UserMetrics.SwatchType.GRID_LANES;
}
}
const button = StyleEditorWidget.createTriggerButton(
this.#stylesPane, this.#treeElement.section(), getEditorClass(match.layoutType),
getButtonTitle(match.layoutType), key);
button.tabIndex = -1;
button.setAttribute('jslog', `${VisualLogging.showStyleEditor().track({click: true}).context(match.layoutType)}`);
this.#treeElement.section().nextEditorTriggerButtonIdx++;
button.addEventListener('click', () => {
Host.userMetrics.swatchActivated(getSwatchType(match.layoutType));
});
const helper = this.#stylesPane.swatchPopoverHelper();
if (helper.isShowing(StyleEditorWidget.instance()) && StyleEditorWidget.instance().getTriggerKey() === key) {
helper.setAnchorElement(button);
}
return [...children, button];
}
}
// clang-format off
export class CSSWideKeywordRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.CSSWideKeywordMatch) {
// clang-format on
readonly #treeElement: StylePropertyTreeElement|null;
readonly #stylesPane: StylesSidebarPane;
constructor(stylesPane: StylesSidebarPane, treeElement: StylePropertyTreeElement|null) {
super();
this.#treeElement = treeElement;
this.#stylesPane = stylesPane;
}
override render(match: SDK.CSSPropertyParserMatchers.CSSWideKeywordMatch, context: RenderingContext): Node[] {
const resolvedProperty = match.resolveProperty();
if (!resolvedProperty) {
return [document.createTextNode(match.text)];
}
const swatch = new InlineEditor.LinkSwatch.LinkSwatch();
swatch.data = {
text: match.text,
tooltip: resolvedProperty ? undefined : {title: i18nString(UIStrings.sIsNotDefined, {PH1: match.text})},
isDefined: Boolean(resolvedProperty),
onLinkActivate: () => resolvedProperty && this.#stylesPane.jumpToDeclaration(resolvedProperty),
jslogContext: 'css-wide-keyword-link',
};
if (SDK.CSSMetadata.cssMetadata().isColorAwareProperty(resolvedProperty.name) ||
SDK.CSSMetadata.cssMetadata().isCustomProperty(resolvedProperty.name)) {
const color = Common.Color.parse(context.matchedResult.getComputedText(match.node));
if (color) {
return [new ColorRenderer(this.#stylesPane, this.#treeElement).renderColorSwatch(color, swatch), swatch];
}
}
return [swatch];
}
}
// clang-format off
export class VariableRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.VariableMatch) {
// clang-format on
readonly #stylesPane: StylesSidebarPane;
readonly #treeElement: StylePropertyTreeElement|null;
readonly #matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles;
readonly #computedStyles: Map<string, string>;
constructor(
stylesPane: StylesSidebarPane, treeElement: StylePropertyTreeElement|null,
matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, computedStyles: Map<string, string>) {
super();
this.#treeElement = treeElement;
this.#stylesPane = stylesPane;
this.#matchedStyles = matchedStyles;
this.#computedStyles = computedStyles;
}
override render(match: SDK.CSSPropertyParserMatchers.VariableMatch, context: RenderingContext): Node[] {
if (this.#treeElement?.property.ownerStyle.parentRule instanceof SDK.CSSRule.CSSFunctionRule) {
return Renderer.render(ASTUtils.children(match.node), context).nodes;
}
const {declaration, value: variableValue} = match.resolveVariable() ?? {};
const fromFallback = variableValue === undefined;
const computedValue = variableValue ?? match.fallbackValue();
const onLinkActivate = (name: string): void => this.#handleVarDefinitionActivate(declaration ?? name);
const varSwatch = document.createElement('span');
const substitution = context.tracing?.substitution({match, context});
if (substitution) {
if (declaration?.declaration) {
const {nodes, cssControls} = Renderer.renderValueNodes(
{name: declaration.name, value: declaration.value ?? ''},
substitution.cachedParsedValue(declaration.declaration, this.#matchedStyles, this.#computedStyles),
getPropertyRenderers(
declaration.name, declaration.style, this.#stylesPane, this.#matchedStyles, null, this.#computedStyles),
substitution);
cssControls.forEach((value, key) => value.forEach(control => context.addControl(key, control)));
return nodes;
}
if (!declaration && match.fallback) {
return Renderer.render(match.fallback, substitution.renderingContext(context)).nodes;
}
}
const renderedFallback = match.fallback ? Renderer.render(match.fallback, context) : undefined;
const varCall =
this.#treeElement?.getTracingTooltip('var', match.node, this.#matchedStyles, this.#computedStyles, context);
const tooltipContents =
this.#stylesPane.getVariablePopoverContents(this.#matchedStyles, match.name, variableValue ?? null);
const tooltipId = this.#treeElement?.getTooltipId('custom-property-var');
const tooltip = tooltipId ? {tooltipId} : undefined;
// clang-format off
render(html`
<span data-title=${computedValue || ''}
jslog=${VisualLogging.link('css-variable').track({click: true, hover: true})}>
${varCall ?? 'var'}(
<devtools-link-swatch class=css-var-link .data=${{
tooltip,
text: match.name,
isDefined: computedValue !== null && !fromFallback,
onLinkActivate,
}}>
</devtools-link-swatch>
${renderedFallback ? html`, ${renderedFallback.nodes}` : nothing})
</span>
${tooltipId ? html`
<devtools-tooltip
id=${tooltipId}
variant=rich
jslogContext=elements.css-var
>
${tooltipContents}
</devtools-tooltip>
` : ''}
`, varSwatch);
// clang-format on
const color = computedValue && Common.Color.parse(computedValue);
if (!color) {
return [varSwatch];
}
const colorSwatch = new ColorRenderer(this.#stylesPane, this.#treeElement).renderColorSwatch(color, varSwatch);
context.addControl('color', colorSwatch);
if (fromFallback) {
renderedFallback?.cssControls.get('color')?.forEach(
innerSwatch => innerSwatch.addEventListener(InlineEditor.ColorSwatch.ColorChangedEvent.eventName, ev => {
colorSwatch.color = ev.data.color;
}));
}
return [colorSwatch, varSwatch];
}
#handleVarDefinitionActivate(variable: string|SDK.CSSMatchedStyles.CSSValueSource): void {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.CustomPropertyLinkClicked);
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.VAR_LINK);
if (typeof variable === 'string') {
this.#stylesPane.jumpToProperty(variable) ||
this.#stylesPane.jumpToProperty('initial-value', variable, REGISTERED_PROPERTY_SECTION_NAME);
} else if (variable.declaration instanceof SDK.CSSProperty.CSSProperty) {
this.#stylesPane.revealProperty(variable.declaration);
} else if (variable.declaration instanceof SDK.CSSMatchedStyles.CSSRegisteredProperty) {
this.#stylesPane.jumpToProperty('initial-value', variable.name, REGISTERED_PROPERTY_SECTION_NAME);
}
}
}
// clang-format off
export class AttributeRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.AttributeMatch) {
// clang-format on
readonly #stylesPane: StylesSidebarPane;
readonly #treeElement: StylePropertyTreeElement|null;
readonly #matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles;
readonly #computedStyles: Map<string, string>;
constructor(
stylesPane: StylesSidebarPane, treeElement: StylePropertyTreeElement|null,
matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, computedStyles: Map<string, string>) {
super();
this.#treeElement = treeElement;
this.#stylesPane = stylesPane;
this.#matchedStyles = matchedStyles;
this.#computedStyles = computedStyles;
}
override render(match: SDK.CSSPropertyParserMatchers.AttributeMatch, context: RenderingContext): Node[] {
if (this.#treeElement?.property.ownerStyle.parentRule instanceof SDK.CSSRule.CSSFunctionRule) {
return Renderer.render(ASTUtils.children(match.node), context).nodes;
}
const rawValue = match.rawAttributeValue();
const attributeValue = match.resolveAttributeValue();
const fromFallback = attributeValue === null;
const attributeMissing = rawValue === null;
const typeError = fromFallback && !attributeMissing;
const attributeClass = attributeMissing ? 'inactive' : '';
const typeClass = typeError ? 'inactive' : '';
const fallbackClass = fromFallback ? '' : 'inactive';
const computedValue = attributeValue ?? match.fallbackValue();
const varSwatch = document.createElement('span');
const substitution = context.tracing?.substitution({match, context});
if (substitution) {
// TODO(b/441945435): If we combine these conditions, we can, when no fallback is
// specified but the type check fails, render a series of substitutions
// which may help debug why the type check failed. However, we can't
// distinguish ordinary type mismatch from cycles, and we need a way
// to handle cycles. And we may want UI for showing the substitutions
// anyway, even when a fallback is specified.
if (fromFallback) {
if (match.fallback) {
return Renderer.render(match.fallback, substitution.renderingContext(context)).nodes;
}
} else if (match.substitutionText !== null) {
const matching = SDK.CSSPropertyParser.matchDeclaration(
'--property', match.substitutionText,
this.#matchedStyles.propertyMatchers(match.style, this.#computedStyles));
return Renderer
.renderValueNodes(
{name: '--property', value: match.substitutionText}, matching,
getPropertyRenderers(
'--property', match.style, this.#stylesPane, this.#matchedStyles, null, this.#computedStyles),
substitution)
.nodes;
}
}
const renderedFallback = match.fallback ? Renderer.render(match.fallback, context) : undefined;
const attrCall =
this.#treeElement?.getTracingTooltip('attr', match.node, this.#matchedStyles, this.#computedStyles, context);
const tooltipId = attributeMissing ? undefined : this.#treeElement?.getTooltipId('custom-attribute');
const tooltip = tooltipId ? {tooltipId} : undefined;
// clang-format off
render(html`
<span data-title=${computedValue || ''}
jslog=${VisualLogging.link('css-variable').track({click: true, hover: true})}
>${attrCall ?? 'attr'}(<devtools-link-swatch class=${attributeClass} .data=${{
tooltip,
text: match.name,
isDefined: true,
onLinkActivate: () => this.#handleAttributeActivate(this.#matchedStyles.originatingNodeForStyle(match.style), match.name),
}}></devtools-link-swatch>${tooltipId ? html`
<devtools-tooltip
id=${tooltipId}
variant=rich
jslogContext=elements.css-var
>${JSON.stringify(rawValue)}</devtools-tooltip>` : nothing}${
match.type ? html` <span class=${typeClass}>${match.type}</span>` : nothing
}${renderedFallback ? html`, <span class=${fallbackClass}>${renderedFallback.nodes}</span>` : nothing
})</span>`, varSwatch);
// clang-format on
const color = computedValue && Common.Color.parse(computedValue);
if (!color) {
return [varSwatch];
}
const colorSwatch = new ColorRenderer(this.#stylesPane, this.#treeElement).renderColorSwatch(color, varSwatch);
context.addControl('color', colorSwatch);
if (fromFallback) {
renderedFallback?.cssControls.get('color')?.forEach(
innerSwatch => innerSwatch.addEventListener(InlineEditor.ColorSwatch.ColorChangedEvent.eventName, ev => {
colorSwatch.color = ev.data.color;
}));
}
return [colorSwatch, varSwatch];
}
#handleAttributeActivate(node: SDK.DOMModel.DOMNode|null, attribute: string): void {
if (!node) {
return;
}
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AttributeLinkClicked);
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.ATTR_LINK);
ElementsPanel.instance().highlightNodeAttribute(node, attribute);
}
}
// clang-format off
export class LinearGradientRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.LinearGradientMatch) {
// clang-format on
override render(match: SDK.CSSPropertyParserMatchers.LinearGradientMatch, context: RenderingContext): Node[] {
const children = ASTUtils.children(match.node);
const {nodes, cssControls} = Renderer.render(children, context);
const angles = cssControls.get('angle');
const angle = angles?.length === 1 ? angles[0] : null;
if (angle instanceof InlineEditor.CSSAngle.CSSAngle) {
angle.updateProperty(context.matchedResult.getComputedText(match.node));
const args = ASTUtils.callArgs(match.node);
const angleNode = args[0]?.find(
node => context.matchedResult.getMatch(node) instanceof SDK.CSSPropertyParserMatchers.AngleMatch);
const angleMatch = angleNode && context.matchedResult.getMatch(angleNode);
if (angleMatch) {
angle.addEventListener(InlineEditor.InlineEditorUtils.ValueChangedEvent.eventName, ev => {
angle.updateProperty(
context.matchedResult.getComputedText(match.node, match => match === angleMatch ? ev.data.value : null));
});
}
}
return nodes;
}
}
// clang-format off
export class RelativeColorChannelRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.RelativeColorChannelMatch) {
// clang-format on
readonly #treeElement: StylePropertyTreeElement|null;
constructor(treeElement: StylePropertyTreeElement|null) {
super();
this.#treeElement = treeElement;
}
override render(match: SDK.CSSPropertyParserMatchers.RelativeColorChannelMatch, context: RenderingContext): Node[] {
const color = context.findParent(match.node, SDK.CSSPropertyParserMatchers.ColorMatch);
if (!color?.relativeColor) {
return [document.createTextNode(match.text)];
}
const value = match.getColorChannelValue(color.relativeColor);
if (value === null) {
return [document.createTextNode(match.text)];
}
const evaluation =
context.tracing?.applyEvaluation([], () => ({placeholder: [document.createTextNode(value.toFixed(3))]}));
if (evaluation) {
return evaluation;
}
const span = document.createElement('span');
span.append(match.text);
const tooltipId = this.#treeElement?.getTooltipId('relative-color-channel');
if (!tooltipId) {
return [span];
}
span.setAttribute('aria-details', tooltipId);
const tooltip = new Tooltips.Tooltip.Tooltip({
id: tooltipId,
variant: 'rich',
anchor: span,
jslogContext: 'elements.relative-color-channel',
});
tooltip.append(value.toFixed(3));
return [span, tooltip];
}
}
// clang-format off
export class ColorRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.ColorMatch) {
// clang-format on
readonly #treeElement: StylePropertyTreeElement|null;
readonly #stylesPane: StylesSidebarPane;
constructor(stylesPane: StylesSidebarPane, treeElement: StylePropertyTreeElement|null) {
super();
this.#treeElement = treeElement;
this.#stylesPane = stylesPane;
}
#getValueChild(match: SDK.CSSPropertyParserMatchers.ColorMatch, context: RenderingContext): {
valueChild: HTMLSpanElement,
cssControls?: SDK.CSSPropertyParser.CSSControlMap,
childTracingContexts?: TracingContext[],
} {
const valueChild = document.createElement('span');
if (match.node.name !== 'CallExpression') {
valueChild.appendChild(document.createTextNode(match.text));
return {valueChild};
}
const func = context.matchedResult.ast.text(match.node.getChild('Callee'));
const args = ASTUtils.siblings(match.node.getChild('ArgList'));
const childTracingContexts = context.tracing?.evaluation([args], {match, context}) ?? undefined;
const renderingContext = childTracingContexts?.at(0)?.renderingContext(context) ?? context;
const {nodes, cssControls} = Renderer.renderInto(args, renderingContext, valueChild);
render(
html`${
this.#treeElement?.getTracingTooltip(
func, match.node, this.#treeElement.matchedStyles(), this.#treeElement.getComputedStyles() ?? new Map(),
renderingContext) ??
func}${nodes}`,
valueChild);
return {valueChild, cssControls, childTracingContexts};
}
override render(match: SDK.CSSPropertyParserMatchers.ColorMatch, context: RenderingContext): Node[] {
const {valueChild, cssControls, childTracingContexts} = this.#getValueChild(match, context);
let colorText = context.matchedResult.getComputedText(match.node);
if (match.relativeColor) {
const fakeSpan = document.body.appendChild(document.createElement('span'));
fakeSpan.style.backgroundColor = colorText;
colorText = window.getComputedStyle(fakeSpan).backgroundColor?.toString() || colorText;
fakeSpan.remove();
}
// Now try render a color swatch if the result is parsable.
const color = Common.Color.parse(colorText);
if (!color) {
if (match.node.name === 'CallExpression') {
return Renderer.render(ASTUtils.children(match.node), context).nodes;
}
return [document.createTextNode(colorText)];
}
if (match.node.name === 'CallExpression' && childTracingContexts) {
const evaluation = context.tracing?.applyEvaluation(childTracingContexts, () => {
const displayColor = color.as(((color.alpha ?? 1) !== 1) ? Common.Color.Format.HEXA : Common.Color.Format.HEX);
const colorText = document.createElement('span');
colorText.textContent = displayColor.asString();
const swatch =
new ColorRenderer(this.#stylesPane, null)
.renderColorSwatch(
displayColor.isGamutClipped() ? color : (displayColor.nickname() ?? displayColor), colorText);
swatch.addEventListener(InlineEditor.ColorSwatch.ColorChangedEvent.eventName, ev => {
colorText.textContent = ev.data.color.asString();
});
context.addControl('color', swatch);
return {placeholder: [swatch, colorText]};
});
if (evaluation) {
return evaluation;
}
}
const swatch = this.renderColorSwatch(color, valueChild);
context.addControl('color', swatch);
// For hsl/hwb colors, hook up the angle swatch for the hue.
if (cssControls && match.node.name === 'CallExpression' &&
context.ast.text(match.node.getChild('Callee')).match(/^(hsla?|hwba?)/)) {
const [angle] = cssControls.get('angle') ?? [];
if (angle instanceof InlineEditor.CSSAngle.CSSAngle) {
angle.updateProperty(swatch.color?.asString() ?? '');
angle.addEventListener(InlineEditor.InlineEditorUtils.ValueChangedEvent.eventName, ev => {
const hue = Common.Color.parseHueNumeric(ev.data.value);
const color = swatch.color;
if (!hue || !color) {
return;
}
if (color.is(Common.Color.Format.HSL) || color.is(Common.Color.Format.HSLA)) {
swatch.color = 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.color = new Common.Color.HWB(hue, color.w, color.b, color.alpha);
}
angle.updateProperty(swatch.color?.asString() ?? '');
});
}
}
return [swatch, valueChild];
}
renderColorSwatch(color: Common.Color.Color|undefined, valueChild: Node): 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(tooltip);
swatch.readonly = !editable;
if (color) {
swatch.color = color;
}
if (this.#treeElement?.editable()) {
const treeElement = this.#treeElement;
const onColorChanged = (): void => {
void treeElement.applyStyleText(treeElement.renderedPropertyText(), false);
};
const onColorFormatChanged = (e: InlineEditor.ColorSwatch.ColorFormatChangedEvent): void => {
valueChild.textContent = e.data.color.getAuthoredText() ?? e.data.color.asString();
void treeElement.applyStyleText(treeElement.renderedPropertyText(), false);
};
swatch.addEventListener(InlineEditor.ColorSwatch.ClickEvent.eventName, () => {
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.COLOR);
});
swatch.addEventListener(InlineEditor.ColorSwatch.ColorChangedEvent.eventName, onColorChanged);
swatch.addEventListener(InlineEditor.ColorSwatch.ColorFormatChangedEvent.eventName, onColorFormatChanged);
const swatchIcon =
new ColorSwatchPopoverIcon(treeElement, treeElement.parentPane().swatchPopoverHelper(), swatch);
swatchIcon.addEventListener(ColorSwatchPopoverIconEvents.COLOR_CHANGED, ev => {
valueChild.textContent = ev.data.getAuthoredText() ?? ev.data.asString();
swatch.color = ev.data;
});
if (treeElement.property.name === 'color') {
void this.#addColorContrastInfo(swatchIcon);
}
}
return swatch;
}
async #addColorContrastInfo(swatchIcon: ColorSwatchPopoverIcon): Promise<void> {
const cssModel = this.#stylesPane.cssModel();
const node = this.#stylesPane.node();
if (!cssModel || typeof node?.id === 'undefined') {
return;
}
const contrastInfo = new ColorPicker.ContrastInfo.ContrastInfo(await cssModel.getBackgroundColors(node.id));
swatchIcon.setContrastInfo(contrastInfo);
}
}
// clang-format off
export class LightDarkColorRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.LightDarkColorMatch) {
// clang-format on
readonly #treeElement: StylePropertyTreeElement|null;
readonly #stylesPane: StylesSidebarPane;
readonly #matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles;
constructor(
stylesPane: StylesSidebarPane, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
treeElement: StylePropertyTreeElement|null) {
super();
this.#treeElement = treeElement;
this.#stylesPane = stylesPane;
this.#matchedStyles = matchedStyles;
}
override render(match: SDK.CSSPropertyParserMatchers.LightDarkColorMatch, context: RenderingContext): Node[] {
const content = document.createElement('span');
content.appendChild(document.createTextNode('light-dark('));
const light = content.appendChild(document.createElement('span'));
content.appendChild(document.createTextNode(', '));
const dark = content.appendChild(document.createElement('span'));
content.appendChild(document.createTextNode(')'));
const {cssControls: lightControls} = Renderer.renderInto(match.light, context, light);
const {cssControls: darkControls} = Renderer.renderInto(match.dark, context, dark);
if (context.matchedResult.hasUnresolvedSubstitutions(match.node)) {
return [content];
}
const color = Common.Color.parse(
context.matchedResult.getComputedTextRange(match.light[0], match.light[match.light.length - 1]));
if (!color) {
return [content];
}
// Pass an undefined color here to insert a placeholder swatch that will be filled in from the async
// applyColorScheme below.
const colorSwatch = new ColorRenderer(this.#stylesPane, this.#treeElement).renderColorSwatch(undefined, content);
context.addControl('color', colorSwatch);
void this.applyColorScheme(match, context, colorSwatch, light, dark, lightControls, darkControls);
return [colorSwatch, content];
}
async applyColorScheme(
match: SDK.CSSPropertyParserMatchers.LightDarkColorMatch, context: RenderingContext,
colorSwatch: InlineEditor.ColorSwatch.ColorSwatch, light: HTMLSpanElement, dark: HTMLSpanElement,
lightControls: SDK.CSSPropertyParser.CSSControlMap,
darkControls: SDK.CSSPropertyParser.CSSControlMap): Promise<void> {
const activeColor = await this.#activeColor(match);
if (!activeColor) {
return;
}
const activeColorSwatches = (activeColor === match.light ? lightControls : darkControls).get('color');
activeColorSwatches?.forEach(
swatch => swatch.addEventListener(InlineEditor.ColorSwatch.ColorChangedEvent.eventName, ev => {
colorSwatch.color = ev.data.color;
}));
const inactiveColor = (activeColor === match.light) ? dark : light;
const colorText = context.matchedResult.getComputedTextRange(activeColor[0], activeColor[activeColor.length - 1]);
const color = colorText && Common.Color.parse(colorText);
inactiveColor.classList.add('inactive-value');
if (color) {
colorSwatch.color = color;
}
}
// Returns the syntax node group corresponding the active color scheme:
// If the element has color-scheme set to light or dark, return the respective group.
// If the element has color-scheme set to both light and dark, we check the prefers-color-scheme media query.
async #activeColor(match: SDK.CSSPropertyParserMatchers.LightDarkColorMatch):
Promise<CodeMirror.SyntaxNode[]|undefined> {
const activeColorSchemes = this.#matchedStyles.resolveProperty('color-scheme', match.style)
?.parseValue(this.#matchedStyles, new Map())
?.getComputedPropertyValueText()
.split(' ') ??
[];
const hasLight = activeColorSchemes.includes(SDK.CSSModel.ColorScheme.LIGHT);
const hasDark = activeColorSchemes.includes(SDK.CSSModel.ColorScheme.DARK);
if (!hasDark && !hasLight) {
return match.light;
}
if (!hasLight) {
return match.dark;
}
if (!hasDark) {
return match.light;
}
switch (await this.#stylesPane.cssModel()?.colorScheme()) {
case SDK.CSSModel.ColorScheme.DARK:
return match.dark;
case SDK.CSSModel.ColorScheme.LIGHT:
return match.light;
default:
return undefined;
}
}
}
// clang-format off
export class ColorMixRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.ColorMixMatch) {
// clang-format on
readonly #pane: StylesSidebarPane;
readonly #matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles;
readonly #computedStyles: Map<string, string>;
readonly #treeElement: StylePropertyTreeElement|null;
constructor(
pane: StylesSidebarPane, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
computedStyles: Map<string, string>, treeElement: StylePropertyTreeElement|null) {
super();
this.#pane = pane;
this.#matchedStyles = matchedStyles;
this.#computedStyles = computedStyles;
this.#treeElement = treeElement;
}
override render(match: SDK.CSSPropertyParserMatchers.ColorMixMatch, 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.color.getAuthoredText() ?? ev.data.color.asString()));
} else {
node.addEventListener(
InlineEditor.ColorMixSwatch.ColorMixChangedEvent.eventName, ev => onChange(ev.data.text));
}
const color = node.getText();
if (color) {
onChange(color);
return true;
}
}
return false;
};
const childTracingContexts =
context.tracing?.evaluation([match.space, match.color1, match.color2], {match, context});
const childRenderingContexts =
childTracingContexts?.map(ctx => ctx.renderingContext(context)) ?? [context, context, context];
const contentChild = document.createElement('span');
const color1 = Renderer.renderInto(match.color1, childRenderingContexts[1], contentChild);
const color2 = Renderer.renderInto(match.color2, childRenderingContexts[2], contentChild);
render(
html`${
this.#treeElement?.getTracingTooltip(
'color-mix', match.node, this.#matchedStyles, this.#computedStyles, context) ??
'color-mix'}(${Renderer.render(match.space, childRenderingContexts[0]).nodes}, ${color1.nodes}, ${
color2.nodes})`,
contentChild);
const color1Controls = color1.cssControls.get('color') ?? [];
const color2Controls = color2.cssControls.get('color') ?? [];
if (context.matchedResult.hasUnresolvedSubstitutions(match.node) || color1Controls.length !== 1 ||
color2Controls.length !== 1) {
return [contentChild];
}
const space = match.space.map(space => context.matchedResult.getComputedText(space)).join(' ');
const color1Text = match.color1.map(color => context.matchedResult.getComputedText(color)).join(' ');
const color2Text = match.color2.map(color => context.matchedResult.getComputedText(color)).join(' ');
const colorMixText = `color-mix(${space}, ${color1Text}, ${color2Text})`;
const nodeId = this.#pane.node()?.id;
if (nodeId !== undefined && childTracingContexts) {
const evaluation = context.tracing?.applyEvaluation(childTracingContexts, () => {
const initialColor = Common.Color.parse('#000') as Common.Color.Color;
const colorText = document.createElement('span');
colorText.textContent = initialColor.asString();
const swatch = new ColorRenderer(this.#pane, null).renderColorSwatch(initialColor, colorText);
swatch.addEventListener(InlineEditor.ColorSwatch.ColorChangedEvent.eventName, ev => {
colorText.textContent = ev.data.color.asString();
});
context.addControl('color', swatch);
const asyncEvalCallback = async(): Promise<boolean> => {
const results = await this.#pane.cssModel()?.resolveValues(undefined, nodeId, colorMixText);
if (results) {
const color = Common.Color.parse(results[0]);
if (color) {
swatch.color = color.as(Common.Color.Format.HEXA);
return true;
}
}
return false;
};
return {placeholder: [swatch, colorText], asyncEvalCallback};
});
if (evaluation) {
return evaluation;
}
}
const swatch = new InlineEditor.ColorMixSwatch.ColorMixSwatch();
if (!hookUpColorArg(color1Controls[0], text => swatch.setFirstColor(text)) ||
!hookUpColorArg(color2Controls[0], text => swatch.setSecondColor(text))) {
return [contentChild];
}
swatch.tabIndex = -1;
swatch.setColorMixText(colorMixText);
UI.ARIAUtils.setLabel(swatch, colorMixText);
context.addControl('color', swatch);
if (context.tracing) {
return [swatch, contentChild];
}
const tooltipId = this.#treeElement?.getTooltipId('color-mix');
if (!tooltipId) {
return [swatch, contentChild];
}
swatch.setAttribute('aria-details', tooltipId);
const tooltip = new Tooltips.Tooltip.Tooltip({
id: tooltipId,
variant: 'rich',
anchor: swatch,
jslogContext: 'elements.css-color-mix',
});
const colorTextSpan = tooltip.appendChild(document.createElement('span'));
tooltip.onbeforetoggle = e => {
if ((e as ToggleEvent).newState !== 'open') {
return;
}
const color = swatch.mixedColor();
if (!color) {
return;
}
const rgb = color.as(Common.Color.Format.HEX);
colorTextSpan.textContent = rgb.isGamutClipped() ? color.asString() : rgb.asString();
};
return [swatch, contentChild, tooltip];
}
}
// clang-format off
export class AngleRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.AngleMatch) {
// clang-format on
readonly #treeElement: StylePropertyTreeElement|null;
constructor(treeElement: StylePropertyTreeElement|null) {
super();
this.#treeElement = treeElement;
}
override render(match: SDK.CSSPropertyParserMatchers.AngleMatch, context: RenderingContext): Node[] {
const angleText = match.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;
cssAngle.data = {
angleText,
containingPane:
(this.#treeElement.parentPane().element.enclosingNodeOrSelfWithClass('style-panes-wrapper') as HTMLElement),
};
cssAngle.append(valueElement);
const treeElement = this.#treeElement;
cssAngle.addEventListener('popovertoggled', ({data}) => {
const section = treeElement.section();
if (!section) {
return;
}
if (data.open) {
treeElement.parentPane().hideAllPopovers();
treeElement.parentPane().activeCSSAngle = cssAngle;
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.ANGLE);
}
section.element.classList.toggle('has-open-popover', data.open);
treeElement.parentPane().setEditingStyle(data.open);
// Commit the value as a major change after the angle popover is closed.
if (!data.open) {
void treeElement.applyStyleText(treeElement.renderedPropertyText(), true);
}
});
cssAngle.addEventListener('valuechanged', async ({data}) => {
valueElement.textContent = data.value;
await treeElement.applyStyleText(treeElement.renderedPropertyText(), false);
});
cssAngle.addEventListener('unitchanged', ({data}) => {
valueElement.textContent = data.value;
});
context.addControl('angle', cssAngle);
return [cssAngle];
}
}
// clang-format off
export class LinkableNameRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.LinkableNameMatch) {
// clang-format on
readonly #matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles;
readonly #stylesPane: StylesSidebarPane;
constructor(matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, stylesSidebarPane: StylesSidebarPane) {
super();
this.#matchedStyles = matchedStyles;
this.#stylesPane = stylesSidebarPane;
}
#getLinkData(match: SDK.CSSPropertyParserMatchers.LinkableNameMatch):
{jslogContext: string, metric: null|Host.UserMetrics.SwatchType, ruleBlock: string, isDefined: boolean} {
switch (match.propertyName) {
case SDK.CSSPropertyParserMatchers.LinkableNameProperties.ANIMATION:
case SDK.CSSPropertyParserMatchers.LinkableNameProperties.ANIMATION_NAME:
return {
jslogContext: 'css-animation-name',
metric: Host.UserMetrics.SwatchType.ANIMATION_NAME_LINK,
ruleBlock: '@keyframes',
isDefined: Boolean(this.#matchedStyles.keyframes().find(kf => kf.name().text === match.text)),
};
case SDK.CSSPropertyParserMatchers.LinkableNameProperties.FONT_PALETTE:
return {
jslogContext: 'css-font-palette',
metric: null,
ruleBlock: '@font-*',
isDefined: Boolean(this.#matchedStyles.atRules().find(
ar => ar.type() === 'font-palette-values' && ar.name()?.text === match.text)),
};
case SDK.CSSPropertyParserMatchers.LinkableNameProperties.POSITION_TRY:
case SDK.CSSPropertyParserMatchers.LinkableNameProperties.POSITION_TRY_FALLBACKS:
return {
jslogContext: 'css-position-try',
metric: Host.UserMetrics.SwatchType.POSITION_TRY_LINK,
ruleBlock: '@position-try',
isDefined: Boolean(this.#matchedStyles.positionTryRules().find(pt => pt.name().text === match.text)),
};
}
}
override render(match: SDK.CSSPropertyParserMatchers.LinkableNameMatch): Node[] {
const swatch = new InlineEditor.LinkSwatch.LinkSwatch();
const {metric, jslogContext, ruleBlock, isDefined} = this.#getLinkData(match);
swatch.data = {
text: match.text,
tooltip: isDefined ? undefined : {title: i18nString(UIStrings.sIsNotDefined, {PH1: match.text})},
isDefined,
onLinkActivate: (): void => {
metric && Host.userMetrics.swatchActivated(metric);
if (match.propertyName === SDK.CSSPropertyParserMatchers.LinkableNameProperties.FONT_PALETTE) {
this.#stylesPane.jumpToFontPaletteDefinition(match.text);
} else {
this.#stylesPane.jumpToSectionBlock(`${ruleBlock} ${match.text}`);
}
},
jslogContext,
};
if (match.propertyName === SDK.CSSPropertyParserMatchers.LinkableNameProperties.ANIMATION ||
match.propertyName === SDK.CSSPropertyParserMatchers.LinkableNameProperties.ANIMATION_NAME) {
const el = document.createElement('span');
el.appendChild(swatch);
const node = this.#stylesPane.node();
if (node) {
const animationModel = node.domModel().target().model(SDK.AnimationModel.AnimationModel);
void animationModel?.getAnimationGroupForAnimation(match.text, node.id).then(maybeAnimationGroup => {
if (!maybeAnimationGroup) {
return;
}
const icon = createIcon('animation', 'open-in-animations-panel');
icon.setAttribute('jslog', `${VisualLogging.link('open-in-animations-panel').track({click: true})}`);
icon.setAttribute('role', 'button');
icon.setAttribute('title', i18nString(UIStrings.jumpToAnimationsPanel));
icon.addEventListener('mouseup', ev => {
ev.consume(true);
void Common.Revealer.reveal(maybeAnimationGroup);
});
el.insertBefore(icon, swatch);
});
}
return [el];
}
return [swatch];
}
}
// clang-format off
export class BezierRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.BezierMatch) {
// clang-format on
readonly #treeElement: StylePropertyTreeElement|null;
constructor(treeElement: StylePropertyTreeElement|null) {
super();
this.#treeElement = treeElement;
}
override render(match: SDK.CSSPropertyParserMatchers.BezierMatch, context: RenderingContext): Node[] {
const nodes = match.node.name === 'CallExpression' ? Renderer.render(ASTUtils.children(match.node), context).nodes :
[document.createTextNode(match.text)];
if (!this.#treeElement?.editable() ||
!InlineEditor.AnimationTimingModel.AnimationTimingModel.parse(
context.matchedResult.getComputedText(match.node))) {
return nodes;
}
const swatchPopoverHelper = this.#treeElement.parentPane().swatchPopoverHelper();
const icon = createIcon('bezier-curve-filled', 'bezier-swatch-icon');
icon.setAttribute('jslog', `${VisualLogging.showStyleEditor('bezier')}`);
icon.tabIndex = -1;
icon.addEventListener('click', () => {
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.ANIMATION_TIMING);
});
const bezierText = document.createElement('span');
bezierText.append(...nodes);
new BezierPopoverIcon({treeElement: this.#treeElement, swatchPopoverHelper, swatch: icon, bezierText});
const iconAndTextContainer = document.createElement('span');
iconAndTextContainer.classList.add('bezier-icon-and-text');
iconAndTextContainer.append(icon);
iconAndTextContainer.append(bezierText);
return [iconAndTextContainer];
}
}
// clang-format off
export class AutoBaseRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.AutoBaseMatch) {
readonly #computedStyle: Map<string, string>;
// clang-format on
constructor(computedStyle: Map<string, string>) {
super();
this.#computedStyle = computedStyle;
}
override render(match: SDK.CSSPropertyParserMatchers.AutoBaseMatch, context: RenderingContext): Node[] {
const content = document.createElement('span');
content.appendChild(document.createTextNode('-internal-auto-base('));
const auto = content.appendChild(document.createElement('span'));
content.appendChild(document.createTextNode(', '));
const base = content.appendChild(document.createElement('span'));
content.appendChild(document.createTextNode(')'));
Renderer.renderInto(match.auto, context, auto);
Renderer.renderInto(match.base, context, base);
const activeAppearance = this.#computedStyle.get('appearance');
if (activeAppearance?.startsWith('base')) {
auto.classList.add('inactive-value');
} else {
base.classList.add('inactive-value');
}
return [content];
}
}
export const enum ShadowPropertyType {
X = 'x',
Y = 'y',
SPREAD = 'spread',
BLUR = 'blur',
INSET = 'inset',
COLOR = 'color',
}
interface ShadowProperty {
value: string|CodeMirror.SyntaxNode;
source: CodeMirror.SyntaxNode|null;
expansionContext: RenderingContext|null;
propertyType: ShadowPropertyType;
}
type ShadowLengthProperty = ShadowProperty&{
length: InlineEditor.CSSShadowEditor.CSSLength,
propertyType: Exclude<ShadowPropertyType, ShadowPropertyType.INSET|ShadowPropertyType.COLOR>,
};
/**
* The shadow model is an abstraction over the various shadow properties on the one hand and the order they were defined
* in on the other, so that modifications through the shadow editor can retain the property order in the authored text.
* The model also looks through var()s by keeping a mapping between individual properties and any var()s they are coming
* from, replacing the var() functions as needed with concrete values when edited.
**/
export class ShadowModel implements InlineEditor.CSSShadowEditor.CSSShadowModel {
readonly #properties: ShadowProperty[];
readonly #shadowType: SDK.CSSPropertyParserMatchers.ShadowType;
readonly #context: RenderingContext;
constructor(
shadowType: SDK.CSSPropertyParserMatchers.ShadowType, properties: ShadowProperty[], context: RenderingContext) {
this.#shadowType = shadowType;
this.#properties = properties;
this.#context = context;
}
isBoxShadow(): boolean {
return this.#shadowType === SDK.CSSPropertyParserMatchers.ShadowType.BOX_SHADOW;
}
inset(): boolean {
return Boolean(this.#properties.find(property => property.propertyType === ShadowPropertyType.INSET));
}
#length(lengthType: ShadowLengthProperty['propertyType']): InlineEditor.CSSShadowEditor.CSSLength {
return this.#properties.find((property): property is ShadowLengthProperty => property.propertyType === lengthType)
?.length ??
InlineEditor.CSSShadowEditor.CSSLength.zero();
}
offsetX(): InlineEditor.CSSShadowEditor.CSSLength {
return this.#length(ShadowPropertyType.X);
}
offsetY(): InlineEditor.CSSShadowEditor.CSSLength {
return this.#length(ShadowPropertyType.Y);
}
blurRadius(): InlineEditor.CSSShadowEditor.CSSLength {
return this.#length(ShadowPropertyType.BLUR);
}
spreadRadius(): InlineEditor.CSSShadowEditor.CSSLength {
return this.#length(ShadowPropertyType.SPREAD);
}
#needsExpansion(property: ShadowProperty): boolean {
return Boolean(property.expansionContext && property.source);
}
#expandPropertyIfNeeded(property: ShadowProperty): void {
if (this.#needsExpansion(property)) {
// Rendering prefers `source` if present. It's sufficient to clear it in order to switch rendering to render the
// individual properties directly.
const source = property.source;
this.#properties.filter(property => property.source === source).forEach(property => {
property.source = null;
});
}
}
#expandOrGetProperty(propertyType: Exclude<ShadowPropertyType, ShadowLengthProperty['propertyType']>):
{property: ShadowProperty|undefined, index: number};
#expandOrGetProperty(propertyType: ShadowLengthProperty['propertyType']):
{property: ShadowLengthProperty|undefined, index: number};
#expandOrGetProperty(propertyType: ShadowPropertyType): {property: ShadowProperty|undefined, index: number} {
const index = this.#properties.findIndex(property => property.propertyType === propertyType);
const property = index >= 0 ? this.#properties[index] : undefined;
property && this.#expandPropertyIfNeeded(property);
return {property, index};
}
setInset(inset: boolean): void {
if (!this.isBoxShadow()) {
return;
}
const {property, index} = this.#expandOrGetProperty(ShadowPropertyType.INSET);
if (property) {
// For `inset`, remove the entry if value is false, otherwise don't touch it.
if (!inset) {
this.#properties.splice(index, 1);
}
} else {
this.#properties.unshift(
{value: 'inset', source: null, expansionContext: null, propertyType: ShadowPropertyType.INSET});
}
}
#setLength(value: InlineEditor.CSSShadowEditor.CSSLength, propertyType: ShadowLengthProperty['propertyType']): void {
const {property} = this.#expandOrGetProperty(propertyType);
if (property) {
property.value = value.asCSSText();
property.length = value;
property.source = null;
} else {
// Lengths are ordered X, Y, Blur, Spread, with the latter two being optional. When inserting an optional property
// we need to insert it after Y or after Blur, depending on what's being inserted and which properties are
// present.
const insertionIdx = 1 +
this.#properties.findLastIndex(
property => property.propertyType === ShadowPropertyType.Y ||
(propertyType === ShadowPropertyType.SPREAD && property.propertyType === ShadowPropertyType.BLUR));
if (insertionIdx > 0 && insertionIdx < this.#properties.length &&
this.#needsExpansion(this.#properties[insertionIdx]) &&
this.#properties[insertionIdx - 1].source === this.#properties[insertionIdx].source) {
// This prevents the edge case where insertion after the last length would break up a group of values that
// require expansion.
this.#expandPropertyIfNeeded(this.#properties[insertionIdx]);
}
this.#properties.splice(
insertionIdx, 0,
{value: value.asCSSText(), length: value, source: null, expansionContext: null, propertyType} as
ShadowLengthProperty);
}
}
setOffsetX(value: InlineEditor.CSSShadowEditor.CSSLength): void {
this.#setLength(value, ShadowPropertyType.X);
}
setOffsetY(value: InlineEditor.CSSShadowEditor.CSSLength): void {
this.#setLength(value, ShadowPropertyType.Y);
}
setBlurRadius(value: InlineEditor.CSSShadowEditor.CSSLength): void {
this.#setLength(value, ShadowPropertyType.BLUR);
}
setSpreadRadius(value: InlineEditor.CSSShadowEditor.CSSLength): void {
if (this.isBoxShadow()) {
this.#setLength(value, ShadowPropertyType.SPREAD);
}
}
renderContents(span: HTMLSpanElement): void {
span.removeChildren();
let previousSource = null;
for (const property of this.#properties) {
if (!property.source || property.source !== previousSource) {
if (property !== this.#properties[0]) {
span.append(' ');
}
// If `source` is present on the property that means it came from a var() and we'll use that to render.
if (property.source) {
span.append(...Renderer.render(property.source, this.#context).nodes);
} else if (typeof property.value === 'string') {
span.append(property.value);
} else {
span.append(...Renderer.render(property.value, property.expansionContext ?? this.#context).nodes);
}
}
previousSource = property.source;
}
}
}
// clang-format off
export class ShadowRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.ShadowMatch) {
readonly #treeElement: StylePropertyTreeElement|null;
// clang-format on
constructor(treeElement: StylePropertyTreeElement|null) {
super();
this.#treeElement = treeElement;
}
shadowModel(
shadow: CodeMirror.SyntaxNode[], shadowType: SDK.CSSPropertyParserMatchers.ShadowType,
context: RenderingContext): null|ShadowModel {
const properties: Array<ShadowProperty|ShadowLengthProperty> = [];
const missingLengths: Array<ShadowLengthProperty['propertyType']> =
[ShadowPropertyType.SPREAD, ShadowPropertyType.BLUR, ShadowPropertyType.Y, ShadowPropertyType.X];
let stillAcceptsLengths = true;
// We're parsing the individual shadow properties into an array here retaining the ordering. This also looks through
// var() functions by re-parsing the variable values on the fly. For properties coming from a var() we're keeping
// track of their origin to allow for adhoc expansion when one of those properties is edited.
const queue: Array<{
value: CodeMirror.SyntaxNode,
source: CodeMirror.SyntaxNode,
match: SDK.CSSPropertyParser.Match | undefined,
expansionContext: RenderingContext | null,
}> =
shadow.map(
value => ({value, source: value, match: context.matchedResult.getMatch(value), expansionContext: null}));
for (let item = queue.shift(); item; item = queue.shift()) {
const {value, source, match, expansionContext} = item;
const text = (expansionContext ?? context).ast.text(value);
if (value.name === 'NumberLiteral') {
if (!stillAcceptsLengths) {
return null;
}
const propertyType = missingLengths.pop();
if (propertyType === undefined ||
(propertyType === ShadowPropertyType.SPREAD &&
shadowType === SDK.CSSPropertyParserMatchers.ShadowType.TEXT_SHADOW)) {
return null;
}
const length = InlineEditor.CSSShadowEditor.CSSLength.parse(text);
if (!length) {
return null;
}
properties.push({value, source, length, propertyType, expansionContext});
} else if (match instanceof SDK.CSSPropertyParserMatchers.VariableMatch) {
// This doesn't come from any computed text, so we can rely on context here
const computedValue = context.matchedResult.getComputedText(value);
const computedValueAst = SDK.CSSPropertyParser.tokenizeDeclaration('--property', computedValue);
if (!computedValueAst) {
return null;
}
const matches = SDK.CSSPropertyParser.BottomUpTreeMatching.walkExcludingSuccessors(
computedValueAst, [new SDK.CSSPropertyParserMatchers.ColorMatcher()]);
if (matches.hasUnresolvedSubstitutions(matches.ast.tree)) {
return null;
}
queue.unshift(
...ASTUtils.siblings(ASTUtils.declValue(matches.ast.tree))
.map(matchedNode => ({
value: matchedNode,
source: value,
match: matches.getMatch(matchedNode),
expansionContext: new RenderingContext(computedValueAst, null, context.renderers, matches),
})));
} else {
// The length properties must come in one block, so if there were any lengths before, followed by a non-length
// property, we will not allow any future lengths.
stillAcceptsLengths = missingLengths.length === 4;
if (value.name === 'ValueName' && text.toLowerCase() === 'inset') {
if (shadowType === SDK.CSSPropertyParserMatchers.ShadowType.TEXT_SHADOW ||
properties.find(({propertyType}) => propertyType === ShadowPropertyType.INSET)) {
return null;
}
properties.push({value, source, propertyType: ShadowPropertyType.INSET, expansionContext});
} else if (
match instanceof SDK.CSSPropertyParserMatchers.ColorMatch ||
match instanceof SDK.CSSPropertyParserMatchers.ColorMixMatch) {
if (properties.find(({propertyType}) => propertyType === ShadowPropertyType.COLOR)) {
return null;
}
properties.push({value, source, propertyType: ShadowPropertyType.COLOR, expansionContext});
} else if (value.name !== 'Comment' && value.name !== 'Important') {
return null;
}
}
}
if (missingLengths.length > 2) {
// X and Y are mandatory
return null;
}
return new ShadowModel(shadowType, properties, context);
}
override render(match: SDK.CSSPropertyParserMatchers.ShadowMatch, context: RenderingContext): Node[] {
const shadows = ASTUtils.split(ASTUtils.siblings(ASTUtils.declValue(match.node)));
const result: Node[] = [];
for (const shadow of shadows) {
const model = this.shadowModel(shadow, match.shadowType, context);
const isImportant = shadow.find(node => node.name === 'Important');
if (shadow !== shadows[0]) {
result.push(document.createTextNode(', '));
}
if (!model || !this.#treeElement?.editable()) {
const {nodes} = Renderer.render(shadow, context);
result.push(...nodes);
continue;
}
const swatch = new InlineEditor.Swatches.CSSShadowSwatch(model);
swatch.setAttribute('jslog', `${VisualLogging.showStyleEditor('css-shadow').track({click: true})}`);
swatch.iconElement().addEventListener('click', () => {
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.SHADOW);
});
const contents = document.createElement('span');
model.renderContents(contents);
const popoverHelper = new ShadowSwatchPopoverHelper(
this.#treeElement, this.#treeElement.parentPane().swatchPopoverHelper(), swatch);
const treeElement = this.#treeElement;
popoverHelper.addEventListener(ShadowEvents.SHADOW_CHANGED, () => {
model.renderContents(contents);
void treeElement.applyStyleText(treeElement.renderedPropertyText(), false);
});
result.push(swatch, contents);
if (isImportant) {
result.push(...[document.createTextNode(' '), ...Renderer.render(isImportant, context).nodes]);
}
}
return result;
}
}
// clang-format off
export class FontRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.FontMatch) {
// clang-format on
constructor(readonly treeElement: StylePropertyTreeElement) {
super();
}
override render(match: SDK.CSSPropertyParserMatchers.FontMatch, context: RenderingContext): Node[] {
this.treeElement.section().registerFontProperty(this.treeElement);
const {nodes} = Renderer.render(ASTUtils.siblings(ASTUtils.declValue(match.node)), context);
return nodes;
}
}
// clang-format off
export class GridTemplateRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.GridTemplateMatch) {
// clang-format on
override render(match: SDK.CSSPropertyParserMatchers.GridTemplateMatch, context: RenderingContext): Node[] {
if (match.lines.length <= 1) {
return Renderer.render(ASTUtils.siblings(ASTUtils.declValue(match.node)), context).nodes;
}
const indent = Common.Settings.Settings.instance().moduleSetting('text-editor-indent').get();
const container = document.createDocumentFragment();
for (const line of match.lines) {
const value = Renderer.render(line, context);
const lineBreak = UI.Fragment.html`<br /><span class='styles-clipboard-only'>${indent.repeat(2)}</span>`;
container.append(lineBreak, ...value.nodes);
}
return [container];
}
}
export const SHORTHANDS_FOR_PERCENTAGES = new Set([
'inset',
'inset-block',
'inset-inline',
'margin',
'margin-block',
'margin-inline',
'padding',
'padding-block',
'padding-inline',
]);
async function resolveValues(
stylesPane: StylesSidebarPane, propertyName: string, match: SDK.CSSPropertyParser.Match, context: RenderingContext,
...values: string[]): Promise<string[]|null|undefined> {
// We want to resolve values against the original property we're tracing and not the property we're substituting,
// so try to look up the original name.
propertyName = context.tracing?.propertyName ?? context.matchedResult.ast.propertyName ?? propertyName;
if (SHORTHANDS_FOR_PERCENTAGES.has(propertyName) &&
(context.tracing?.expandPercentagesInShorthands ?? context.matchedResult.getLonghandValuesCount() > 1)) {
propertyName = context.getComputedLonghandName(match.node) ?? propertyName;
}
const nodeId = stylesPane.node()?.id;
if (nodeId === undefined) {
return null;
}
return (await stylesPane.cssModel()?.resolveValues(propertyName, nodeId, ...values)) ??
(await stylesPane.cssModel()?.resolveValues(undefined, nodeId, ...values));
}
// clang-format off
export class LengthRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.LengthMatch) {
// clang-format on
readonly #stylesPane: StylesSidebarPane;
readonly #treeElement: StylePropertyTreeElement|null;
readonly #propertyName: string;
constructor(stylesPane: StylesSidebarPane, propertyName: string, treeElement: StylePropertyTreeElement|null) {
super();
this.#stylesPane = stylesPane;
this.#treeElement = treeElement;
this.#propertyName = propertyName;
}
override render(match: SDK.CSSPropertyParserMatchers.LengthMatch, context: RenderingContext): Node[] {
const valueElement = document.createElement('span');
valueElement.tabIndex = -1;
valueElement.textContent = match.text;
const tooltip = this.#getTooltip(valueElement, match, context);
const evaluation = context.tracing?.applyEvaluation([], () => {
return {
placeholder: [valueElement],
asyncEvalCallback: () => this.#applyEvaluation(valueElement, match, context)
};
});
if (evaluation) {
return evaluation;
}
return tooltip ? [valueElement, tooltip] : [valueElement];
}
async #applyEvaluation(
valueElement: HTMLElement, match: SDK.CSSPropertyParser.Match, context: RenderingContext): Promise<boolean> {
const pixelValue = await resolveValues(this.#stylesPane, this.#propertyName, match, context, match.text);
if (pixelValue?.[0] && pixelValue?.[0] !== match.text) {
valueElement.textContent = pixelValue[0];
return true;
}
return false;
}
#getTooltip(valueElement: HTMLElement, match: SDK.CSSPropertyParser.Match, context: RenderingContext):
Tooltips.Tooltip.Tooltip|undefined {
const tooltipId = this.#treeElement?.getTooltipId('length');
if (!tooltipId) {
return undefined;
}
valueElement.setAttribute('aria-details', tooltipId);
const tooltip = new Tooltips.Tooltip.Tooltip(
{anchor: valueElement, variant: 'rich', id: tooltipId, jslogContext: 'length-popover'});
tooltip.addEventListener('beforetoggle', () => this.getTooltipValue(tooltip, match, context), {once: true});
return tooltip;
}
async getTooltipValue(
tooltip: Tooltips.Tooltip.Tooltip, match: SDK.CSSPropertyParser.Match, context: RenderingContext): Promise<void> {
const pixelValue = await resolveValues(this.#stylesPane, this.#propertyName, match, context, match.text);
if (!pixelValue) {
return;
}
tooltip.appendChild(document.createTextNode(pixelValue[0]));
}
}
// clang-format off
export class BaseFunctionRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.BaseFunctionMatch) {
// clang-format on
readonly #stylesPane: StylesSidebarPane;
readonly #matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles;
readonly #computedStyles: Map<string, string>;
readonly #treeElement: StylePropertyTreeElement|null;
readonly #propertyName: string;
constructor(
stylesPane: StylesSidebarPane, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
computedStyles: Map<string, string>, propertyName: string, treeElement: StylePropertyTreeElement|null) {
super();
this.#matchedStyles = matchedStyles;
this.#computedStyles = computedStyles;
this.#stylesPane = stylesPane;
this.#treeElement = treeElement;
this.#propertyName = propertyName;
}
override render(match: SDK.CSSPropertyParserMatchers.BaseFunctionMatch<string>, context: RenderingContext): Node[] {
const childTracingContexts = context.tracing?.evaluation(match.args, {match, context});
const renderedArgs = match.args.map((arg, idx) => {
const span = document.createElement('span');
Renderer.renderInto(
arg, childTracingContexts ? childTracingContexts[idx].renderingContext(context) : context, span);
return span;
});
const span = document.createElement('span');
render(
html`${
this.#treeElement?.getTracingTooltip(
match.func, match.node, this.#matchedStyles, this.#computedStyles, context) ??
match.func}(${renderedArgs.map((arg, idx) => idx === 0 ? [arg] : [html`, `, arg]).flat()})`,
span);
if (childTracingContexts) {
const evaluation = context.tracing?.applyEvaluation(
childTracingContexts,
() => ({placeholder: [span], asyncEvalCallback: () => this.applyEvaluation(span, match, context)}));
if (evaluation) {
return evaluation;
}
} else if (match instanceof SDK.CSSPropertyParserMatchers.MathFunctionMatch && !match.isArithmeticFunctionCall()) {
void this.applyMathFunction(renderedArgs, match, context);
}
return [span];
}
async applyEvaluation(
span: HTMLSpanElement, match: SDK.CSSPropertyParserMatchers.BaseFunctionMatch<string>,
context: RenderingContext): Promise<boolean> {
const value = context.matchedResult.getComputedText(match.node, match => {
if (match instanceof SDK.CSSPropertyParserMatchers.RelativeColorChannelMatch) {
const relativeColor =
context.findParent(match.node, SDK.CSSPropertyParserMatchers.ColorMatch)?.relativeColor ?? null;
return (relativeColor && match.getColorChannelValue(relativeColor)?.toFixed(3)) ?? null;
}
return null;
});
const evaled = await resolveValues(this.#stylesPane, this.#propertyName, match, context, value);
if (!evaled?.[0] || evaled[0] === value) {
return false;
}
span.textContent = evaled[0];
return true;
}
async applyMathFunction(
renderedArgs: HTMLElement[], match: SDK.CSSPropertyParserMatchers.BaseFunctionMatch<string>,
context: RenderingContext): Promise<void> {
// To understand which argument was selected by the function, we evaluate the function as well as all the arguments
// and compare the function result to the values of all its arguments. Evaluating the arguments eliminates nested
// function calls and normalizes all units to px.
const values = match.args.map(arg => context.matchedResult.getComputedTextRange(arg[0], arg[arg.length - 1]));
values.unshift(context.matchedResult.getComputedText(match.node));
const evaledArgs = await resolveValues(this.#stylesPane, this.#propertyName, match, context, ...values);
if (!evaledArgs) {
return;
}
const functionResult = evaledArgs.shift();
if (!functionResult) {
return;
}
for (let i = 0; i < renderedArgs.length; ++i) {
if (evaledArgs[i] !== functionResult) {
renderedArgs[i].classList.add('inactive-value');
}
}
}
}
export class MathFunctionRenderer extends BaseFunctionRenderer {
override readonly matchType = SDK.CSSPropertyParserMatchers.MathFunctionMatch;
}
export class CustomFunctionRenderer extends BaseFunctionRenderer {
override readonly matchType = SDK.CSSPropertyParserMatchers.CustomFunctionMatch;
}
// clang-format off
export class AnchorFunctionRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.AnchorFunctionMatch) {
// clang-format on
readonly #stylesPane: StylesSidebarPane;
static async decorateAnchorForAnchorLink(
stylesPane: StylesSidebarPane, container: HTMLElement,
{identifier, needsSpace}: {identifier?: string, needsSpace?: boolean}): Promise<void> {
if (identifier) {
render(html`${identifier}`, container, {host: container});
}
const anchorNode = await stylesPane.node()?.getAnchorBySpecifier(identifier) ?? undefined;
if (!identifier && !anchorNode) {
return;
}
const onLinkActivate = (): void => {
if (!anchorNode) {
return;
}
void Common.Revealer.reveal(anchorNode, false);
};
const handleIconClick = (ev: MouseEvent): void => {
ev.stopPropagation();
onLinkActivate();
};
const onMouseEnter = (): void => {
anchorNode?.highlight();
};
const onMouseLeave = (): void => {
SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight();
};
if (identifier) {
render(
// clang-format off
html`<devtools-link-swatch
@mouseenter=${onMouseEnter}
@mouseleave=${onMouseLeave}
.data=${{
text: identifier,
tooltip: anchorNode ? undefined :
{title: i18nString(UIStrings.sIsNotDefined, {PH1: identifier})},
isDefined: Boolean(anchorNode),
jslogContext: 'anchor-link',
onLinkActivate,
} as InlineEditor.LinkSwatch.LinkSwatchRenderData}
></devtools-link-swatch>${needsSpace ? ' ' : ''}`,
// clang-format on
container, {host: container});
} else {
// clang-format off
render(html`<devtools-icon
role='button'
title=${i18nString(UIStrings.jumpToAnchorNode)}
class='icon-link'
name='open-externally'
jslog=${VisualLogging.action('jump-to-anchor-node').track({click: true})}
@mouseenter=${onMouseEnter}
@mouseleave=${onMouseLeave}
@mousedown=${(ev: MouseEvent) => ev.stopPropagation()}
@click=${handleIconClick}
></devtools-icon>${needsSpace ? ' ' : ''}`, container, {host: container});
// clang-format on
}
}
constructor(stylesPane: StylesSidebarPane) {
super();
this.#stylesPane = stylesPane;
}
override render(match: SDK.CSSPropertyParserMatchers.AnchorFunctionMatch, context: RenderingContext): Node[] {
const content = document.createElement('span');
if (match.node.name === 'VariableName') {
// Link an anchor double-dashed ident to its matching anchor element.
void AnchorFunctionRenderer.decorateAnchorForAnchorLink(this.#stylesPane, content, {identifier: match.text});
} else {
// The matcher passes a 'CallExpression' node with a functionName
// ('anchor' or 'anchor-size') if the arguments need to have an implicit
// anchor link swatch rendered.
content.appendChild(document.createTextNode(`${match.functionName}(`));
const swatchContainer = document.createElement('span');
content.appendChild(swatchContainer);
const args = ASTUtils.children(match.node.getChild('ArgList'));
const remainingArgs = args.splice(1);
void AnchorFunctionRenderer.decorateAnchorForAnchorLink(
this.#stylesPane, swatchContainer, {needsSpace: remainingArgs.length > 1});
Renderer.renderInto(remainingArgs, context, content);
}
return [content];
}
}
// clang-format off
export class PositionAnchorRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.PositionAnchorMatch) {
readonly #stylesPane: StylesSidebarPane;
// clang-format on
constructor(stylesPane: StylesSidebarPane) {
super();
this.#stylesPane = stylesPane;
}
override render(match: SDK.CSSPropertyParserMatchers.PositionAnchorMatch): Node[] {
const content = document.createElement('span');
void AnchorFunctionRenderer.decorateAnchorForAnchorLink(this.#stylesPane, content, {identifier: match.text});
return [content];
}
}
// clang-format off
export class PositionTryRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.PositionTryMatch) {
readonly #matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles;
// clang-format on
constructor(matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles) {
super();
this.#matchedStyles = matchedStyles;
}
override render(match: SDK.CSSPropertyParserMatchers.PositionTryMatch, context: RenderingContext): Node[] {
const content = [];
if (match.preamble.length > 0) {
const {nodes} = Renderer.render(match.preamble, context);
content.push(...nodes);
}
for (const [i, fallback] of match.fallbacks.entries()) {
const fallbackContent = document.createElement('span');
if (i > 0) {
fallbackContent.appendChild(document.createTextNode(', '));
}
if (i !== this.#matchedStyles.activePositionFallbackIndex()) {
fallbackContent.classList.add('inactive-value');
}
Renderer.renderInto(fallback, context, fallbackContent);
content.push(fallbackContent);
}
return content;
}
}
export function getPropertyRenderers(
propertyName: string, style: SDK.CSSStyleDeclaration.CSSStyleDeclaration, stylesPane: StylesSidebarPane,
matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, treeElement: StylePropertyTreeElement|null,
computedStyles: Map<string, string>): Array<MatchRenderer<SDK.CSSPropertyParser.Match>> {
return [
new VariableRenderer(stylesPane, treeElement, matchedStyles, computedStyles),
new ColorRenderer(stylesPane, treeElement),
new ColorMixRenderer(stylesPane, matchedStyles, computedStyles, treeElement),
new URLRenderer(style.parentRule, stylesPane.node()),
new AngleRenderer(treeElement),
new LinkableNameRenderer(matchedStyles, stylesPane),
new BezierRenderer(treeElement),
new StringRenderer(),
new ShadowRenderer(treeElement),
new CSSWideKeywordRenderer(stylesPane, treeElement),
new LightDarkColorRenderer(stylesPane, matchedStyles, treeElement),
new GridTemplateRenderer(),
new LinearGradientRenderer(),
new AnchorFunctionRenderer(stylesPane),
new PositionAnchorRenderer(stylesPane),
new FlexGridRenderer(stylesPane, treeElement),
new EnvFunctionRenderer(treeElement, matchedStyles, computedStyles),
new PositionTryRenderer(matchedStyles),
new LengthRenderer(stylesPane, propertyName, treeElement),
new MathFunctionRenderer(stylesPane, matchedStyles, computedStyles, propertyName, treeElement),
new CustomFunctionRenderer(stylesPane, matchedStyles, computedStyles, propertyName, treeElement),
new AutoBaseRenderer(computedStyles),
new BinOpRenderer(),
new RelativeColorChannelRenderer(treeElement),
new AttributeRenderer(stylesPane, treeElement, matchedStyles, computedStyles),
];
}
export class StylePropertyTreeElement extends UI.TreeOutline.TreeElement {
private readonly style: SDK.CSSStyleDeclaration.CSSStyleDeclaration;
#matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles;
property: SDK.CSSProperty.CSSProperty;
readonly #inherited: boolean;
#overloaded: boolean;
#parentPane: StylesSidebarPane;
#parentSection: StylePropertiesSection;
isShorthand: boolean;
private readonly applyStyleThrottler = new Common.Throttler.Throttler(0);
private newProperty: boolean;
private expandedDueToFilter = false;
valueElement: HTMLElement|null = null;
nameElement: HTMLElement|null = null;
private expandElement: Icon|null = null;
private originalPropertyText = '';
private hasBeenEditedIncrementally = false;
private prompt: CSSPropertyPrompt|null = null;
private lastComputedValue: string|null = null;
private computedStyles: Map<string, string>|null = null;
private parentsComputedStyles: Map<string, string>|null = null;
private contextForTest!: Context|undefined;
#gridNames: Set<string>|undefined = undefined;
#tooltipKeyCounts = new Map<string, number>();
constructor(
{stylesPane, section, matchedStyles, property, isShorthand, inherited, overloaded, newProperty}:
StylePropertyTreeElementParams,
) {
// Pass an empty title, the title gets made later in onattach.
const jslogContext = property.name.startsWith('--') ? 'custom-property' : property.name;
super('', isShorthand, jslogContext);
this.style = property.ownerStyle;
this.#matchedStyles = matchedStyles;
this.property = property;
this.#inherited = inherited;
this.#overloaded = overloaded;
this.selectable = false;
this.#parentPane = stylesPane;
this.#parentSection = section;
this.isShorthand = isShorthand;
this.newProperty = newProperty;
if (this.newProperty) {
this.listItemElement.textContent = '';
}
this.property.addEventListener(SDK.CSSProperty.Events.LOCAL_VALUE_UPDATED, () => {
this.updateTitle();
});
}
async gridNames(): Promise<Set<string>> {
if (!SDK.CSSMetadata.cssMetadata().isGridNameAwareProperty(this.name)) {
return new Set();
}
for (let node = this.#parentPane.node()?.parentNode; node; node = node?.parentNode) {
const style = await this.#parentPane.cssModel()?.getComputedStyle(node.id);
const display = style?.get('display');
const isGrid = display === 'grid' || display === 'inline-grid';
if (!isGrid) {
continue;
}
const getNames = (propertyName: string, predicate: (node: CodeMirror.SyntaxNode) => boolean): string[] => {
const propertyValue = style?.get(propertyName);
if (!propertyValue) {
return [];
}
const ast = SDK.CSSPropertyParser.tokenizeDeclaration(propertyName, propertyValue);
if (!ast) {
return [];
}
return SDK.CSSPropertyParser.TreeSearch.findAll(ast, predicate).map(node => ast.text(node));
};
if (SDK.CSSMetadata.cssMetadata().isGridAreaNameAwareProperty(this.name)) {
return new Set(
getNames('grid-template-areas', node => node.name === 'StringLiteral')
?.flatMap(row => row.substring(1, row.length - 1).split(/\s+/).filter(cell => !cell.match(/^\.*$/))));
}
if (SDK.CSSMetadata.cssMetadata().isGridColumnNameAwareProperty(this.name)) {
return new Set(getNames(
'grid-template-columns', node => node.name === 'ValueName' && node.parent?.name === 'BracketedValue'));
}
return new Set(
getNames('grid-template-rows', node => node.name === 'ValueName' && node.parent?.name === 'BracketedValue'));
}
return new Set();
}
matchedStyles(): SDK.CSSMatchedStyles.CSSMatchedStyles {
return this.#matchedStyles;
}
getLonghand(): StylePropertyTreeElement|null {
return this.parent instanceof StylePropertyTreeElement && this.parent.isShorthand ? this.parent : null;
}
editable(): boolean {
const hasSourceData = Boolean(this.style.styleSheetId && this.style.range);
return !this.getLonghand() && hasSourceData;
}
inherited(): boolean {
return this.#inherited;
}
overloaded(): boolean {
return this.#overloaded;
}
setOverloaded(x: boolean): void {
if (x === this.#overloaded) {
return;
}
this.#overloaded = x;
this.updateState();
}
setComputedStyles(computedStyles: Map<string, string>|null): void {
this.computedStyles = computedStyles;
}
getComputedStyle(property: string): string|null {
return this.computedStyles?.get(property) ?? null;
}
getComputedStyles(): Map<string, string>|null {
return this.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.#parentPane.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;
}
renderedPropertyText(): string {
if (!this.nameElement || !this.valueElement) {
return '';
}
return this.nameElement.innerText + ': ' + this.valueElement.innerText;
}
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');
}
}
node(): SDK.DOMModel.DOMNode|null {
return this.#parentPane.node();
}
parentPane(): StylesSidebarPane {
return this.#parentPane;
}
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.#parentPane.setUserOperation(true);
const success = await this.property.setDisabled(disabled);
this.#parentPane.setUserOperation(false);
if (!success) {
return;
}
this.#matchedStyles.resetActiveProperties();
this.updatePane();
this.styleTextAppliedForTest();
}
async #getLonghandProperties(): Promise<SDK.CSSProperty.CSSProperty[]> {
const staticLonghandProperties = this.property.getLonghandProperties();
if (staticLonghandProperties.some(property => property.value !== '')) {
return staticLonghandProperties;
}
const parsedProperty = this.#computeCSSExpression(this.style, this.property.value);
if (!parsedProperty || parsedProperty === this.property.value) {
return staticLonghandProperties;
}
const parsedLonghands = await this.#parentPane.cssModel()?.agent.invoke_getLonghandProperties(
{shorthandName: this.property.name, value: parsedProperty});
if (!parsedLonghands || parsedLonghands.getError()) {
return staticLonghandProperties;
}
return parsedLonghands.longhandProperties.map(p => SDK.CSSProperty.CSSProperty.parsePayload(this.style, -1, p));
}
override async onpopulate(): Promise<void> {
if (!this.#gridNames) {
this.#gridNames = await this.gridNames();
}
// Only populate once and if this property is a shorthand.
if (this.childCount() || !this.isShorthand) {
return;
}
const longhandProperties = await this.#getLonghandProperties();
const leadingProperties = this.style.leadingProperties();
// Re-check child count to avoid any races of concurrent onpopulate calls
if (this.childCount()) {
return;
}
for (const property of longhandProperties) {
const name = property.name;
let inherited = false;
let overloaded = false;
inherited = this.#parentSection.isPropertyInherited(name);
overloaded = this.#matchedStyles.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.#parentPane,
section: this.#parentSection,
matchedStyles: this.#matchedStyles,
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.#parentPane, 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';
}
}
// Resolves a CSS expression to its computed value with `var()` and `attr()` calls updated.
// Still returns the string even when a `var()` or `attr()` call is not resolved.
#computeCSSExpression(style: SDK.CSSStyleDeclaration.CSSStyleDeclaration, text: string): string|null {
const ast = SDK.CSSPropertyParser.tokenizeDeclaration('--unused', text);
if (!ast) {
return null;
}
const matching: SDK.CSSPropertyParser.BottomUpTreeMatching = SDK.CSSPropertyParser.BottomUpTreeMatching.walk(ast, [
new SDK.CSSPropertyParserMatchers.VariableMatcher(this.#matchedStyles, style),
new SDK.CSSPropertyParserMatchers.AttributeMatcher(this.#matchedStyles, style),
new SDK.CSSPropertyParserMatchers.EnvFunctionMatcher(this.#matchedStyles),
]);
const decl = SDK.CSSPropertyParser.ASTUtils.siblings(SDK.CSSPropertyParser.ASTUtils.declValue(matching.ast.tree));
return decl.length > 0 ? matching.getComputedTextRange(decl[0], decl[decl.length - 1]) : '';
}
refreshIfComputedValueChanged(): void {
this.#gridNames = undefined;
const computedValue = this.#computeCSSExpression(this.property.ownerStyle, this.property.value);
if (computedValue === this.lastComputedValue) {
return;
}
this.lastComputedValue = computedValue;
this.#updateTitle();
}
updateTitle(): void {
this.lastComputedValue = this.#computeCSSExpression(this.property.ownerStyle, this.property.value);
this.#updateTitle();
}
#updateTitle(): void {
this.#tooltipKeyCounts.clear();
this.updateState();
if (this.isExpandable()) {
this.expandElement = createIcon('triangle-right', 'expand-icon');
this.expandElement.setAttribute('jslog', `${VisualLogging.expand().track({click: true})}`);
}
const renderers = this.property.parsedOk ?
getPropertyRenderers(
this.name, this.style, this.#parentPane, this.#matchedStyles, this, this.getComputedStyles() ?? new Map()) :
[];
if (Root.Runtime.experiments.isEnabled('font-editor') && this.property.parsedOk) {
renderers.push(new FontRenderer(this));
}
this.listItemElement.removeChildren();
const matchedResult = this.property.parseValue(this.matchedStyles(), this.computedStyles);
this.valueElement = Renderer.renderValueElement(this.property, matchedResult, renderers).valueElement;
this.nameElement = Renderer.renderNameElement(this.name);
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.repeat(this.section().nestingLevel + 1) + (this.property.disabled ? '/* ' : ''));
this.listItemElement.appendChild(this.nameElement);
if (this.property.name.startsWith('--') &&
!(this.property.ownerStyle.parentRule instanceof SDK.CSSRule.CSSFunctionRule)) {
const contents = this.#parentPane.getVariablePopoverContents(
this.matchedStyles(), this.property.name,
this.#matchedStyles.computeCSSVariable(this.style, this.property.name)?.value ?? null);
const tooltipId = this.getTooltipId('custom-property-decl');
this.nameElement.setAttribute('aria-details', tooltipId);
const tooltip = new Tooltips.Tooltip.Tooltip(
{anchor: this.nameElement, variant: 'rich', id: tooltipId, jslogContext: 'elements.css-var'});
tooltip.appendChild(contents);
tooltip.onbeforetoggle = (e: Event) => {
if ((e as ToggleEvent).newState === 'open') {
contents.value = this.#matchedStyles.computeCSSVariable(this.style, this.property.name)?.value;
}
};
this.listItemElement.appendChild(tooltip);
} else if (Common.Settings.Settings.instance().moduleSetting('show-css-property-documentation-on-hover').get()) {
const tooltipId = this.getTooltipId('property-doc');
this.nameElement.setAttribute('aria-details', tooltipId);
const tooltip = new Tooltips.Tooltip.Tooltip({
anchor: this.nameElement,
variant: 'rich',
padding: 'large',
id: tooltipId,
jslogContext: 'elements.css-property-doc',
});
tooltip.onbeforetoggle = event => {
if ((event as ToggleEvent).newState !== 'open') {
return;
}
if (!Common.Settings.Settings.instance().moduleSetting('show-css-property-documentation-on-hover').get()) {
event.consume(true);
return;
}
const cssProperty = this.#parentPane.webCustomData?.findCssProperty(this.name);
if (!cssProperty) {
event.consume(true);
return;
}
tooltip.removeChildren();
tooltip.appendChild(new ElementsComponents.CSSPropertyDocsView.CSSPropertyDocsView(cssProperty));
};
this.listItemElement.appendChild(tooltip);
}
if (this.valueElement) {
const lineBreakValue = 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.updateExpandElement();
}
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.property.parsedOk) {
this.updateAuthoringHint();
} else {
// 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(
this.createExclamationMark(
this.property, this.#parentPane.getVariableParserError(this.matchedStyles(), this.property.name)),
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?.root) {
const enabledCheckboxElement = document.createElement('input');
enabledCheckboxElement.classList.add('enabled-button', 'small');
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.name} ${this.value}`);
}
this.listItemElement.insertBefore(enabledCheckboxElement, this.listItemElement.firstChild);
}
const that = this;
this.valueElement.addEventListener('keydown', nonEditingNameValueKeyDown);
this.nameElement.addEventListener('keydown', nonEditingNameValueKeyDown);
function nonEditingNameValueKeyDown(this: HTMLElement, event: KeyboardEvent): void {
if (UI.UIUtils.isBeingEdited(this)) {
return;
}
if (event.key !== Platform.KeyboardUtilities.ENTER_KEY && event.key !== ' ') {
return;
}
if (this === that.valueElement) {
that.startEditingValue();
event.consume(true);
} else if (this === that.nameElement) {
that.startEditingName();
event.consume(true);
}
}
}
createExclamationMark(property: SDK.CSSProperty.CSSProperty, title: HTMLElement|null): Element {
const container = document.createElement('span');
const exclamationElement = container.createChild('span');
exclamationElement.tabIndex = -1;
exclamationElement.classList.add('exclamation-mark');
const invalidMessage = SDK.CSSMetadata.cssMetadata().isCSSPropertyName(property.name) ?
i18nString(UIStrings.invalidPropertyValue) :
i18nString(UIStrings.unknownPropertyName);
if (title === null) {
UI.Tooltip.Tooltip.install(exclamationElement, invalidMessage);
} else {
const tooltipId = this.getTooltipId('property-warning');
exclamationElement.setAttribute('aria-describedby', tooltipId);
const tooltip = new Tooltips.Tooltip.Tooltip({
anchor: exclamationElement,
variant: 'simple',
id: tooltipId,
jslogContext: 'elements.invalid-property-decl-popover'
});
tooltip.appendChild(title);
container.appendChild(tooltip);
}
const invalidString =
i18nString(UIStrings.invalidString, {PH1: invalidMessage, PH2: property.name, PH3: property.value});
// Storing the invalidString for future screen reader support when editing the property
property.setDisplayedStringForInvalidProperty(invalidString);
return container;
}
#getLinkableFunction(functionName: string, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles):
InlineEditor.LinkSwatch.LinkSwatch {
const swatch = new InlineEditor.LinkSwatch.LinkSwatch();
const registeredFunction = matchedStyles.getRegisteredFunction(functionName);
const isDefined = Boolean(registeredFunction);
swatch.data = {
jslogContext: 'css-function',
text: functionName,
tooltip: isDefined ? undefined : {title: i18nString(UIStrings.sIsNotDefined, {PH1: functionName})},
isDefined,
onLinkActivate: (): void => {
if (!registeredFunction) {
return;
}
this.#parentPane.jumpToFunctionDefinition(registeredFunction);
},
};
return swatch;
}
getTracingTooltip(
functionName: string, node: CodeMirror.SyntaxNode, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
computedStyles: Map<string, string>, context: RenderingContext): Lit.TemplateResult {
if (context.tracing || !context.property) {
return html`${functionName}`;
}
const text = context.ast.text(node);
const expandPercentagesInShorthands = context.matchedResult.getLonghandValuesCount() > 1;
const shorthandPositionOffset = context.matchedResult.getComputedLonghandName(node);
const {property} = context;
const stylesPane = this.parentPane();
const tooltipId = this.getTooltipId(`${functionName}-trace`);
// clang-format off
return html`
<span tabIndex=-1 class=tracing-anchor aria-details=${tooltipId}>${functionName.startsWith('--') ? this.#getLinkableFunction(functionName, matchedStyles) : functionName}</span>
<devtools-tooltip
id=${tooltipId}
use-hotkey
variant=rich
jslogContext=elements.css-value-trace
@beforetoggle=${function(this: Tooltips.Tooltip.Tooltip, e: ToggleEvent) {
if (e.newState === 'open') {
void (this.querySelector('devtools-widget') as UI.Widget.WidgetElement<CSSValueTraceView>| null)
?.getWidget()
?.showTrace(
property, text, matchedStyles, computedStyles,
getPropertyRenderers(property.name,
property.ownerStyle, stylesPane, matchedStyles, null, computedStyles),
expandPercentagesInShorthands, shorthandPositionOffset, this.openedViaHotkey);
}
}}
@toggle=${function(this: Tooltips.Tooltip.Tooltip, e: ToggleEvent) {
if (e.newState !== 'open') {
(this.querySelector('devtools-widget') as UI.Widget.WidgetElement<CSSValueTraceView>| null)
?.getWidget()
?.resetPendingFocus();
}
}}>
<devtools-widget
@keydown=${(e: KeyboardEvent) => {
const maybeTooltip = (e.target as Element).parentElement ;
if (!(maybeTooltip instanceof Tooltips.Tooltip.Tooltip)) {
return;
}
if (e.key === 'Escape' || (e.altKey && e.key === 'ArrowDown')){
maybeTooltip.hideTooltip();
maybeTooltip.anchor?.focus();
e.consume(true);
}
}}
.widgetConfig=${UI.Widget.widgetConfig(CSSValueTraceView)}>
</devtools-widget>
</devtools-tooltip>`;
// clang-format on
}
// Returns an id for <devtools-tooltips> that's stable across re-rendering of property values but unique across
// sections and across switches between different nodes.
getTooltipId(key: string): string {
const sectionId = this.section().sectionTooltipIdPrefix;
const tooltipKeyCount = this.#tooltipKeyCounts.get(key) ?? 0;
this.#tooltipKeyCounts.set(key, tooltipKeyCount + 1);
const propertyNameForCounting = this.getLonghand()?.name ?? this.name;
const ownIndex = this.style.allProperties().indexOf(this.property);
const propertyCount = this.style.allProperties().reduce<number>(
(value, property, index) =>
index < ownIndex && (property.name === this.name || property.name === propertyNameForCounting) ? value + 1 :
value,
0);
return `swatch-tooltip-${sectionId}-${this.name}-${propertyCount}-${key}-${tooltipKeyCount}`;
}
updateAuthoringHint(): void {
this.listItemElement.classList.remove('inactive-property');
const existingElement = this.listItemElement.querySelector('.hint');
if (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.#parentPane.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) {
const wrapper = document.createElement('span');
wrapper.classList.add('hint-wrapper');
const hintIcon = new Icon();
hintIcon.name = 'info';
hintIcon.classList.add('hint', 'small');
hintIcon.tabIndex = -1;
wrapper.append(hintIcon);
this.listItemElement.append(wrapper);
this.listItemElement.classList.add('inactive-property');
const tooltipId = this.getTooltipId('css-hint');
hintIcon.setAttribute('aria-details', tooltipId);
const tooltip = new Tooltips.Tooltip.Tooltip(
{anchor: hintIcon, variant: 'rich', padding: 'large', id: tooltipId, jslogContext: 'elements.css-hint'});
tooltip.appendChild(new ElementsComponents.CSSHintDetailsView.CSSHintDetailsView(hint));
this.listItemElement.appendChild(tooltip);
break;
}
}
}
private mouseUp(event: MouseEvent): void {
const activeTreeElement = parentMap.get(this.#parentPane);
parentMap.delete(this.#parentPane);
if (!activeTreeElement) {
return;
}
if (this.listItemElement.hasSelection()) {
return;
}
if (UI.UIUtils.isBeingEdited((event.target as Node))) {
return;
}
if (event.composedPath()[0] instanceof HTMLButtonElement) {
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) {
VisualLogging.logClick(this.nameElement as Element, event);
this.startEditingName();
} else {
VisualLogging.logClick(this.valueElement as Element, event);
this.startEditingValue();
}
}
private handleContextMenuEvent(context: Context, event: Event): void {
const contextMenu = new UI.ContextMenu.ContextMenu(event);
if (this.property.parsedOk && this.parent?.root) {
const sectionIndex = this.#parentPane.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(context);
await this.toggleDisabled(!this.property.disabled);
event.consume();
this.#parentPane.continueEditingElement(sectionIndex, propertyIndex);
}
}, {checked: !this.property.disabled, jslogContext: 'toggle-property-and-continue-editing'});
}
const revealCallback = this.navigateToSource.bind(this) as () => void;
contextMenu.defaultSection().appendItem(
i18nString(UIStrings.openInSourcesPanel), revealCallback, {jslogContext: 'reveal-in-sources-panel'});
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);
}, {jslogContext: 'copy-declaration'});
contextMenu.headerSection().appendItem(i18nString(UIStrings.copyProperty), () => {
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(this.property.name);
}, {jslogContext: 'copy-property'});
contextMenu.headerSection().appendItem(i18nString(UIStrings.copyValue), () => {
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(this.property.value);
}, {jslogContext: 'copy-value'});
contextMenu.headerSection().appendItem(i18nString(UIStrings.copyRule), () => {
const ruleText = StylesSidebarPane.formatLeadingProperties(this.#parentSection).ruleText;
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(ruleText);
}, {jslogContext: 'copy-rule'});
contextMenu.headerSection().appendItem(
i18nString(UIStrings.copyCssDeclarationAsJs), this.copyCssDeclarationAsJs.bind(this),
{jslogContext: 'copy-css-declaration-as-js'});
contextMenu.clipboardSection().appendItem(i18nString(UIStrings.copyAllDeclarations), () => {
const allDeclarationText = StylesSidebarPane.formatLeadingProperties(this.#parentSection).allDeclarationText;
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(allDeclarationText);
}, {jslogContext: 'copy-all-declarations'});
contextMenu.clipboardSection().appendItem(
i18nString(UIStrings.copyAllCssDeclarationsAsJs), this.copyAllCssDeclarationAsJs.bind(this),
{jslogContext: 'copy-all-css-declarations-as-js'});
contextMenu.footerSection().appendItem(i18nString(UIStrings.viewComputedValue), () => {
void this.viewComputedValue();
}, {jslogContext: 'view-computed-value'});
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);
}
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'));
}
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.#parentPane.setEditingStyle(true);
selectedElement.parentElement?.scrollIntoViewIfNeeded(false);
this.prompt = new CSSPropertyPrompt(this, context.isEditingName, Array.from(this.#gridNames ?? []));
this.prompt.setAutocompletionTimeout(0);
this.prompt.addEventListener(UI.TextPrompt.Events.TEXT_CHANGED, () => {
void this.applyFreeFlowStyleTextEdit(context);
});
const invalidString = this.property.getInvalidStringForInvalidProperty();
if (invalidString) {
UI.ARIAUtils.LiveAnnouncer.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(context);
if (context.isEditingName) {
this.nameElement?.focus();
} else {
this.valueElement?.focus();
}
break;
case 'forward':
case 'backward':
void this.editingCommitted(target.textContent || '', context, result);
break;
}
event.consume();
return;
}
}
static shouldCommitValueSemicolon(text: string, cursorPosition: number): boolean {
// FIXME: should this account for semicolons inside comments?
let openQuote = '';
const openParens: string[] = [];
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 (ch === '[') {
openParens.push(']');
} else if (ch === '{') {
openParens.push('}');
} else if (ch === '(') {
openParens.push(')');
} else if (openQuote === ch) {
openQuote = '';
} else if (openParens.at(-1) === ch && !openQuote) {
openParens.pop();
}
}
return !openQuote && openParens.length === 0;
}
private editingNameValueKeyPress(context: Context, event: Event): void {
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 &&
StylePropertyTreeElement.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.#parentPane.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.#parentPane.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?.parentElement) {
editedElement.parentElement.classList.remove('child-editing');
}
this.#parentPane.setEditingStyle(false);
}
editingCancelled(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?.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.#parentPane.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?.removeChild(this);
return;
}
const currentNode = this.#parentPane.node();
this.#parentPane.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);
if (success && majorChange) {
Badges.UserBadges.instance().recordAction(Badges.BadgeAction.CSS_RULE_MODIFIED);
}
// 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.#parentPane.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;
}
this.#matchedStyles.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;
}