UNPKG

chrome-devtools-frontend

Version:
307 lines (271 loc) • 11.2 kB
// Copyright 2014 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable @devtools/no-imperative-dom-api */ import * as Common from '../../../../core/common/common.js'; import * as i18n from '../../../../core/i18n/i18n.js'; import type * as SDK from '../../../../core/sdk/sdk.js'; import type * as Protocol from '../../../../generated/protocol.js'; import {Directives, html, type LitTemplate, nothing, render} from '../../../lit/lit.js'; import * as UI from '../../legacy.js'; import {sanitizeStyle} from './CSSStyleSanitizer.js'; import customPreviewComponentStyles from './customPreviewComponent.css.js'; import { defaultObjectPresentation, ObjectPropertiesMode, ObjectPropertiesSectionWidget, ObjectTree, } from './ObjectPropertiesSection.js'; const UIStrings = { /** * @description Context menu item to show a custom formatted object as a standard JavaScript object. */ showAsJavascriptObject: 'Show as JavaScript object', } as const; const str_ = i18n.i18n.registerUIStrings('ui/legacy/components/object_ui/CustomPreviewComponent.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export class CustomPreviewSection extends UI.Widget.Widget { #object?: SDK.RemoteObject.RemoteObject; private expanded = false; private cachedContent?: unknown|ObjectTree; private headerJsonML?: unknown; private readonly view: View; constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) { super(element); this.view = view; } get object(): SDK.RemoteObject.RemoteObject|undefined { return this.#object; } set object(object: SDK.RemoteObject.RemoteObject|undefined) { if (this.#object === object) { return; } this.#object = object; this.headerJsonML = undefined; this.cachedContent = undefined; this.expanded = false; this.parseHeader(); // CustomPreviewComponent is used synchronously by ConsoleViewMessage. We must render synchronously // so ConsoleViewport can measure the true row height upon insertion. this.performUpdate(); } private parseHeader(): void { const customPreview = this.#object?.customPreview(); if (!customPreview) { return; } try { this.headerJsonML = JSON.parse(customPreview.header); } catch (e) { Common.Console.Console.instance().error('Broken formatter: header is invalid json ' + e); } } override performUpdate(): void { this.view({ object: this.#object, headerJsonML: this.headerJsonML, expanded: this.expanded, cachedContent: this.cachedContent, toggleExpanded: this.toggleExpanded, }, undefined, this.contentElement); } private toggleExpanded = (): void => { if (this.cachedContent !== undefined) { this.toggleExpand(); } else { void this.loadBody(); } }; private toggleExpand(): void { this.expanded = !this.expanded; this.performUpdate(); } async loadBody(): Promise<void> { const customPreview = this.#object?.customPreview(); if (!this.#object || !customPreview?.bodyGetterId) { return; } const bodyJsonML = await this.#object.callFunctionJSON(bodyGetter => bodyGetter(), [{objectId: customPreview.bodyGetterId}]); if (bodyJsonML === null) { // Per https://firefox-source-docs.mozilla.org/devtools-user/custom_formatters/index.html#custom-formatter-structure // we are supposed to fall back to the default format when the `body()` callback returns `null`. const objectTree = new ObjectTree(this.#object, { readOnly: true, propertiesMode: ObjectPropertiesMode.OWN_AND_INTERNAL_AND_INHERITED, }); objectTree.expanded = true; this.cachedContent = objectTree; } else { this.cachedContent = bodyJsonML; } this.expanded = true; this.performUpdate(); } } const ALLOWED_TAGS = ['span', 'div', 'ol', 'li', 'table', 'tr', 'td']; export interface ViewInput { object?: SDK.RemoteObject.RemoteObject; headerJsonML?: unknown; expanded: boolean; cachedContent?: unknown|ObjectTree|null; toggleExpanded: () => void; } export const DEFAULT_VIEW = (input: ViewInput, _output: undefined, target: HTMLElement): void => { const renderJSONMLTag = (object: SDK.RemoteObject.RemoteObject, jsonML: unknown): LitTemplate => { if (!Array.isArray(jsonML)) { return html`${String(jsonML)}`; } if (jsonML[0] !== 'object') { return renderElement(object, jsonML); } if (jsonML.length !== 2) { Common.Console.Console.instance().error('Broken formatter: object reference must contain exactly two elements'); return html`<span></span>`; } return layoutObjectTag(object, jsonML); }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const renderElement = (object: SDK.RemoteObject.RemoteObject, jsonML: any[]): LitTemplate => { const it = jsonML[Symbol.iterator](); const tagName = it.next().value as string; if (!ALLOWED_TAGS.includes(tagName)) { Common.Console.Console.instance().error('Broken formatter: element ' + tagName + ' is not allowed!'); return html`<span></span>`; } let next = it.next(); const stylePropertyMap: Record<string, string> = {}; if (typeof next.value === 'object' && !Array.isArray(next.value) && next.value !== null) { const attributes = next.value as Record<string, string>; for (const key in attributes) { const value = attributes[key]; if ((key !== 'style') || (typeof value !== 'string')) { continue; } const sanitizedStyle = new Map<string, {value: string, priority: string}>(); sanitizeStyle(sanitizedStyle, value); for (const [property, {value: propertyValue, priority}] of sanitizedStyle) { stylePropertyMap[property] = priority ? `${propertyValue} !${priority}` : propertyValue; } } next = it.next(); } const children: LitTemplate[] = []; while (!next.done) { children.push(renderJSONMLTag(object, next.value)); next = it.next(); } const style = Directives.styleMap(stylePropertyMap); switch (tagName) { case 'span': return html`<span style=${style}>${children}</span>`; case 'div': return html`<div style=${style}>${children}</div>`; case 'ol': return html`<ol style=${style}>${children}</ol>`; case 'li': return html`<li style=${style}>${children}</li>`; case 'table': return html`<table style=${style}>${children}</table>`; case 'tr': return html`<tr style=${style}>${children}</tr>`; case 'td': return html`<td style=${style}>${children}</td>`; default: return html`<span>${children}</span>`; } }; const layoutObjectTag = (object: SDK.RemoteObject.RemoteObject, objectTag: unknown[]): LitTemplate => { const it = objectTag[Symbol.iterator](); it.next(); // skip 'object' const attributes = it.next().value; const remoteObject = object.runtimeModel().createRemoteObject((attributes as Protocol.Runtime.RemoteObject)); if (remoteObject.customPreview()) { return html`${UI.Widget.widget(CustomPreviewSection, {object: remoteObject})}`; } return defaultObjectPresentation(remoteObject, undefined, undefined, undefined, {'custom-expandable-section-standard-section': remoteObject.hasChildren}); }; const onClick = (event: Event): void => { event.consume(true); input.toggleExpanded(); }; const object = input.object; const customPreview = object?.customPreview(); if (!object || !customPreview || !input.headerJsonML) { render(nothing, target); return; } const headerTemplate = renderJSONMLTag(object, input.headerJsonML); if (customPreview.bodyGetterId) { let bodyContent: LitTemplate|Node|undefined; if (input.cachedContent instanceof ObjectTree) { bodyContent = html`<devtools-widget class="custom-expandable-section-default-body" ${ UI.Widget.widget(ObjectPropertiesSectionWidget, { objectTree: input.cachedContent, showOverflow: false, })}></devtools-widget>`; } else if (input.cachedContent) { bodyContent = renderJSONMLTag(object, input.cachedContent); } render(html` <span class=${Directives.classMap({'custom-expandable-section-header': true, expanded: input.expanded})} @click=${onClick}> <devtools-icon name=${ input.expanded ? 'triangle-down' : 'triangle-right'} class="custom-expand-icon"></devtools-icon> ${headerTemplate} </span> ${ bodyContent ? html`<span class="custom-expandable-section-body" ?hidden=${!input.expanded}>${bodyContent}</span>` : nothing} `, target, {container: {classes: ['custom-expandable-section']}}); } else { render(html`${headerTemplate}`, target, {container: {classes: ['custom-expandable-section']}}); } }; export type View = typeof DEFAULT_VIEW; export class CustomPreviewComponent { private readonly object: SDK.RemoteObject.RemoteObject; private customPreviewSection: CustomPreviewSection|null; element: HTMLSpanElement; constructor(object: SDK.RemoteObject.RemoteObject) { this.object = object; this.customPreviewSection = new CustomPreviewSection(); this.customPreviewSection.object = object; this.element = document.createElement('span'); this.element.classList.add('source-code'); const shadowRoot = UI.UIUtils.createShadowRootWithCoreStyles(this.element, {cssFile: customPreviewComponentStyles}); this.element.addEventListener('contextmenu', this.contextMenuEventFired.bind(this), false); this.customPreviewSection.show(shadowRoot, undefined, /* suppressOrphanWidgetError= */ true); } async expandIfPossible(): Promise<void> { const customPreview = this.object.customPreview(); if (customPreview && customPreview.bodyGetterId && this.customPreviewSection) { await this.customPreviewSection.loadBody(); } } private contextMenuEventFired(event: Event): void { const contextMenu = new UI.ContextMenu.ContextMenu(event); if (this.customPreviewSection) { contextMenu.revealSection().appendItem(i18nString(UIStrings.showAsJavascriptObject), this.disassemble.bind(this), {jslogContext: 'show-as-javascript-object'}); } contextMenu.appendApplicableItems(this.object); void contextMenu.show(); } private disassemble(): void { if (this.element.shadowRoot) { if (this.customPreviewSection) { this.customPreviewSection.detach(); this.customPreviewSection = null; } this.element.shadowRoot.textContent = ''; // eslint-disable-next-line @devtools/no-lit-render-outside-of-view render(defaultObjectPresentation(this.object), this.element.shadowRoot); } } }