UNPKG

chrome-devtools-frontend

Version:
254 lines (232 loc) • 10.8 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. 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}', }; 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: SDK.CSSPropertyParser.Constructor<MatchT>; render(match: MatchT, context: RenderingContext): Node[]; matcher(): SDK.CSSPropertyParser.Matcher<MatchT>; } // 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: SDK.CSSPropertyParser.Constructor<MatchT>) { abstract class RendererBase implements MatchRenderer<MatchT> { abstract matcher(): SDK.CSSPropertyParser.Matcher<MatchT>; readonly matchType = matchT; render(_match: MatchT, _context: RenderingContext): Node[] { return []; } } return RendererBase; } export class RenderingContext { constructor( readonly ast: SDK.CSSPropertyParser.SyntaxTree, readonly renderers: Map<SDK.CSSPropertyParser.Constructor<SDK.CSSPropertyParser.Match>, MatchRenderer<SDK.CSSPropertyParser.Match>>, readonly matchedResult: SDK.CSSPropertyParser.BottomUpTreeMatching, readonly cssControls?: SDK.CSSPropertyParser.CSSControlMap, readonly options: {readonly: boolean} = {readonly: false}) { } 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); } } } } export class Renderer extends SDK.CSSPropertyParser.TreeWalker { readonly #matchedResult: SDK.CSSPropertyParser.BottomUpTreeMatching; #output: Node[] = []; readonly #context: RenderingContext; constructor( ast: SDK.CSSPropertyParser.SyntaxTree, renderers: Map<SDK.CSSPropertyParser.Constructor<SDK.CSSPropertyParser.Match>, MatchRenderer<SDK.CSSPropertyParser.Match>>, matchedResult: SDK.CSSPropertyParser.BottomUpTreeMatching, cssControls: SDK.CSSPropertyParser.CSSControlMap, options: { readonly: boolean, }) { super(ast); this.#matchedResult = matchedResult; this.#context = new RenderingContext(this.ast, renderers, this.#matchedResult, cssControls, options); } 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.renderers, context.matchedResult, cssControls, context.options)); 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 SDK.CSSPropertyParser.Constructor<SDK.CSSPropertyParser.Match>); if (renderer || match instanceof SDK.CSSPropertyParser.TextMatch) { const output = renderer ? renderer.render(match, this.#context) : (match as SDK.CSSPropertyParser.TextMatch).render(); 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(); 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( propertyName: string, propertyValue: string, renderers: MatchRenderer<SDK.CSSPropertyParser.Match>[]): HTMLElement { 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: propertyValue})); valueElement.className = 'value'; const ast = SDK.CSSPropertyParser.tokenizeDeclaration(propertyName, propertyValue); if (!ast) { valueElement.appendChild(document.createTextNode(propertyValue)); return valueElement; } const matchers = []; const rendererMap = new Map< SDK.CSSPropertyParser.Constructor<SDK.CSSPropertyParser.Match>, MatchRenderer<SDK.CSSPropertyParser.Match>>(); for (const renderer of renderers) { const matcher = renderer.matcher(); matchers.push(matcher); rendererMap.set(renderer.matchType, renderer); } const matchedResult = SDK.CSSPropertyParser.BottomUpTreeMatching.walk(ast, matchers); ast.trailingNodes.forEach(n => matchedResult.matchText(n)); const context = new RenderingContext(ast, rendererMap, matchedResult); Renderer.render([ast.tree, ...ast.trailingNodes], context).nodes.forEach(node => valueElement.appendChild(node)); valueElement.normalize(); return valueElement; } } // 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]; } matcher(): SDK.CSSPropertyParserMatchers.URLMatcher { return new SDK.CSSPropertyParserMatchers.URLMatcher(); } } // 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]; } matcher(): SDK.CSSPropertyParserMatchers.StringMatcher { return new SDK.CSSPropertyParserMatchers.StringMatcher(); } }