chrome-devtools-frontend
Version:
Chrome DevTools UI
262 lines (233 loc) • 8.78 kB
text/typescript
// 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 i18n from '../../core/i18n/i18n.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as UI from '../../ui/legacy/legacy.js';
import {Directives, html, nothing, render} from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import domLinkifierStyles from './domLinkifier.css.js';
const {classMap} = Directives;
const UIStrings = {
/**
* @description Text displayed when trying to create a link to a node in the UI, but the node
* location could not be found so we display this placeholder instead. Node refers to a DOM node.
* This should be translated if appropriate.
*/
node: '<node>',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/elements/DOMLinkifier.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export interface Options extends Common.Linkifier.Options {
hiddenClassList?: string[];
disabled?: boolean;
}
interface ViewInput {
dynamic?: boolean;
disabled?: boolean;
preventKeyboardFocus?: boolean;
tagName?: string;
id?: string;
classes: string[];
pseudo?: string;
onClick: () => void;
onMouseOver: () => void;
onMouseLeave: () => void;
}
export type View = (input: ViewInput, output: object, target: HTMLElement) => void;
const DEFAULT_VIEW: View = (input, _output, target: HTMLElement) => {
// clang-format off
render(html`${(input.tagName || input.pseudo) ? html`
<style>${domLinkifierStyles}</style>
<span class="monospace">
<button class="node-link text-button link-style ${classMap({
'dynamic-link': Boolean(input.dynamic),
disabled: Boolean(input.disabled)
})}"
jslog=${VisualLogging.link('node').track({click: true, keydown: 'Enter'})}
tabindex=${input.preventKeyboardFocus ? -1 : 0}
@click=${input.onClick}
@mouseover=${input.onMouseOver}
@mouseleave=${input.onMouseLeave}
title=${[
input.tagName ?? '',
input.id ? `#${input.id}` : '',
...input.classes.map(c => `.${c}`),
input.pseudo ? `::${input.pseudo}` : '',
].join(' ')}>${
[
input.tagName ? html`<span class="node-label-name">${input.tagName}</span>` : nothing,
input.id ? html`<span class="node-label-id">#${input.id}</span>` : nothing,
...input.classes.map(className => html`<span class="extra node-label-class">.${className}</span>`),
input.pseudo ? html`<span class="extra node-label-pseudo">${input.pseudo}</span>` : nothing,
]
}</button>
</span>` : i18nString(UIStrings.node)}`, target, {host: input});
// clang-format on
};
export class DOMNodeLink extends UI.Widget.Widget {
#node: SDK.DOMModel.DOMNode|undefined = undefined;
#options: Options|undefined = undefined;
#view: View;
constructor(element?: HTMLElement, node?: SDK.DOMModel.DOMNode, options?: Options, view = DEFAULT_VIEW) {
super(true, undefined, element);
this.element.classList.remove('vbox');
this.#node = node;
this.#options = options;
this.#view = view;
this.performUpdate();
}
set node(node: SDK.DOMModel.DOMNode|undefined) {
this.#node = node;
this.performUpdate();
}
set options(options: Options|undefined) {
this.#options = options;
this.performUpdate();
}
override performUpdate(): void {
const options = this.#options ?? {
tooltip: undefined,
preventKeyboardFocus: undefined,
textContent: undefined,
isDynamicLink: false,
disabled: false,
};
const viewInput: ViewInput = {
dynamic: options.isDynamicLink,
disabled: options.disabled,
preventKeyboardFocus: options.preventKeyboardFocus,
classes: [],
onClick: () => {
void Common.Revealer.reveal(this.#node);
return false;
},
onMouseOver: () => {
this.#node?.highlight?.();
},
onMouseLeave: () => {
SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight();
},
};
if (!this.#node) {
this.#view(viewInput, {}, this.contentElement);
return;
}
let node = this.#node;
const isPseudo = node.nodeType() === Node.ELEMENT_NODE && node.pseudoType();
if (isPseudo && node.parentNode) {
node = node.parentNode;
}
// Special case rendering the node links for view transition pseudo elements.
// We don't include the ancestor name in the node link because
// they always have the same ancestor. See crbug.com/340633630.
if (node.isViewTransitionPseudoNode()) {
viewInput.pseudo = `::${this.#node.pseudoType()}(${this.#node.pseudoIdentifier()})`;
this.#view(viewInput, {}, this.contentElement);
return;
}
if (options.textContent) {
viewInput.tagName = options.textContent;
this.#view(viewInput, {}, this.contentElement);
return;
}
viewInput.tagName = node.nodeNameInCorrectCase();
const idAttribute = node.getAttribute('id');
if (idAttribute) {
viewInput.id = idAttribute;
}
const classAttribute = node.getAttribute('class');
if (classAttribute) {
const classes = classAttribute.split(/\s+/);
if (classes.length) {
const foundClasses = new Set<string>();
for (let i = 0; i < classes.length; ++i) {
const className = classes[i];
if (className && !options.hiddenClassList?.includes(className) && !foundClasses.has(className)) {
foundClasses.add(className);
}
}
viewInput.classes = [...foundClasses];
}
}
if (isPseudo) {
const pseudoIdentifier = this.#node.pseudoIdentifier();
let pseudoText = '::' + this.#node.pseudoType();
if (pseudoIdentifier) {
pseudoText += `(${pseudoIdentifier})`;
}
viewInput.pseudo = pseudoText;
}
this.#view(viewInput, {}, this.contentElement);
}
}
interface DeferredViewInput {
preventKeyboardFocus?: boolean;
onClick: () => void;
}
type DeferredView = (input: DeferredViewInput, output: object, target: HTMLElement) => void;
const DEFERRED_DEFAULT_VIEW: DeferredView = (input, _output, target: HTMLElement) => {
// clang-format off
render(html`
<style>${domLinkifierStyles}</style>
<button class="node-link text-button link-style"
jslog=${VisualLogging.link('node').track({click: true})}
tabindex=${input.preventKeyboardFocus ? -1 : 0}
@click=${input.onClick}
@mousedown=${(e: Event) => e.consume()}>
<slot></slot>
</button>`, target, {host: input});
// clang-format on
};
export class DeferredDOMNodeLink extends UI.Widget.Widget {
#deferredNode: SDK.DOMModel.DeferredDOMNode|undefined = undefined;
#options: Options|undefined = undefined;
#view: DeferredView;
constructor(
element?: HTMLElement, deferredNode?: SDK.DOMModel.DeferredDOMNode, options?: Options,
view: DeferredView = DEFERRED_DEFAULT_VIEW) {
super(true, undefined, element);
this.element.classList.remove('vbox');
this.#deferredNode = deferredNode;
this.#options = options;
this.#view = view;
this.performUpdate();
}
override performUpdate(): void {
const viewInput = {
preventKeyboardFocus: this.#options?.preventKeyboardFocus,
onClick: () => {
this.#deferredNode?.resolve?.(node => {
void Common.Revealer.reveal(node);
});
},
};
this.#view(viewInput, {}, this.contentElement);
}
}
let linkifierInstance: Linkifier;
export class Linkifier implements Common.Linkifier.Linkifier {
static instance(opts: {
forceNew: boolean|null,
} = {forceNew: null}): Linkifier {
const {forceNew} = opts;
if (!linkifierInstance || forceNew) {
linkifierInstance = new Linkifier();
}
return linkifierInstance;
}
linkify(object: Object, options?: Options): Node {
if (object instanceof SDK.DOMModel.DOMNode) {
const link = document.createElement('devtools-widget') as UI.Widget.WidgetElement<DOMNodeLink>;
link.widgetConfig = UI.Widget.widgetConfig(e => new DOMNodeLink(e, object, options));
return link;
}
if (object instanceof SDK.DOMModel.DeferredDOMNode) {
const link = document.createElement('devtools-widget') as UI.Widget.WidgetElement<DeferredDOMNodeLink>;
link.widgetConfig = UI.Widget.widgetConfig(e => new DeferredDOMNodeLink(e, object, options));
return link;
}
throw new Error('Can\'t linkify non-node');
}
}