chrome-devtools-frontend
Version:
Chrome DevTools UI
1,202 lines (1,091 loc) • 127 kB
text/typescript
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable rulesdir/no-imperative-dom-api */
/* eslint-disable rulesdir/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 Bindings from '../../models/bindings/bindings.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import type * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
import * as IconButton from '../../ui/components/icon_button/icon_button.js';
import * as Tooltips from '../../ui/components/tooltips/tooltips.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} = Lit;
const ASTUtils = SDK.CSSPropertyParser.ASTUtils;
const FlexboxEditor = ElementsComponents.StylePropertyEditor.FlexboxEditor;
const GridEditor = ElementsComponents.StylePropertyEditor.GridEditor;
const UIStrings = {
/**
*@description Text in Color Swatch Popover Icon of the Elements panel
*/
shiftClickToChangeColorFormat: 'Shift + Click to change color format.',
/**
*@description Swatch icon element title in Color Swatch Popover Icon of the Elements panel
*@example {Shift + Click to change color format.} PH1
*/
openColorPickerS: 'Open color picker. {PH1}',
/**
*@description 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 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 FlexGridRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.FlexGridMatch) {
// 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.FlexGridMatch, 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}`;
const button = StyleEditorWidget.createTriggerButton(
this.#stylesPane, this.#treeElement.section(), match.isFlex ? FlexboxEditor : GridEditor,
match.isFlex ? i18nString(UIStrings.flexboxEditorButton) : i18nString(UIStrings.gridEditorButton), key);
button.tabIndex = -1;
button.setAttribute(
'jslog', `${VisualLogging.showStyleEditor().track({click: true}).context(match.isFlex ? 'flex' : 'grid')}`);
this.#treeElement.section().nextEditorTriggerButtonIdx++;
button.addEventListener('click', () => {
Host.userMetrics.swatchActivated(
match.isFlex ? Host.UserMetrics.SwatchType.FLEX : Host.UserMetrics.SwatchType.GRID);
});
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)];
}
}
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.length > 0) {
return Renderer.render(match.fallback, substitution.renderingContext(context)).nodes;
}
}
const renderedFallback = match.fallback.length > 0 ? 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?.nodes.length ? html`, ${renderedFallback.nodes}` : nothing})
</span>
${tooltipId ? html`
<devtools-tooltip variant=rich id=${tooltipId} 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.setColor(ev.data.color);
}));
}
return [colorSwatch];
}
#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 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 swatch =
new ColorRenderer(this.#stylesPane, null)
.renderColorSwatch(displayColor.isGamutClipped() ? color : (displayColor.nickname() ?? displayColor));
context.addControl('color', swatch);
return {placeholder: [swatch]};
});
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.getColor()?.asString() ?? '');
angle.addEventListener(InlineEditor.InlineEditorUtils.ValueChangedEvent.eventName, ev => {
const hue = Common.Color.parseHueNumeric(ev.data.value);
const color = swatch.getColor();
if (!hue || !color) {
return;
}
if (color.is(Common.Color.Format.HSL) || color.is(Common.Color.Format.HSLA)) {
swatch.setColor(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.setColor(new Common.Color.HWB(hue, color.w, color.b, color.alpha));
}
angle.updateProperty(swatch.getColor()?.asString() ?? '');
});
}
}
return [swatch];
}
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.setReadonly(!editable);
if (color) {
swatch.renderColor(color);
}
if (!valueChild) {
valueChild = swatch.createChild('span');
if (color) {
valueChild.textContent = color.getAuthoredText() ?? color.asString();
}
}
swatch.appendChild(valueChild);
if (this.#treeElement?.editable()) {
const treeElement = this.#treeElement;
const onColorChanged = (): void => {
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);
const swatchIcon =
new ColorSwatchPopoverIcon(treeElement, treeElement.parentPane().swatchPopoverHelper(), swatch);
swatchIcon.addEventListener(ColorSwatchPopoverIconEvents.COLOR_CHANGED, ev => {
swatch.setColorText(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.hasUnresolvedVars(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];
}
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.setColor(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.renderColor(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.hasUnresolvedVars(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 swatch = new ColorRenderer(this.#pane, null).renderColorSwatch(initialColor);
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.setColorText(color.as(Common.Color.Format.HEXA));
return true;
}
}
return false;
};
return {placeholder: [swatch], 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-palette-values',
isDefined: this.#matchedStyles.fontPaletteValuesRule()?.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)),
};
case SDK.CSSPropertyParserMatchers.LinkableNameProperties.FUNCTION:
return {
jslogContext: 'css-function',
metric: null,
ruleBlock: '@function',
isDefined: Boolean(this.#matchedStyles.getRegisteredFunction(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.FUNCTION) {
const functionName = this.#matchedStyles.getRegisteredFunction(match.text);
if (!functionName) {
return;
}
this.#stylesPane.jumpToFunctionDefinition(functionName);
} 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 = IconButton.Icon.create('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): Node[] {
return [this.renderSwatch(match)];
}
renderSwatch(match: SDK.CSSPropertyParserMatchers.BezierMatch): Node {
if (!this.#treeElement?.editable() || !InlineEditor.AnimationTimingModel.AnimationTimingModel.parse(match.text)) {
return document.createTextNode(match.text);
}
const swatchPopoverHelper = this.#treeElement.parentPane().swatchPopoverHelper();
const swatch = InlineEditor.Swatches.BezierSwatch.create();
swatch.iconElement().addEventListener('click', () => {
Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.ANIMATION_TIMING);
});
swatch.setBezierText(match.text);
new BezierPopoverIcon({treeElement: this.#treeElement, swatchPopoverHelper, swatch});
return swatch;
}
}
// 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<Shado