UNPKG

chrome-devtools-frontend

Version:
590 lines (540 loc) • 25 kB
// Copyright 2024 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 */ import * as Common from '../../core/common/common.js'; import * as i18n from '../../core/i18n/i18n.js'; import type * as Platform from '../../core/platform/platform.js'; import * as SDK from '../../core/sdk/sdk.js'; import type * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js'; import * as Components from '../../ui/legacy/components/utils/utils.js'; import * as UI from '../../ui/legacy/legacy.js'; import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import {ImagePreviewPopover} from './ImagePreviewPopover.js'; import {unescapeCssString} from './StylesSidebarPane.js'; const UIStrings = { /** *@description Text that is announced by the screen reader when the user focuses on an input field for entering the name of a CSS property in the Styles panel *@example {margin} PH1 */ cssPropertyName: '`CSS` property name: {PH1}', /** *@description Text that is announced by the screen reader when the user focuses on an input field for entering the value of a CSS property in the Styles panel *@example {10px} PH1 */ cssPropertyValue: '`CSS` property value: {PH1}', } as const; const str_ = i18n.i18n.registerUIStrings('panels/elements/PropertyRenderer.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); function mergeWithSpacing(nodes: Node[], merge: Node[]): Node[] { const result = [...nodes]; if (SDK.CSSPropertyParser.requiresSpace(nodes, merge)) { result.push(document.createTextNode(' ')); } result.push(...merge); return result; } export interface MatchRenderer<MatchT extends SDK.CSSPropertyParser.Match> { readonly matchType: Platform.Constructor.Constructor<MatchT>; render(match: MatchT, context: RenderingContext): Node[]; } // A mixin to automatically expose the match type on specific renrerers // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function rendererBase<MatchT extends SDK.CSSPropertyParser.Match>( matchT: Platform.Constructor.Constructor<MatchT>) { abstract class RendererBase implements MatchRenderer<MatchT> { readonly matchType = matchT; render(_match: MatchT, _context: RenderingContext): Node[] { return []; } } return RendererBase; } // This class implements highlighting for rendered nodes in value traces. On hover, all nodes belonging to the same // Match (using object identity) are highlighted. export class Highlighting { static readonly REGISTRY_NAME = 'css-value-tracing'; // This holds a stack of active ranges, the top-stack is the currently highlighted set. mouseenter and mouseleave // push and pop range sets, respectively. readonly #activeHighlights: Range[][] = []; // We hold a bidirectional mapping between nodes and matches. A node can belong to multiple matches when matches are // nested (via function arguments for instance). readonly #nodesForMatches = new Map<SDK.CSSPropertyParser.Match, Node[][]>(); readonly #matchesForNodes = new Map<Node, SDK.CSSPropertyParser.Match[]>(); readonly #registry: Highlight; readonly #boundOnEnter: (e: Event) => void; readonly #boundOnExit: (e: Event) => void; constructor() { const registry = CSS.highlights.get(Highlighting.REGISTRY_NAME); this.#registry = registry ?? new Highlight(); if (!registry) { CSS.highlights.set(Highlighting.REGISTRY_NAME, this.#registry); } this.#boundOnExit = this.#onExit.bind(this); this.#boundOnEnter = this.#onEnter.bind(this); } addMatch(match: SDK.CSSPropertyParser.Match, nodes: Node[]): void { if (nodes.length > 0) { const ranges = this.#nodesForMatches.get(match); if (ranges) { ranges.push(nodes); } else { this.#nodesForMatches.set(match, [nodes]); } } for (const node of nodes) { const matches = this.#matchesForNodes.get(node); if (matches) { matches.push(match); } else { this.#matchesForNodes.set(node, [match]); } if (node instanceof HTMLElement) { node.onmouseenter = this.#boundOnEnter; node.onmouseleave = this.#boundOnExit; node.onfocus = this.#boundOnEnter; node.onblur = this.#boundOnExit; node.tabIndex = 0; } } } * #nodeRangesHitByMouseEvent(e: Event): Generator<Node[]> { for (const node of e.composedPath()) { const matches = this.#matchesForNodes.get(node as Node); if (matches) { for (const match of matches) { yield* this.#nodesForMatches.get(match) ?? []; } break; } } } #onEnter(e: Event): void { this.#registry.clear(); this.#activeHighlights.push([]); for (const nodeRange of this.#nodeRangesHitByMouseEvent(e)) { const range = new Range(); const begin = nodeRange[0]; const end = nodeRange[nodeRange.length - 1]; if (begin.parentNode && end.parentNode) { range.setStartBefore(begin); range.setEndAfter(end); this.#activeHighlights[this.#activeHighlights.length - 1].push(range); this.#registry.add(range); } } } #onExit(): void { this.#registry.clear(); this.#activeHighlights.pop(); if (this.#activeHighlights.length > 0) { this.#activeHighlights[this.#activeHighlights.length - 1].forEach(range => this.#registry.add(range)); } } } // This class is used to guide value tracing when passed to the Renderer. Tracing has two phases. First, substitutions // such as var() are applied step by step. In each step, all vars in the value are replaced by their definition until no // vars remain. In the second phase, we evaluate other functions such as calc() or min() or color-mix(). Which CSS // function types are actually substituted or evaluated is not relevant here, rather it is decided by an individual // MatchRenderer. // // Callers don't need to keep track of the tracing depth (i.e., the number of substitution/evaluation steps). // TracingContext is stateful and keeps track of the depth, so callers can progressively produce steps by calling // TracingContext#nextSubstitution or TracingContext#nextEvaluation. Calling Renderer with the tracing context will then // produce the next step of tracing. The tracing depth is passed to the individual MatchRenderers by way of // TracingContext#substitution or TracingContext#applyEvaluation/TracingContext#evaluation (see function-level comments // about how these two play together), which MatchRenderers call to request a fresh TracingContext for the next level of // substitution/evaluation. export class TracingContext { #substitutionDepth = 0; #hasMoreSubstitutions: boolean; #parent: TracingContext|null = null; #evaluationCount = 0; #appliedEvaluations = 0; #hasMoreEvaluations = true; #longhandOffset: number; readonly #highlighting: Highlighting; #parsedValueCache = new Map<SDK.CSSProperty.CSSProperty|SDK.CSSMatchedStyles.CSSRegisteredProperty, { matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, computedStyles: Map<string, string>, parsedValue: SDK.CSSPropertyParser.BottomUpTreeMatching|null, }>(); #root: {match: SDK.CSSPropertyParser.Match, context: RenderingContext}|null = null; #propertyName: string|null; #asyncEvalCallbacks: Array<(() => Promise<boolean>)|undefined> = []; readonly expandPercentagesInShorthands: boolean; constructor( highlighting: Highlighting, expandPercentagesInShorthands: boolean, initialLonghandOffset = 0, matchedResult?: SDK.CSSPropertyParser.BottomUpTreeMatching) { this.#highlighting = highlighting; this.#hasMoreSubstitutions = matchedResult?.hasMatches( SDK.CSSPropertyParserMatchers.VariableMatch, SDK.CSSPropertyParserMatchers.BaseVariableMatch) ?? false; this.#propertyName = matchedResult?.ast.propertyName ?? null; this.#longhandOffset = initialLonghandOffset; this.expandPercentagesInShorthands = expandPercentagesInShorthands; } get highlighting(): Highlighting { return this.#highlighting; } get root(): {match: SDK.CSSPropertyParser.Match, context: RenderingContext}|null { return this.#root; } get propertyName(): string|null { return this.#propertyName; } get longhandOffset(): number { return this.#longhandOffset; } renderingContext(context: RenderingContext): RenderingContext { return new RenderingContext( context.ast, context.property, context.renderers, context.matchedResult, context.cssControls, context.options, this); } nextSubstitution(): boolean { if (!this.#hasMoreSubstitutions) { return false; } this.#substitutionDepth++; this.#hasMoreSubstitutions = false; this.#asyncEvalCallbacks = []; return true; } nextEvaluation(): boolean { if (this.#hasMoreSubstitutions) { throw new Error('Need to apply substitutions first'); } if (!this.#hasMoreEvaluations) { return false; } this.#appliedEvaluations = 0; this.#hasMoreEvaluations = false; this.#evaluationCount++; this.#asyncEvalCallbacks = []; return true; } didApplyEvaluations(): boolean { return this.#appliedEvaluations > 0; } #setHasMoreEvaluations(value: boolean): void { if (this.#parent) { this.#parent.#setHasMoreEvaluations(value); } this.#hasMoreEvaluations = value; } // Evaluations are applied bottom up, i.e., innermost sub-expressions are evaluated first before evaluating any // function call. This function produces TracingContexts for each of the arguments of the function call which should // be passed to the Renderer calls for the respective subtrees. evaluation(args: unknown[], root: {match: SDK.CSSPropertyParser.Match, context: RenderingContext}|null = null): TracingContext[]|null { const childContexts = args.map(() => { const child = new TracingContext(this.#highlighting, this.expandPercentagesInShorthands); child.#parent = this; child.#substitutionDepth = this.#substitutionDepth; child.#evaluationCount = this.#evaluationCount; child.#hasMoreSubstitutions = this.#hasMoreSubstitutions; child.#parsedValueCache = this.#parsedValueCache; child.#root = root; child.#propertyName = this.propertyName; return child; }); return childContexts; } #setAppliedEvaluations(value: number): void { if (this.#parent) { this.#parent.#setAppliedEvaluations(value); } this.#appliedEvaluations = Math.max(this.#appliedEvaluations, value); } // After rendering the arguments of a function call, the TracingContext produced by TracingContext#evaluation need to // be passed here to determine whether the "current" function call should be evaluated or not. If so, the // evaluation callback is run. The callback should return synchronously an array of Nodes as placeholder to be // rendered immediately and optionally a callback for asynchronous updates of the placeholder nodes. The callback // returns a boolean indicating whether the update was successful or not. applyEvaluation( children: TracingContext[], evaluation: () => ({placeholder: Node[], asyncEvalCallback?: () => Promise<boolean>})): Node[]|null { if (this.#evaluationCount === 0 || children.some(child => child.#appliedEvaluations >= this.#evaluationCount)) { this.#setHasMoreEvaluations(true); children.forEach(child => this.#asyncEvalCallbacks.push(...child.#asyncEvalCallbacks)); return null; } this.#setAppliedEvaluations( children.map(child => child.#appliedEvaluations).reduce((a, b) => Math.max(a, b), 0) + 1); const {placeholder, asyncEvalCallback} = evaluation(); this.#asyncEvalCallbacks.push(asyncEvalCallback); return placeholder; } #setHasMoreSubstitutions(): void { if (this.#parent) { this.#parent.#setHasMoreSubstitutions(); } this.#hasMoreSubstitutions = true; } // Request a tracing context for the next level of substitutions. If this returns null, no further substitution should // be applied on this branch of the AST. Otherwise, the TracingContext should be passed to the Renderer call for the // substitution subtree. substitution(root: {match: SDK.CSSPropertyParser.Match, context: RenderingContext}|null = null): TracingContext|null { if (this.#substitutionDepth <= 0) { this.#setHasMoreSubstitutions(); return null; } const child = new TracingContext(this.#highlighting, this.expandPercentagesInShorthands); child.#parent = this; child.#substitutionDepth = this.#substitutionDepth - 1; child.#evaluationCount = this.#evaluationCount; child.#hasMoreSubstitutions = false; child.#parsedValueCache = this.#parsedValueCache; child.#root = root; // Async evaluation callbacks need to be gathered across substitution contexts so that they bubble to the root. That // is not the case for evaluation contexts since `applyEvaluation` conditionally collects callbacks for its subtree // already. child.#asyncEvalCallbacks = this.#asyncEvalCallbacks; child.#longhandOffset = this.#longhandOffset + (root?.context.matchedResult.getComputedLonghandName(root?.match.node) ?? 0); child.#propertyName = this.propertyName; return child; } cachedParsedValue( declaration: SDK.CSSProperty.CSSProperty|SDK.CSSMatchedStyles.CSSRegisteredProperty, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, computedStyles: Map<string, string>): SDK.CSSPropertyParser.BottomUpTreeMatching|null { const cachedValue = this.#parsedValueCache.get(declaration); if (cachedValue?.matchedStyles === matchedStyles && cachedValue?.computedStyles === computedStyles) { return cachedValue.parsedValue; } const parsedValue = declaration.parseValue(matchedStyles, computedStyles); this.#parsedValueCache.set(declaration, {matchedStyles, computedStyles, parsedValue}); return parsedValue; } // If this returns `false`, all evaluations for this trace line have failed. async runAsyncEvaluations(): Promise<boolean> { const results = await Promise.all(this.#asyncEvalCallbacks.map(callback => callback?.())); return results.some(result => result !== false); } } export class RenderingContext { constructor( readonly ast: SDK.CSSPropertyParser.SyntaxTree, readonly property: SDK.CSSProperty.CSSProperty|null, readonly renderers: Map<Platform.Constructor.Constructor<SDK.CSSPropertyParser.Match>, MatchRenderer<SDK.CSSPropertyParser.Match>>, readonly matchedResult: SDK.CSSPropertyParser.BottomUpTreeMatching, readonly cssControls?: SDK.CSSPropertyParser.CSSControlMap, readonly options: {readonly?: boolean} = {}, readonly tracing?: TracingContext) { } addControl(cssType: string, control: HTMLElement): void { if (this.cssControls) { const controls = this.cssControls.get(cssType); if (!controls) { this.cssControls.set(cssType, [control]); } else { controls.push(control); } } } getComputedLonghandName(node: CodeMirror.SyntaxNode): string|null { if (!this.matchedResult.ast.propertyName) { return null; } const longhands = SDK.CSSMetadata.cssMetadata().getLonghands(this.tracing?.propertyName ?? this.matchedResult.ast.propertyName); if (!longhands) { return null; } const index = this.matchedResult.getComputedLonghandName(node); return longhands[index + (this.tracing?.longhandOffset ?? 0)] ?? null; } findParent<MatchT extends SDK.CSSPropertyParser.Match>( node: CodeMirror.SyntaxNode|null, matchType: Platform.Constructor.Constructor<MatchT>): MatchT|null { while (node) { const match = this.matchedResult.getMatch(node); if (match instanceof matchType) { return match; } node = node.parent; } if (this.tracing?.root) { return this.tracing.root.context.findParent(this.tracing.root.match.node, matchType); } return null; } } export class Renderer extends SDK.CSSPropertyParser.TreeWalker { readonly #matchedResult: SDK.CSSPropertyParser.BottomUpTreeMatching; #output: Node[] = []; readonly #context: RenderingContext; constructor( ast: SDK.CSSPropertyParser.SyntaxTree, property: SDK.CSSProperty.CSSProperty|null, renderers: Map<Platform.Constructor.Constructor<SDK.CSSPropertyParser.Match>, MatchRenderer<SDK.CSSPropertyParser.Match>>, matchedResult: SDK.CSSPropertyParser.BottomUpTreeMatching, cssControls: SDK.CSSPropertyParser.CSSControlMap, options: { readonly?: boolean, }, tracing: TracingContext|undefined, ) { super(ast); this.#matchedResult = matchedResult; this.#context = new RenderingContext(this.ast, property, renderers, this.#matchedResult, cssControls, options, tracing); } static render(nodeOrNodes: CodeMirror.SyntaxNode|CodeMirror.SyntaxNode[], context: RenderingContext): {nodes: Node[], cssControls: SDK.CSSPropertyParser.CSSControlMap} { if (!Array.isArray(nodeOrNodes)) { return this.render([nodeOrNodes], context); } const cssControls = new SDK.CSSPropertyParser.CSSControlMap(); const renderers = nodeOrNodes.map( node => this.walkExcludingSuccessors( context.ast.subtree(node), context.property, context.renderers, context.matchedResult, cssControls, context.options, context.tracing)); const nodes = renderers.map(node => node.#output).reduce(mergeWithSpacing); return {nodes, cssControls}; } static renderInto( nodeOrNodes: CodeMirror.SyntaxNode|CodeMirror.SyntaxNode[], context: RenderingContext, parent: Node): {nodes: Node[], cssControls: SDK.CSSPropertyParser.CSSControlMap} { const {nodes, cssControls} = this.render(nodeOrNodes, context); if (parent.lastChild && SDK.CSSPropertyParser.requiresSpace([parent.lastChild], nodes)) { parent.appendChild(document.createTextNode(' ')); } nodes.map(n => parent.appendChild(n)); return {nodes, cssControls}; } renderedMatchForTest(_nodes: Node[], _match: SDK.CSSPropertyParser.Match): void { } protected override enter({node}: SDK.CSSPropertyParser.SyntaxNodeRef): boolean { const match = this.#matchedResult.getMatch(node); const renderer = match && this.#context.renderers.get(match.constructor as Platform.Constructor.Constructor<SDK.CSSPropertyParser.Match>); if (renderer || match instanceof SDK.CSSPropertyParserMatchers.TextMatch) { const output = renderer ? renderer.render(match, this.#context) : (match as SDK.CSSPropertyParserMatchers.TextMatch).render(); this.#context.tracing?.highlighting.addMatch(match, output); this.renderedMatchForTest(output, match); this.#output = mergeWithSpacing(this.#output, output); return false; } return true; } static renderNameElement(name: string): HTMLElement { const nameElement = document.createElement('span'); nameElement.setAttribute( 'jslog', `${VisualLogging.key().track({ change: true, keydown: 'ArrowLeft|ArrowUp|PageUp|Home|PageDown|ArrowRight|ArrowDown|End|Space|Tab|Enter|Escape', })}`); UI.ARIAUtils.setLabel(nameElement, i18nString(UIStrings.cssPropertyName, {PH1: name})); nameElement.className = 'webkit-css-property'; nameElement.textContent = name; nameElement.normalize(); nameElement.tabIndex = -1; return nameElement; } // This function renders a property value as HTML, customizing the presentation with a set of given AST matchers. This // comprises the following steps: // 1. Build an AST of the property. // 2. Apply tree matchers during bottom up traversal. // 3. Render the value from left to right into HTML, deferring rendering of matched subtrees to the matchers // // More general, longer matches take precedence over shorter, more specific matches. Whitespaces are normalized, for // unmatched text and around rendered matching results. static renderValueElement( property: SDK.CSSProperty.CSSProperty|{name: string, value: string}, matchedResult: SDK.CSSPropertyParser.BottomUpTreeMatching|null, renderers: Array<MatchRenderer<SDK.CSSPropertyParser.Match>>, tracing?: TracingContext): {valueElement: HTMLElement, cssControls: SDK.CSSPropertyParser.CSSControlMap} { const valueElement = document.createElement('span'); valueElement.setAttribute( 'jslog', `${VisualLogging.value().track({ change: true, keydown: 'ArrowLeft|ArrowUp|PageUp|Home|PageDown|ArrowRight|ArrowDown|End|Space|Tab|Enter|Escape', })}`); UI.ARIAUtils.setLabel(valueElement, i18nString(UIStrings.cssPropertyValue, {PH1: property.value})); valueElement.className = 'value'; valueElement.tabIndex = -1; const {nodes, cssControls} = this.renderValueNodes(property, matchedResult, renderers, tracing); nodes.forEach(node => valueElement.appendChild(node)); valueElement.normalize(); return {valueElement, cssControls}; } static renderValueNodes( property: SDK.CSSProperty.CSSProperty|{name: string, value: string}, matchedResult: SDK.CSSPropertyParser.BottomUpTreeMatching|null, renderers: Array<MatchRenderer<SDK.CSSPropertyParser.Match>>, tracing?: TracingContext): {nodes: Node[], cssControls: SDK.CSSPropertyParser.CSSControlMap} { if (!matchedResult) { return {nodes: [document.createTextNode(property.value)], cssControls: new Map()}; } const rendererMap = new Map< Platform.Constructor.Constructor<SDK.CSSPropertyParser.Match>, MatchRenderer<SDK.CSSPropertyParser.Match>>(); for (const renderer of renderers) { rendererMap.set(renderer.matchType, renderer); } const context = new RenderingContext( matchedResult.ast, property instanceof SDK.CSSProperty.CSSProperty ? property : null, rendererMap, matchedResult, undefined, {}, tracing); return Renderer.render([matchedResult.ast.tree, ...matchedResult.ast.trailingNodes], context); } } // clang-format off export class URLRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.URLMatch) { // clang-format on constructor(private readonly rule: SDK.CSSRule.CSSRule|null, private readonly node: SDK.DOMModel.DOMNode|null) { super(); } override render(match: SDK.CSSPropertyParserMatchers.URLMatch): Node[] { const url = unescapeCssString(match.url) as Platform.DevToolsPath.UrlString; const container = document.createDocumentFragment(); UI.UIUtils.createTextChild(container, 'url('); let hrefUrl: Platform.DevToolsPath.UrlString|null = null; if (this.rule && this.rule.resourceURL()) { hrefUrl = Common.ParsedURL.ParsedURL.completeURL(this.rule.resourceURL(), url); } else if (this.node) { hrefUrl = this.node.resolveURL(url); } const link = ImagePreviewPopover.setImageUrl( Components.Linkifier.Linkifier.linkifyURL(hrefUrl || url, { text: url, preventClick: false, // crbug.com/1027168 // We rely on CSS text-overflow: ellipsis to hide long URLs in the Style panel, // so that we don't have to keep two versions (original vs. trimmed) of URL // at the same time, which complicates both StylesSidebarPane and StylePropertyTreeElement. bypassURLTrimming: true, showColumnNumber: false, inlineFrameIndex: 0, }), hrefUrl || url); container.appendChild(link); UI.UIUtils.createTextChild(container, ')'); return [container]; } } // clang-format off export class StringRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.StringMatch) { // clang-format on override render(match: SDK.CSSPropertyParserMatchers.StringMatch): Node[] { const element = document.createElement('span'); element.innerText = match.text; UI.Tooltip.Tooltip.install(element, unescapeCssString(match.text)); return [element]; } } // clang-format off export class BinOpRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.BinOpMatch) { // clang-format on override render(match: SDK.CSSPropertyParserMatchers.BinOpMatch, context: RenderingContext): Node[] { const [lhs, binop, rhs] = SDK.CSSPropertyParser.ASTUtils.children(match.node).map(child => { const span = document.createElement('span'); Renderer.renderInto(child, context, span); return span; }); return [lhs, document.createTextNode(' '), binop, document.createTextNode(' '), rhs]; } }