UNPKG

chrome-devtools-frontend

Version:
1,202 lines (1,091 loc) • 127 kB
// 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