UNPKG

chrome-devtools-frontend

Version:
1,193 lines (1,070 loc) • 111 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. import * as Common from '../../core/common/common.js'; import * as Host from '../../core/host/host.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as Platform from '../../core/platform/platform.js'; import * as Root from '../../core/root/root.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as Bindings from '../../models/bindings/bindings.js'; import * as TextUtils from '../../models/text_utils/text_utils.js'; import type * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js'; import * as IconButton from '../../ui/components/icon_button/icon_button.js'; import * as ColorPicker from '../../ui/legacy/components/color_picker/color_picker.js'; import * as InlineEditor from '../../ui/legacy/components/inline_editor/inline_editor.js'; import * as UI from '../../ui/legacy/legacy.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, type Hint} from './CSSRuleValidator.js'; import {ElementsPanel} from './ElementsPanel.js'; import { type MatchRenderer, Renderer, rendererBase, RenderingContext, StringRenderer, 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 ASTUtils = SDK.CSSPropertyParser.ASTUtils; const FlexboxEditor = ElementsComponents.StylePropertyEditor.FlexboxEditor; const GridEditor = ElementsComponents.StylePropertyEditor.GridEditor; export const activeHints = new WeakMap<Element, Hint>(); const UIStrings = { /** *@description Text in Color Swatch Popover Icon of the Elements panel */ shiftClickToChangeColorFormat: 'Shift + Click to change color format.', /** *@description Swatch icon element title in Color Swatch Popover Icon of the Elements panel *@example {Shift + Click to change color format.} PH1 */ openColorPickerS: 'Open color picker. {PH1}', /** *@description Context menu item for style property in edit mode */ togglePropertyAndContinueEditing: 'Toggle property and continue editing', /** *@description Context menu item for style property in edit mode */ 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 copy all the CSS changes */ copyAllCSSChanges: 'Copy all CSS changes', /** *@description A context menu item in Styles panel to view the computed CSS property value. */ viewComputedValue: 'View computed value', /** * @description Title of the button that opens the flexbox editor in the Styles panel. */ flexboxEditorButton: 'Open `flexbox` editor', /** * @description Title of the button that opens the CSS Grid editor in the Styles panel. */ gridEditorButton: 'Open `grid` editor', /** *@description A context menu item in Styles panel to copy CSS declaration as JavaScript property. */ copyCssDeclarationAsJs: 'Copy declaration as JS', /** *@description A context menu item in Styles panel to copy all declarations of CSS rule as JavaScript properties. */ copyAllCssDeclarationsAsJs: 'Copy all declarations as JS', /** *@description Title of the link in Styles panel to jump to the Animations panel. */ jumpToAnimationsPanel: 'Jump to Animations panel', }; 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; constructor(treeElement: StylePropertyTreeElement) { super(); this.#treeElement = treeElement; } matcher(): SDK.CSSPropertyParser.Matcher<SDK.CSSPropertyParserMatchers.FlexGridMatch> { return new SDK.CSSPropertyParserMatchers.FlexGridMatcher(); } override render(match: SDK.CSSPropertyParserMatchers.FlexGridMatch, context: RenderingContext): Node[] { const key = `${this.#treeElement.section().getSectionIdx()}_${this.#treeElement.section().nextEditorTriggerButtonIdx}`; const button = StyleEditorWidget.createTriggerButton( this.#treeElement.parentPane(), this.#treeElement.section(), match.isFlex ? FlexboxEditor : GridEditor, match.isFlex ? i18nString(UIStrings.flexboxEditorButton) : i18nString(UIStrings.gridEditorButton), key); 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.#treeElement.parentPane().swatchPopoverHelper(); if (helper.isShowing(StyleEditorWidget.instance()) && StyleEditorWidget.instance().getTriggerKey() === key) { helper.setAnchorElement(button); } return [...Renderer.render(ASTUtils.siblings(ASTUtils.declValue(match.node)), context).nodes, button]; } } // clang-format off export class CSSWideKeywordRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.CSSWideKeywordMatch) { // clang-format on readonly #treeElement: StylePropertyTreeElement; constructor(treeElement: StylePropertyTreeElement) { super(); this.#treeElement = treeElement; } matcher(): SDK.CSSPropertyParser.Matcher<SDK.CSSPropertyParserMatchers.CSSWideKeywordMatch> { return new SDK.CSSPropertyParserMatchers.CSSWideKeywordMatcher( this.#treeElement.property, this.#treeElement.matchedStyles()); } 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(); UI.UIUtils.createTextChild(swatch, match.text); swatch.data = { text: match.text, isDefined: Boolean(resolvedProperty), onLinkActivate: () => resolvedProperty && this.#treeElement.parentPane().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.#treeElement).renderColorSwatch(color, swatch)]; } } return [swatch]; } } // clang-format off export class VariableRenderer extends rendererBase(SDK.CSSPropertyParser.VariableMatch) { // clang-format on readonly #treeElement: StylePropertyTreeElement; readonly #style: SDK.CSSStyleDeclaration.CSSStyleDeclaration; constructor(treeElement: StylePropertyTreeElement, style: SDK.CSSStyleDeclaration.CSSStyleDeclaration) { super(); this.#treeElement = treeElement; this.#style = style; } matcher(): SDK.CSSPropertyParser.VariableMatcher { return new SDK.CSSPropertyParser.VariableMatcher(this.computedText.bind(this)); } resolveVariable(match: SDK.CSSPropertyParser.VariableMatch): SDK.CSSMatchedStyles.CSSVariableValue|null { return this.#matchedStyles.computeCSSVariable(this.#style, match.name); } fallbackValue(match: SDK.CSSPropertyParser.VariableMatch): string|null { if (match.fallback.length === 0 || match.matching.hasUnresolvedVarsRange(match.fallback[0], match.fallback[match.fallback.length - 1])) { return null; } return match.matching.getComputedTextRange(match.fallback[0], match.fallback[match.fallback.length - 1]); } computedText(match: SDK.CSSPropertyParser.VariableMatch): string|null { return this.resolveVariable(match)?.value ?? this.fallbackValue(match); } override render(match: SDK.CSSPropertyParser.VariableMatch, context: RenderingContext): Node[] { const renderedFallback = match.fallback.length > 0 ? Renderer.render(match.fallback, context) : undefined; const {declaration, value: variableValue} = this.resolveVariable(match) ?? {}; const fromFallback = variableValue === undefined; const computedValue = variableValue ?? this.fallbackValue(match); const varSwatch = new InlineEditor.LinkSwatch.CSSVarSwatch(); varSwatch.data = { computedValue, variableName: match.name, fromFallback, fallbackText: match.fallback.map(n => context.ast.text(n)).join(' '), onLinkActivate: name => this.#handleVarDefinitionActivate(declaration ?? name), }; if (renderedFallback?.nodes.length) { // When slotting someting into the fallback slot, also emit text children so that .textContent produces the // correct var value. varSwatch.appendChild(document.createTextNode(`var(${match.name}`)); const span = varSwatch.appendChild(document.createElement('span')); span.appendChild(document.createTextNode(', ')); span.slot = 'fallback'; renderedFallback.nodes.forEach(n => span.appendChild(n)); varSwatch.appendChild(document.createTextNode(')')); } else { UI.UIUtils.createTextChild(varSwatch, match.text); } if (varSwatch.link) { this.#pane.addPopover(varSwatch.link, { contents: () => this.#treeElement.getVariablePopoverContents(match.name, variableValue ?? null), jslogContext: 'elements.css-var', }); } const color = computedValue && Common.Color.parse(computedValue); if (!color) { return [varSwatch]; } const colorSwatch = new ColorRenderer(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]; } get #pane(): StylesSidebarPane { return this.#treeElement.parentPane(); } get #matchedStyles(): SDK.CSSMatchedStyles.CSSMatchedStyles { return this.#treeElement.matchedStyles(); } #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.#pane.jumpToProperty(variable) || this.#pane.jumpToProperty('initial-value', variable, REGISTERED_PROPERTY_SECTION_NAME); } else if (variable.declaration instanceof SDK.CSSProperty.CSSProperty) { this.#pane.revealProperty(variable.declaration); } else if (variable.declaration instanceof SDK.CSSMatchedStyles.CSSRegisteredProperty) { this.#pane.jumpToProperty('initial-value', variable.name, REGISTERED_PROPERTY_SECTION_NAME); } } } // clang-format off export class LinearGradientRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.LinearGradientMatch) { // clang-format on matcher(): SDK.CSSPropertyParser.Matcher<SDK.CSSPropertyParserMatchers.LinearGradientMatch> { return new SDK.CSSPropertyParserMatchers.LinearGradientMatcher(); } 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, new Map([[angleMatch, ev.data.value]]))); }); } } return nodes; } } // clang-format off export class ColorRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.ColorMatch) { // clang-format on constructor(private readonly treeElement: StylePropertyTreeElement) { super(); } matcher(): SDK.CSSPropertyParserMatchers.ColorMatcher { const getCurrentColorCallback = (): string|null => this.treeElement.getComputedStyle('color'); return new SDK.CSSPropertyParserMatchers.ColorMatcher(getCurrentColorCallback); } #getValueChild(match: SDK.CSSPropertyParserMatchers.ColorMatch, context: RenderingContext): {valueChild: HTMLSpanElement, cssControls?: SDK.CSSPropertyParser.CSSControlMap} { const valueChild = document.createElement('span'); if (match.node.name === 'ColorLiteral' || match.node.name === 'ValueName') { valueChild.appendChild(document.createTextNode(match.text)); return {valueChild}; } const {cssControls} = Renderer.renderInto(ASTUtils.children(match.node), context, valueChild); return {valueChild, cssControls}; } override render(match: SDK.CSSPropertyParserMatchers.ColorMatch, context: RenderingContext): Node[] { const {valueChild, cssControls} = this.#getValueChild(match, context); let colorText = context.matchedResult.getComputedText(match.node); // Evaluate relative color values if (match.node.name === 'CallExpression' && colorText.match(/^[^)]*\(\W*from\W+/) && !context.matchedResult.hasUnresolvedVars(match.node) && CSS.supports('color', colorText)) { 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) { return [document.createTextNode(colorText)]; } 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); const onColorChanged = (): void => { void this.treeElement.applyStyleText(this.treeElement.renderedPropertyText(), false); }; swatch.addEventListener(InlineEditor.ColorSwatch.ClickEvent.eventName, () => { Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.COLOR); }); swatch.addEventListener(InlineEditor.ColorSwatch.ColorChangedEvent.eventName, onColorChanged); if (editable) { const swatchIcon = new ColorSwatchPopoverIcon(this.treeElement, this.treeElement.parentPane().swatchPopoverHelper(), swatch); swatchIcon.addEventListener(ColorSwatchPopoverIconEvents.COLOR_CHANGED, ev => { swatch.setColorText(ev.data); }); void this.#addColorContrastInfo(swatchIcon); } return swatch; } async #addColorContrastInfo(swatchIcon: ColorSwatchPopoverIcon): Promise<void> { const cssModel = this.treeElement.parentPane().cssModel(); const node = this.treeElement.node(); if (this.treeElement.property.name !== 'color' || !cssModel || !node || typeof node.id === 'undefined') { return; } const contrastInfo = new ColorPicker.ContrastInfo.ContrastInfo(await cssModel.getBackgroundColors(node.id)); swatchIcon.setContrastInfo(contrastInfo); } } // clang-format off export class LightDarkColorRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.LightDarkColorMatch) { // clang-format on readonly #treeElement: StylePropertyTreeElement; constructor(treeElement: StylePropertyTreeElement) { super(); this.#treeElement = treeElement; } matcher(): SDK.CSSPropertyParserMatchers.LightDarkColorMatcher { return new SDK.CSSPropertyParserMatchers.LightDarkColorMatcher(); } 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.#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.#treeElement.getComputedStyle('color-scheme')?.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.#treeElement.parentPane().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; constructor(pane: StylesSidebarPane) { super(); this.#pane = pane; } 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.Events.COLOR_CHANGED, ev => onChange(ev.data.text)); } const color = node.getText(); if (color) { onChange(color); return true; } } return false; }; const contentChild = document.createElement('span'); contentChild.appendChild(document.createTextNode('color-mix(')); Renderer.renderInto(match.space, context, contentChild); contentChild.appendChild(document.createTextNode(', ')); const color1 = Renderer.renderInto(match.color1, context, contentChild).cssControls.get('color') ?? []; contentChild.appendChild(document.createTextNode(', ')); const color2 = Renderer.renderInto(match.color2, context, contentChild).cssControls.get('color') ?? []; contentChild.appendChild(document.createTextNode(')')); if (context.matchedResult.hasUnresolvedVars(match.node) || color1.length !== 1 || color2.length !== 1) { return [contentChild]; } const swatch = new InlineEditor.ColorMixSwatch.ColorMixSwatch(); if (!hookUpColorArg(color1[0], text => swatch.setFirstColor(text)) || !hookUpColorArg(color2[0], text => swatch.setSecondColor(text))) { return [contentChild]; } const space = 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(' '); swatch.appendChild(contentChild); swatch.setColorMixText(`color-mix(${space}, ${color1Text}, ${color2Text})`); swatch.setRegisterPopoverCallback(swatch => { if (swatch.icon) { this.#pane.addPopover(swatch.icon, { contents: () => { const color = swatch.mixedColor(); if (!color) { return undefined; } const span = document.createElement('span'); span.style.padding = '11px 7px'; const rgb = color.as(Common.Color.Format.HEX); const text = rgb.isGamutClipped() ? color.asString() : rgb.asString(); if (!text) { return undefined; } span.appendChild(document.createTextNode(text)); return span; }, jslogContext: 'elements.css-color-mix', }); } }); context.addControl('color', swatch); return [swatch]; } matcher(): SDK.CSSPropertyParserMatchers.ColorMixMatcher { return new SDK.CSSPropertyParserMatchers.ColorMixMatcher(); } } // clang-format off export class AngleRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.AngleMatch) { // clang-format on readonly #treeElement: StylePropertyTreeElement; constructor(treeElement: StylePropertyTreeElement) { 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); cssAngle.addEventListener('popovertoggled', ({data}) => { const section = this.#treeElement.section(); if (!section) { return; } if (data.open) { this.#treeElement.parentPane().hideAllPopovers(); this.#treeElement.parentPane().activeCSSAngle = cssAngle; Host.userMetrics.swatchActivated(Host.UserMetrics.SwatchType.ANGLE); } section.element.classList.toggle('has-open-popover', data.open); this.#treeElement.parentPane().setEditingStyle(data.open); // Commit the value as a major change after the angle popover is closed. if (!data.open) { void this.#treeElement.applyStyleText(this.#treeElement.renderedPropertyText(), true); } }); cssAngle.addEventListener('valuechanged', async ({data}) => { valueElement.textContent = data.value; await this.#treeElement.applyStyleText(this.#treeElement.renderedPropertyText(), false); }); cssAngle.addEventListener('unitchanged', ({data}) => { valueElement.textContent = data.value; }); context.addControl('angle', cssAngle); return [cssAngle]; } matcher(): SDK.CSSPropertyParserMatchers.AngleMatcher { return new SDK.CSSPropertyParserMatchers.AngleMatcher(); } } // clang-format off export class LinkableNameRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.LinkableNameMatch) { // clang-format on readonly #treeElement: StylePropertyTreeElement; constructor(treeElement: StylePropertyTreeElement) { super(); this.#treeElement = treeElement; } #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.#treeElement.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.#treeElement.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.#treeElement.matchedStyles().positionTryRules().find(pt => pt.name().text === match.text)), }; } } override render(match: SDK.CSSPropertyParserMatchers.LinkableNameMatch): Node[] { const swatch = new InlineEditor.LinkSwatch.LinkSwatch(); UI.UIUtils.createTextChild(swatch, match.text); const {metric, jslogContext, ruleBlock, isDefined} = this.#getLinkData(match); swatch.data = { text: match.text, isDefined, onLinkActivate: (): void => { metric && Host.userMetrics.swatchActivated(metric); this.#treeElement.parentPane().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.#treeElement.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]; } matcher(): SDK.CSSPropertyParserMatchers.LinkableNameMatcher { return new SDK.CSSPropertyParserMatchers.LinkableNameMatcher(); } } // clang-format off export class BezierRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.BezierMatch) { // clang-format on readonly #treeElement: StylePropertyTreeElement; constructor(treeElement: StylePropertyTreeElement) { 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; } matcher(): SDK.CSSPropertyParserMatchers.BezierMatcher { return new SDK.CSSPropertyParserMatchers.BezierMatcher(); } } // clang-format off export class AutoBaseRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.AutoBaseMatch) { // clang-format on readonly #treeElement: StylePropertyTreeElement; constructor(treeElement: StylePropertyTreeElement) { super(); this.#treeElement = treeElement; } matcher(): SDK.CSSPropertyParserMatchers.AutoBaseMatcher { return new SDK.CSSPropertyParserMatchers.AutoBaseMatcher(); } 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.#treeElement.getComputedStyle('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(parent: HTMLElement): void { parent.removeChildren(); const span = parent.createChild('span'); 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) { // clang-format on readonly #treeElement: StylePropertyTreeElement; constructor(treeElement: StylePropertyTreeElement) { super(); this.#treeElement = treeElement; } shadowModel( shadow: CodeMirror.SyntaxNode[], shadowType: SDK.CSSPropertyParserMatchers.ShadowType, context: RenderingContext): null|ShadowModel { const properties: Array<ShadowProperty|ShadowLengthProperty> = []; const missingLengths: 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: { 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.CSSPropertyParser.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.hasUnresolvedVars(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, 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) { 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); }); model.renderContents(swatch); const popoverHelper = new ShadowSwatchPopoverHelper( this.#treeElement, this.#treeElement.parentPane().swatchPopoverHelper(), swatch); popoverHelper.addEventListener(ShadowEvents.SHADOW_CHANGED, () => { model.renderContents(swatch); void this.#treeElement.applyStyleText(this.#treeElement.renderedPropertyText(), false); }); result.push(swatch); if (isImportant) { result.push(...[document.createTextNode(' '), ...Renderer.render(isImportant, context).nodes]); } } return result; } matcher(): SDK.CSSPropertyParserMatchers.ShadowMatcher { return new SDK.CSSPropertyParserMatchers.ShadowMatcher(); } } // 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; } matcher(): SDK.CSSPropertyParserMatchers.FontMatcher { return new SDK.CSSPropertyParserMatchers.FontMatcher(); } } // 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