chrome-devtools-frontend
Version:
Chrome DevTools UI
1,489 lines (1,320 loc) • 49.9 kB
JavaScript
/*
* Copyright (C) 2007, 2008 Apple Inc. All rights reserved.
* Copyright (C) 2008 Matt Lilek <webkit@mattlilek.com>
* Copyright (C) 2009 Joseph Pecoraro
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of
* its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
import * as Common from '../common/common.js';
import * as Components from '../components/components.js';
import * as Extensions from '../extensions/extensions.js';
import * as Host from '../host/host.js';
import * as i18n from '../i18n/i18n.js';
import * as Root from '../root/root.js';
import * as SDK from '../sdk/sdk.js';
import * as UI from '../ui/ui.js';
import {AccessibilityTreeView} from './AccessibilityTreeView.js';
import {ComputedStyleWidget} from './ComputedStyleWidget.js';
import {DOMNode, ElementsBreadcrumbs} from './ElementsBreadcrumbs.js'; // eslint-disable-line no-unused-vars
import {ElementsTreeElement} from './ElementsTreeElement.js'; // eslint-disable-line no-unused-vars
import {ElementsTreeElementHighlighter} from './ElementsTreeElementHighlighter.js';
import {ElementsTreeOutline} from './ElementsTreeOutline.js';
import {MarkerDecorator} from './MarkerDecorator.js'; // eslint-disable-line no-unused-vars
import {MetricsSidebarPane} from './MetricsSidebarPane.js';
import {Events as StylesSidebarPaneEvents, StylesSidebarPane} from './StylesSidebarPane.js';
export const UIStrings = {
/**
*@description Text in Elements Panel of the Elements panel
*/
findByStringSelectorOrXpath: 'Find by string, selector, or XPath',
/**
*@description Title of the switch to accessibility tree button in the Elements panel
*/
switchToAccessibilityTreeView: 'Switch to Accessibility Tree view',
/**
*@description Title of the switch to DOM tree button in the Elements panel
*/
switchToDomTreeView: 'Switch to DOM Tree view',
/**
*@description Text for a rendering frame
*/
frame: 'Frame',
/**
*@description Title of the Computed Styles sidebar toggle in the Styles pane
*/
computedStylesSidebar: 'Computed Styles sidebar',
/**
*@description Text in Elements Panel of the Elements panel
*/
computed: 'Computed',
/**
*@description Text in Elements Panel of the Elements panel
*/
styles: 'Styles',
/**
*@description A context menu item to reveal a node in the DOM tree of the Elements Panel
*/
revealInElementsPanel: 'Reveal in Elements panel',
/**
*@description Text when node can not be found in page
*/
nodeCannotBeFoundInTheCurrent: 'Node cannot be found in the current page.',
/**
*@description Console warning when a user tries to reveal a non-node type Remote Object.
*/
theRemoteObjectCouldNotBe: 'The remote object could not be resolved into a valid node.',
/**
*@description Console warning when the user tries to reveal a deferred DOM Node that resolves as null.
*/
theDeferredDomNodeCouldNotBe: 'The deferred DOM Node could not be resolved into a valid node.',
/**
*@description Text in Elements Panel of the Elements panel
*@example {::after, ::before} PH1
*/
elementStateS: 'Element state: {PH1}',
};
const str_ = i18n.i18n.registerUIStrings('elements/ElementsPanel.js', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
/**
*
* @param {!SDK.DOMModel.DOMNode} node
* @return {!DOMNode}
*/
const legacyNodeToNewBreadcrumbsNode = node => {
return {
parentNode: node.parentNode ? legacyNodeToNewBreadcrumbsNode(node.parentNode) : null,
id: /** @type {number} */ (node.id),
nodeType: node.nodeType(),
pseudoType: node.pseudoType(),
shadowRootType: node.shadowRootType(),
nodeName: node.nodeName(),
nodeNameNicelyCased: node.nodeNameInCorrectCase(),
legacyDomNode: node,
highlightNode: () => node.highlight(),
clearHighlight: () => SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(),
getAttribute: node.getAttribute.bind(node),
};
};
/** @type {!ElementsPanel} */
let elementsPanelInstance;
/**
* @implements {UI.SearchableView.Searchable}
* @implements {SDK.SDKModel.SDKModelObserver<!SDK.DOMModel.DOMModel>}
* @implements {UI.View.ViewLocationResolver}
*/
export class ElementsPanel extends UI.Panel.Panel {
constructor() {
super('elements');
this.registerRequiredCSS('elements/elementsPanel.css', {enableLegacyPatching: true});
this._splitWidget = new UI.SplitWidget.SplitWidget(true, true, 'elementsPanelSplitViewState', 325, 325);
this._splitWidget.addEventListener(
UI.SplitWidget.Events.SidebarSizeChanged, this._updateTreeOutlineVisibleWidth.bind(this));
this._splitWidget.show(this.element);
this._searchableView = new UI.SearchableView.SearchableView(this, null);
this._searchableView.setMinimumSize(25, 28);
this._searchableView.setPlaceholder(i18nString(UIStrings.findByStringSelectorOrXpath));
const stackElement = this._searchableView.element;
this._contentElement = document.createElement('div');
const crumbsContainer = document.createElement('div');
if (Root.Runtime.experiments.isEnabled('fullAccessibilityTree')) {
this._initializeFullAccessibilityTreeView(stackElement);
}
stackElement.appendChild(this._contentElement);
stackElement.appendChild(crumbsContainer);
this._splitWidget.setMainWidget(this._searchableView);
/** @type {?_splitMode} */
this._splitMode = null;
this._contentElement.id = 'elements-content';
// FIXME: crbug.com/425984
if (Common.Settings.Settings.instance().moduleSetting('domWordWrap').get()) {
this._contentElement.classList.add('elements-wrap');
}
Common.Settings.Settings.instance()
.moduleSetting('domWordWrap')
.addChangeListener(this._domWordWrapSettingChanged.bind(this));
crumbsContainer.id = 'elements-crumbs';
if (this.domTreeButton) {
this._accessibilityTreeView = new AccessibilityTreeView(this.domTreeButton);
}
this._breadcrumbs = new ElementsBreadcrumbs();
this._breadcrumbs.addEventListener('node-selected', /** @param {!Event} event */ event => {
this._crumbNodeSelected(/** @type {?} */ (event));
});
crumbsContainer.appendChild(this._breadcrumbs);
this._stylesWidget = StylesSidebarPane.instance();
this._computedStyleWidget = new ComputedStyleWidget();
this._metricsWidget = new MetricsSidebarPane();
Common.Settings.Settings.instance()
.moduleSetting('sidebarPosition')
.addChangeListener(this._updateSidebarPosition.bind(this));
this._updateSidebarPosition();
/** @type {!Set.<!ElementsTreeOutline>} */
this._treeOutlines = new Set();
/** @type {!Map<!ElementsTreeOutline, !Element>} */
this._treeOutlineHeaders = new Map();
/** @type {!Map<!SDK.CSSModel.CSSModel, !SDK.CSSModel.CSSPropertyTracker>} */
this._gridStyleTrackerByCSSModel = new Map();
SDK.SDKModel.TargetManager.instance().observeModels(SDK.DOMModel.DOMModel, this);
SDK.SDKModel.TargetManager.instance().addEventListener(
SDK.SDKModel.Events.NameChanged,
event => this._targetNameChanged(/** @type {!SDK.SDKModel.Target} */ (event.data)));
Common.Settings.Settings.instance()
.moduleSetting('showUAShadowDOM')
.addChangeListener(this._showUAShadowDOMChanged.bind(this));
SDK.SDKModel.TargetManager.instance().addModelListener(
SDK.DOMModel.DOMModel, SDK.DOMModel.Events.DocumentUpdated, this._documentUpdatedEvent, this);
Extensions.ExtensionServer.ExtensionServer.instance().addEventListener(
Extensions.ExtensionServer.Events.SidebarPaneAdded, this._extensionSidebarPaneAdded, this);
/**
* @type {!Array.<{domModel: !SDK.DOMModel.DOMModel, index: number, node: (?SDK.DOMModel.DOMNode|undefined)}>|undefined}
*/
this._searchResults;
this._currentSearchResultIndex = -1; // -1 represents the initial invalid state
this._pendingNodeReveal = false;
}
/**
* @param {UI.Widget.WidgetElement} stackElement
*/
_initializeFullAccessibilityTreeView(stackElement) {
this._accessibilityTreeButton = document.createElement('button');
this._accessibilityTreeButton.textContent = i18nString(UIStrings.switchToAccessibilityTreeView);
this._accessibilityTreeButton.addEventListener('click', this._showAccessibilityTree.bind(this));
this.domTreeButton = document.createElement('button');
this.domTreeButton.textContent = i18nString(UIStrings.switchToDomTreeView);
this.domTreeButton.addEventListener('click', this._showDOMTree.bind(this));
stackElement.appendChild(this._accessibilityTreeButton);
}
_showAccessibilityTree() {
if (this._accessibilityTreeView) {
this._splitWidget.setMainWidget(this._accessibilityTreeView);
}
}
_showDOMTree() {
this._splitWidget.setMainWidget(this._searchableView);
}
/**
* @param {{forceNew: ?boolean}=} opts
* @return {!ElementsPanel}
*/
static instance(opts = {forceNew: null}) {
const {forceNew} = opts;
if (!elementsPanelInstance || forceNew) {
elementsPanelInstance = new ElementsPanel();
}
return elementsPanelInstance;
}
/**
* @param {!SDK.CSSProperty.CSSProperty} cssProperty
*/
_revealProperty(cssProperty) {
if (!this.sidebarPaneView || !this._stylesViewToReveal) {
return Promise.resolve();
}
return this.sidebarPaneView.showView(this._stylesViewToReveal).then(() => {
this._stylesWidget.revealProperty(/** @type {!SDK.CSSProperty.CSSProperty} */ (cssProperty));
});
}
/**
* @override
* @param {string} locationName
* @return {?UI.View.ViewLocation}
*/
resolveLocation(locationName) {
return this.sidebarPaneView || null;
}
/**
* @param {?UI.Widget.Widget} widget
* @param {?UI.Toolbar.ToolbarToggle} toggle
*/
showToolbarPane(widget, toggle) {
// TODO(luoe): remove this function once its providers have an alternative way to reveal their views.
this._stylesWidget.showToolbarPane(widget, toggle);
}
/**
* @override
* @param {!SDK.DOMModel.DOMModel} domModel
*/
modelAdded(domModel) {
const parentModel = domModel.parentModel();
let treeOutline = parentModel ? ElementsTreeOutline.forDOMModel(parentModel) : null;
if (!treeOutline) {
treeOutline = new ElementsTreeOutline(true, true);
treeOutline.setWordWrap(Common.Settings.Settings.instance().moduleSetting('domWordWrap').get());
treeOutline.addEventListener(ElementsTreeOutline.Events.SelectedNodeChanged, this._selectedNodeChanged, this);
treeOutline.addEventListener(
ElementsTreeOutline.Events.ElementsTreeUpdated, this._updateBreadcrumbIfNeeded, this);
new ElementsTreeElementHighlighter(treeOutline);
this._treeOutlines.add(treeOutline);
if (domModel.target().parentTarget()) {
const element = document.createElement('div');
element.classList.add('elements-tree-header');
this._treeOutlineHeaders.set(treeOutline, element);
this._targetNameChanged(domModel.target());
}
}
treeOutline.wireToDOMModel(domModel);
this._setupStyleTracking(domModel.cssModel());
// Perform attach if necessary.
if (this.isShowing()) {
this.wasShown();
}
}
/**
* @override
* @param {!SDK.DOMModel.DOMModel} domModel
*/
modelRemoved(domModel) {
const treeOutline = ElementsTreeOutline.forDOMModel(domModel);
if (!treeOutline) {
return;
}
treeOutline.unwireFromDOMModel(domModel);
if (domModel.parentModel()) {
return;
}
this._treeOutlines.delete(treeOutline);
const header = this._treeOutlineHeaders.get(treeOutline);
if (header) {
header.remove();
}
this._treeOutlineHeaders.delete(treeOutline);
treeOutline.element.remove();
this._removeStyleTracking(domModel.cssModel());
}
/**
* @param {!SDK.SDKModel.Target} target
*/
_targetNameChanged(target) {
const domModel = target.model(SDK.DOMModel.DOMModel);
if (!domModel) {
return;
}
const treeOutline = ElementsTreeOutline.forDOMModel(domModel);
if (!treeOutline) {
return;
}
const header = this._treeOutlineHeaders.get(treeOutline);
if (!header) {
return;
}
header.removeChildren();
header.createChild('div', 'elements-tree-header-frame').textContent = i18nString(UIStrings.frame);
header.appendChild(Components.Linkifier.Linkifier.linkifyURL(
target.inspectedURL(), /** @type {!Components.Linkifier.LinkifyURLOptions} */ ({text: target.name()})));
}
_updateTreeOutlineVisibleWidth() {
if (!this._treeOutlines.size) {
return;
}
let width = this._splitWidget.element.offsetWidth;
if (this._splitWidget.isVertical()) {
width -= this._splitWidget.sidebarSize();
}
for (const treeOutline of this._treeOutlines) {
treeOutline.setVisibleWidth(width);
}
}
/**
* @override
*/
focus() {
if (this._treeOutlines.size) {
this._treeOutlines.values().next().value.focus();
}
}
/**
* @override
* @return {!UI.SearchableView.SearchableView}
*/
searchableView() {
return this._searchableView;
}
/**
* @override
*/
wasShown() {
UI.Context.Context.instance().setFlavor(ElementsPanel, this);
for (const treeOutline of this._treeOutlines) {
// Attach heavy component lazily
if (treeOutline.element.parentElement !== this._contentElement) {
const header = this._treeOutlineHeaders.get(treeOutline);
if (header) {
this._contentElement.appendChild(header);
}
this._contentElement.appendChild(treeOutline.element);
}
}
super.wasShown();
const domModels = SDK.SDKModel.TargetManager.instance().models(SDK.DOMModel.DOMModel);
for (const domModel of domModels) {
if (domModel.parentModel()) {
continue;
}
const treeOutline = ElementsTreeOutline.forDOMModel(domModel);
if (!treeOutline) {
continue;
}
treeOutline.setVisible(true);
if (!treeOutline.rootDOMNode) {
if (domModel.existingDocument()) {
treeOutline.rootDOMNode = domModel.existingDocument();
this._documentUpdated(domModel);
} else {
domModel.requestDocument();
}
}
}
}
/**
* @override
*/
willHide() {
SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight();
for (const treeOutline of this._treeOutlines) {
treeOutline.setVisible(false);
// Detach heavy component on hide
this._contentElement.removeChild(treeOutline.element);
const header = this._treeOutlineHeaders.get(treeOutline);
if (header) {
this._contentElement.removeChild(header);
}
}
super.willHide();
UI.Context.Context.instance().setFlavor(ElementsPanel, null);
}
/**
* @override
*/
onResize() {
this.element.window().requestAnimationFrame(this._updateSidebarPosition.bind(this)); // Do not force layout.
this._updateTreeOutlineVisibleWidth();
}
/**
* @param {!Common.EventTarget.EventTargetEvent} event
*/
_selectedNodeChanged(event) {
let selectedNode = /** @type {?SDK.DOMModel.DOMNode} */ (event.data.node);
// If the selectedNode is a pseudoNode, we want to ensure that it has a valid parentNode
if (selectedNode && (selectedNode.pseudoType() && !selectedNode.parentNode)) {
selectedNode = null;
}
const focus = /** @type {boolean} */ (event.data.focus);
for (const treeOutline of this._treeOutlines) {
if (!selectedNode || ElementsTreeOutline.forDOMModel(selectedNode.domModel()) !== treeOutline) {
treeOutline.selectDOMNode(null);
}
}
if (selectedNode) {
const activeNode = legacyNodeToNewBreadcrumbsNode(selectedNode);
const crumbs = [activeNode];
for (let current = selectedNode.parentNode; current; current = current.parentNode) {
crumbs.push(legacyNodeToNewBreadcrumbsNode(current));
}
this._breadcrumbs.data = {
crumbs,
selectedNode: legacyNodeToNewBreadcrumbsNode(selectedNode),
};
} else {
this._breadcrumbs.data = {crumbs: [], selectedNode: null};
}
UI.Context.Context.instance().setFlavor(SDK.DOMModel.DOMNode, selectedNode);
if (!selectedNode) {
return;
}
selectedNode.setAsInspectedNode();
if (this._accessibilityTreeView) {
this._accessibilityTreeView.setNode(selectedNode);
}
if (focus) {
this._selectedNodeOnReset = selectedNode;
this._hasNonDefaultSelectedNode = true;
}
const executionContexts = selectedNode.domModel().runtimeModel().executionContexts();
const nodeFrameId = selectedNode.frameId();
for (const context of executionContexts) {
if (context.frameId === nodeFrameId) {
UI.Context.Context.instance().setFlavor(SDK.RuntimeModel.ExecutionContext, context);
break;
}
}
}
/**
* @param {!Common.EventTarget.EventTargetEvent} event
*/
_documentUpdatedEvent(event) {
const domModel = /** @type {!SDK.DOMModel.DOMModel} */ (event.data);
this._documentUpdated(domModel);
this._removeStyleTracking(domModel.cssModel());
this._setupStyleTracking(domModel.cssModel());
}
/**
* @param {!SDK.DOMModel.DOMModel} domModel
*/
_documentUpdated(domModel) {
this._searchableView.resetSearch();
if (!domModel.existingDocument()) {
if (this.isShowing()) {
domModel.requestDocument();
}
return;
}
this._hasNonDefaultSelectedNode = false;
if (this._omitDefaultSelection) {
return;
}
const savedSelectedNodeOnReset = this._selectedNodeOnReset;
restoreNode.call(this, domModel, this._selectedNodeOnReset || null);
/**
* @param {!SDK.DOMModel.DOMModel} domModel
* @param {?SDK.DOMModel.DOMNode} staleNode
* @this {ElementsPanel}
*/
async function restoreNode(domModel, staleNode) {
const nodePath = staleNode ? staleNode.path() : null;
const restoredNodeId = nodePath ? await domModel.pushNodeByPathToFrontend(nodePath) : null;
if (savedSelectedNodeOnReset !== this._selectedNodeOnReset) {
return;
}
let node = restoredNodeId ? domModel.nodeForId(restoredNodeId) : null;
if (!node) {
const inspectedDocument = domModel.existingDocument();
node = inspectedDocument ? inspectedDocument.body || inspectedDocument.documentElement : null;
}
// If `node` is null here, the document hasn't been transmitted from the backend yet
// and isn't in a valid state to have a default-selected node. Another document update
// should be forthcoming. In the meantime, don't set the default-selected node or notify
// the test that it's ready, because it isn't.
if (node) {
this._setDefaultSelectedNode(node);
this._lastSelectedNodeSelectedForTest();
}
}
}
_lastSelectedNodeSelectedForTest() {
}
/**
* @param {?SDK.DOMModel.DOMNode} node
*/
_setDefaultSelectedNode(node) {
if (!node || this._hasNonDefaultSelectedNode || this._pendingNodeReveal) {
return;
}
const treeOutline = ElementsTreeOutline.forDOMModel(node.domModel());
if (!treeOutline) {
return;
}
this.selectDOMNode(node);
if (treeOutline.selectedTreeElement) {
treeOutline.selectedTreeElement.expand();
}
}
/**
* @override
*/
searchCanceled() {
this._searchConfig = undefined;
this._hideSearchHighlights();
this._searchableView.updateSearchMatchesCount(0);
this._currentSearchResultIndex = -1;
delete this._searchResults;
SDK.DOMModel.DOMModel.cancelSearch();
}
/**
* @override
* @param {!UI.SearchableView.SearchConfig} searchConfig
* @param {boolean} shouldJump
* @param {boolean=} jumpBackwards
*/
performSearch(searchConfig, shouldJump, jumpBackwards) {
const query = searchConfig.query;
const whitespaceTrimmedQuery = query.trim();
if (!whitespaceTrimmedQuery.length) {
return;
}
if (!this._searchConfig || this._searchConfig.query !== query) {
this.searchCanceled();
} else {
this._hideSearchHighlights();
}
this._searchConfig = searchConfig;
const showUAShadowDOM = Common.Settings.Settings.instance().moduleSetting('showUAShadowDOM').get();
const domModels = SDK.SDKModel.TargetManager.instance().models(SDK.DOMModel.DOMModel);
const promises = domModels.map(domModel => domModel.performSearch(whitespaceTrimmedQuery, showUAShadowDOM));
Promise.all(promises).then(resultCounts => {
this._searchResults = [];
for (let i = 0; i < resultCounts.length; ++i) {
const resultCount = resultCounts[i];
for (let j = 0; j < resultCount; ++j) {
this._searchResults.push({domModel: domModels[i], index: j, node: undefined});
}
}
this._searchableView.updateSearchMatchesCount(this._searchResults.length);
if (!this._searchResults.length) {
return;
}
if (this._currentSearchResultIndex >= this._searchResults.length) {
this._currentSearchResultIndex = -1;
}
let index = this._currentSearchResultIndex;
if (shouldJump) {
if (this._currentSearchResultIndex === -1) {
index = jumpBackwards ? -1 : 0;
} else {
index = jumpBackwards ? index - 1 : index + 1;
}
this._jumpToSearchResult(index);
}
});
}
/**
* @param {!Common.EventTarget.EventTargetEvent} event
*/
_domWordWrapSettingChanged(event) {
this._contentElement.classList.toggle('elements-wrap', /** @type {boolean} */ (event.data));
for (const treeOutline of this._treeOutlines) {
treeOutline.setWordWrap(/** @type {boolean} */ (event.data));
}
}
/**
* @param {!SDK.DOMModel.DOMNode} node
*/
switchToAndFocus(node) {
// Reset search restore.
this._searchableView.cancelSearch();
UI.ViewManager.ViewManager.instance().showView('elements').then(() => this.selectDOMNode(node, true));
}
/**
* @param {number} index
*/
_jumpToSearchResult(index) {
if (!this._searchResults) {
return;
}
this._currentSearchResultIndex = (index + this._searchResults.length) % this._searchResults.length;
this._highlightCurrentSearchResult();
}
/**
* @override
*/
jumpToNextSearchResult() {
if (!this._searchResults || !this._searchConfig) {
return;
}
this.performSearch(this._searchConfig, true);
}
/**
* @override
*/
jumpToPreviousSearchResult() {
if (!this._searchResults || !this._searchConfig) {
return;
}
this.performSearch(this._searchConfig, true, true);
}
/**
* @override
* @return {boolean}
*/
supportsCaseSensitiveSearch() {
return false;
}
/**
* @override
* @return {boolean}
*/
supportsRegexSearch() {
return false;
}
_highlightCurrentSearchResult() {
const index = this._currentSearchResultIndex;
const searchResults = this._searchResults;
if (!searchResults) {
return;
}
const searchResult = searchResults[index];
this._searchableView.updateCurrentMatchIndex(index);
if (searchResult.node === null) {
return;
}
if (typeof searchResult.node === 'undefined') {
// No data for slot, request it.
searchResult.domModel.searchResult(searchResult.index).then(node => {
searchResult.node = node;
// If any of these properties are undefined or reset to an invalid value,
// this means the search/highlight request is outdated.
const highlightRequestValid =
this._searchConfig && this._searchResults && (this._currentSearchResultIndex !== -1);
if (highlightRequestValid) {
this._highlightCurrentSearchResult();
}
});
return;
}
const treeElement = this._treeElementForNode(searchResult.node);
searchResult.node.scrollIntoView();
if (treeElement) {
this._searchConfig && treeElement.highlightSearchResults(this._searchConfig.query);
treeElement.reveal();
const matches = treeElement.listItemElement.getElementsByClassName(UI.UIUtils.highlightedSearchResultClassName);
if (matches.length) {
matches[0].scrollIntoViewIfNeeded(false);
}
}
}
_hideSearchHighlights() {
if (!this._searchResults || !this._searchResults.length || this._currentSearchResultIndex === -1) {
return;
}
const searchResult = this._searchResults[this._currentSearchResultIndex];
if (!searchResult.node) {
return;
}
const treeElement = this._treeElementForNode(searchResult.node);
if (treeElement) {
treeElement.hideSearchHighlights();
}
}
/**
* @return {?SDK.DOMModel.DOMNode}
*/
selectedDOMNode() {
for (const treeOutline of this._treeOutlines) {
if (treeOutline.selectedDOMNode()) {
return treeOutline.selectedDOMNode();
}
}
return null;
}
/**
* @param {!SDK.DOMModel.DOMNode} node
* @param {boolean=} focus
*/
selectDOMNode(node, focus) {
for (const treeOutline of this._treeOutlines) {
const outline = ElementsTreeOutline.forDOMModel(node.domModel());
if (outline === treeOutline) {
treeOutline.selectDOMNode(node, focus);
} else {
treeOutline.selectDOMNode(null);
}
}
}
/**
* @param {!Common.EventTarget.EventTargetEvent} event
*/
_updateBreadcrumbIfNeeded(event) {
const nodes = /** @type {!Array.<!SDK.DOMModel.DOMNode>} */ (event.data);
/* If we don't have a selected node then we can tell the breadcrumbs that & bail. */
const selectedNode = this.selectedDOMNode();
if (!selectedNode) {
this._breadcrumbs.data = {
crumbs: [],
selectedNode: null,
};
return;
}
/* This function gets called whenever the tree outline is updated
* and contains any nodes that have changed.
* What we need to do is construct the new set of breadcrumb nodes, combining the Nodes
* that we had before with the new nodes, and pass them into the breadcrumbs component.
*/
// Get the current set of active crumbs
const activeNode = legacyNodeToNewBreadcrumbsNode(selectedNode);
const existingCrumbs = [activeNode];
for (let current = selectedNode.parentNode; current; current = current.parentNode) {
existingCrumbs.push(legacyNodeToNewBreadcrumbsNode(current));
}
/* Get the change nodes from the event & convert them to breadcrumb nodes */
const newNodes = nodes.map(legacyNodeToNewBreadcrumbsNode);
const nodesThatHaveChangedMap = new Map();
newNodes.forEach(crumb => nodesThatHaveChangedMap.set(crumb.id, crumb));
/* Loop over our existing crumbs, and if any have an ID that matches an ID from the new nodes
* that we have, use the new node, rather than the one we had, because it's changed.
*/
const newSetOfCrumbs = existingCrumbs.map(crumb => {
const replacement = nodesThatHaveChangedMap.get(crumb.id);
return replacement || crumb;
});
this._breadcrumbs.data = {
crumbs: newSetOfCrumbs,
selectedNode: activeNode,
};
}
/**
* @param {!Common.EventTarget.EventTargetEvent} event
*/
_crumbNodeSelected(event) {
const node = /** @type {!SDK.DOMModel.DOMNode} */ (event.data);
this.selectDOMNode(node, true);
}
/**
* @param {?SDK.DOMModel.DOMNode} node
* @return {?ElementsTreeOutline}
*/
_treeOutlineForNode(node) {
if (!node) {
return null;
}
return ElementsTreeOutline.forDOMModel(node.domModel());
}
/**
* @param {!SDK.DOMModel.DOMNode} node
* @return {?ElementsTreeElement}
*/
_treeElementForNode(node) {
const treeOutline = this._treeOutlineForNode(node);
if (!treeOutline) {
return null;
}
return /** @type {?ElementsTreeElement} */ (treeOutline.findTreeElement(node));
}
/**
* @param {!SDK.DOMModel.DOMNode} node
* @return {!SDK.DOMModel.DOMNode}
*/
_leaveUserAgentShadowDOM(node) {
let userAgentShadowRoot;
while ((userAgentShadowRoot = node.ancestorUserAgentShadowRoot()) && userAgentShadowRoot.parentNode) {
node = userAgentShadowRoot.parentNode;
}
return node;
}
/**
* @param {!SDK.DOMModel.DOMNode} node
* @param {boolean} focus
* @param {boolean=} omitHighlight
* @return {!Promise<void>}
*/
revealAndSelectNode(node, focus, omitHighlight) {
this._omitDefaultSelection = true;
node = Common.Settings.Settings.instance().moduleSetting('showUAShadowDOM').get() ?
node :
this._leaveUserAgentShadowDOM(node);
if (!omitHighlight) {
node.highlightForTwoSeconds();
}
return UI.ViewManager.ViewManager.instance().showView('elements', false, !focus).then(() => {
this.selectDOMNode(node, focus);
delete this._omitDefaultSelection;
if (!this._notFirstInspectElement) {
ElementsPanel._firstInspectElementNodeNameForTest = node.nodeName();
ElementsPanel._firstInspectElementCompletedForTest();
Host.InspectorFrontendHost.InspectorFrontendHostInstance.inspectElementCompleted();
}
this._notFirstInspectElement = true;
});
}
_showUAShadowDOMChanged() {
for (const treeOutline of this._treeOutlines) {
treeOutline.update();
}
}
/**
* @param {!HTMLElement} stylePaneWrapperElement
*/
_setupTextSelectionHack(stylePaneWrapperElement) {
// We "extend" the sidebar area when dragging, in order to keep smooth text
// selection. It should be replaced by 'user-select: contain' in the future.
const uninstallHackBound = uninstallHack.bind(this);
// Fallback to cover unforeseen cases where text selection has ended.
const uninstallHackOnMousemove = /** @param {!Event} event */ event => {
if (/** @type {!MouseEvent} */ (event).buttons === 0) {
uninstallHack.call(this);
}
};
stylePaneWrapperElement.addEventListener('mousedown', /** @param {!Event} event */ event => {
if (/** @type {!MouseEvent} */ (event).button !== 0 /* left or main button */) {
return;
}
this._splitWidget.element.classList.add('disable-resizer-for-elements-hack');
stylePaneWrapperElement.style.setProperty('height', `${stylePaneWrapperElement.offsetHeight}px`);
const largeLength = 1000000;
stylePaneWrapperElement.style.setProperty('left', `${- 1 * largeLength}px`);
stylePaneWrapperElement.style.setProperty('padding-left', `${largeLength}px`);
stylePaneWrapperElement.style.setProperty('width', `calc(100% + ${largeLength}px)`);
stylePaneWrapperElement.style.setProperty('position', 'fixed');
stylePaneWrapperElement.window().addEventListener('blur', uninstallHackBound);
stylePaneWrapperElement.window().addEventListener('contextmenu', uninstallHackBound, true);
stylePaneWrapperElement.window().addEventListener('dragstart', uninstallHackBound, true);
stylePaneWrapperElement.window().addEventListener('mousemove', uninstallHackOnMousemove, true);
stylePaneWrapperElement.window().addEventListener('mouseup', uninstallHackBound, true);
stylePaneWrapperElement.window().addEventListener('visibilitychange', uninstallHackBound);
}, true);
/**
* @this {!ElementsPanel}
*/
function uninstallHack() {
this._splitWidget.element.classList.remove('disable-resizer-for-elements-hack');
stylePaneWrapperElement.style.removeProperty('left');
stylePaneWrapperElement.style.removeProperty('padding-left');
stylePaneWrapperElement.style.removeProperty('width');
stylePaneWrapperElement.style.removeProperty('position');
stylePaneWrapperElement.window().removeEventListener('blur', uninstallHackBound);
stylePaneWrapperElement.window().removeEventListener('contextmenu', uninstallHackBound, true);
stylePaneWrapperElement.window().removeEventListener('dragstart', uninstallHackBound, true);
stylePaneWrapperElement.window().removeEventListener('mousemove', uninstallHackOnMousemove, true);
stylePaneWrapperElement.window().removeEventListener('mouseup', uninstallHackBound, true);
stylePaneWrapperElement.window().removeEventListener('visibilitychange', uninstallHackBound);
}
}
/**
* @param {!_splitMode} splitMode
*/
_initializeSidebarPanes(splitMode) {
this._splitWidget.setVertical(splitMode === _splitMode.Vertical);
this.showToolbarPane(null /* widget */, null /* toggle */);
const matchedStylePanesWrapper = new UI.Widget.VBox();
matchedStylePanesWrapper.element.classList.add('style-panes-wrapper');
this._stylesWidget.show(matchedStylePanesWrapper.element);
this._setupTextSelectionHack(matchedStylePanesWrapper.element);
const computedStylePanesWrapper = new UI.Widget.VBox();
computedStylePanesWrapper.element.classList.add('style-panes-wrapper');
this._computedStyleWidget.show(computedStylePanesWrapper.element);
const stylesSplitWidget = new UI.SplitWidget.SplitWidget(
true /* isVertical */, true /* secondIsSidebar */, 'elements.styles.sidebar.width', 100);
stylesSplitWidget.setMainWidget(matchedStylePanesWrapper);
stylesSplitWidget.hideSidebar();
stylesSplitWidget.enableShowModeSaving();
stylesSplitWidget.addEventListener(UI.SplitWidget.Events.ShowModeChanged, () => {
showMetricsWidgetInStylesPane();
});
this._stylesWidget.addEventListener(StylesSidebarPaneEvents.InitialUpdateCompleted, () => {
this._stylesWidget.appendToolbarItem(
stylesSplitWidget.createShowHideSidebarButton(i18nString(UIStrings.computedStylesSidebar)));
});
const showMetricsWidgetInComputedPane = () => {
this._metricsWidget.show(computedStylePanesWrapper.element, this._computedStyleWidget.element);
this._metricsWidget.toggleVisibility(true /* visible */);
this._stylesWidget.removeEventListener(StylesSidebarPaneEvents.StylesUpdateCompleted, toggleMetricsWidget);
};
const showMetricsWidgetInStylesPane = () => {
const showMergedComputedPane = stylesSplitWidget.showMode() === UI.SplitWidget.ShowMode.Both;
if (showMergedComputedPane) {
showMetricsWidgetInComputedPane();
} else {
this._metricsWidget.show(matchedStylePanesWrapper.element);
if (!this._stylesWidget.hasMatchedStyles) {
this._metricsWidget.toggleVisibility(false /* invisible */);
}
this._stylesWidget.addEventListener(StylesSidebarPaneEvents.StylesUpdateCompleted, toggleMetricsWidget);
}
};
let skippedInitialTabSelectedEvent = false;
/**
* @param {!Common.EventTarget.EventTargetEvent} event
*/
const toggleMetricsWidget = event => {
this._metricsWidget.toggleVisibility(event.data.hasMatchedStyles);
};
/**
* @param {!Common.EventTarget.EventTargetEvent} event
*/
const tabSelected = event => {
const tabId = /** @type {string} */ (event.data.tabId);
if (tabId === i18nString(UIStrings.computed)) {
computedStylePanesWrapper.show(computedView.element);
showMetricsWidgetInComputedPane();
} else if (tabId === i18nString(UIStrings.styles)) {
stylesSplitWidget.setSidebarWidget(computedStylePanesWrapper);
showMetricsWidgetInStylesPane();
}
if (skippedInitialTabSelectedEvent) {
// We don't log the initially selected sidebar pane to UMA because
// it will skew the histogram heavily toward the Styles pane
Host.userMetrics.sidebarPaneShown(tabId);
} else {
skippedInitialTabSelectedEvent = true;
}
};
this.sidebarPaneView = UI.ViewManager.ViewManager.instance().createTabbedLocation(
() => UI.ViewManager.ViewManager.instance().showView('elements'));
const tabbedPane = this.sidebarPaneView.tabbedPane();
if (this._splitMode !== _splitMode.Vertical) {
this._splitWidget.installResizer(tabbedPane.headerElement());
}
const stylesView = new UI.View.SimpleView(i18nString(UIStrings.styles));
this.sidebarPaneView.appendView(stylesView);
stylesView.element.classList.add('flex-auto');
stylesSplitWidget.show(stylesView.element);
const computedView = new UI.View.SimpleView(i18nString(UIStrings.computed));
computedView.element.classList.add('composite', 'fill');
tabbedPane.addEventListener(UI.TabbedPane.Events.TabSelected, tabSelected, this);
this.sidebarPaneView.appendView(computedView);
this._stylesViewToReveal = stylesView;
this.sidebarPaneView.appendApplicableItems('elements-sidebar');
const extensionSidebarPanes = Extensions.ExtensionServer.ExtensionServer.instance().sidebarPanes();
for (let i = 0; i < extensionSidebarPanes.length; ++i) {
this._addExtensionSidebarPane(extensionSidebarPanes[i]);
}
this._splitWidget.setSidebarWidget(this.sidebarPaneView.tabbedPane());
}
_updateSidebarPosition() {
if (this.sidebarPaneView && this.sidebarPaneView.tabbedPane().shouldHideOnDetach()) {
return;
} // We can't reparent extension iframes.
const position = Common.Settings.Settings.instance().moduleSetting('sidebarPosition').get();
let splitMode = _splitMode.Horizontal;
if (position === 'right' ||
(position === 'auto' && UI.InspectorView.InspectorView.instance().element.offsetWidth > 680)) {
splitMode = _splitMode.Vertical;
}
if (!this.sidebarPaneView) {
this._initializeSidebarPanes(splitMode);
return;
}
if (splitMode === this._splitMode) {
return;
}
this._splitMode = splitMode;
const tabbedPane = this.sidebarPaneView.tabbedPane();
this._splitWidget.uninstallResizer(tabbedPane.headerElement());
this._splitWidget.setVertical(this._splitMode === _splitMode.Vertical);
this.showToolbarPane(null /* widget */, null /* toggle */);
if (this._splitMode !== _splitMode.Vertical) {
this._splitWidget.installResizer(tabbedPane.headerElement());
}
}
/**
* @param {!Common.EventTarget.EventTargetEvent} event
*/
_extensionSidebarPaneAdded(event) {
const pane = /** @type {!Extensions.ExtensionPanel.ExtensionSidebarPane} */ (event.data);
this._addExtensionSidebarPane(pane);
}
/**
* @param {!Extensions.ExtensionPanel.ExtensionSidebarPane} pane
*/
_addExtensionSidebarPane(pane) {
if (this.sidebarPaneView && pane.panelName() === this.name) {
this.sidebarPaneView.appendView(pane);
}
}
/**
* @param {!SDK.CSSModel.CSSModel} cssModel
*/
_setupStyleTracking(cssModel) {
const gridStyleTracker = cssModel.createCSSPropertyTracker(TrackedCSSGridProperties);
gridStyleTracker.start();
this._gridStyleTrackerByCSSModel.set(cssModel, gridStyleTracker);
gridStyleTracker.addEventListener(
SDK.CSSModel.CSSPropertyTrackerEvents.TrackedCSSPropertiesUpdated, this._trackedCSSPropertiesUpdated, this);
}
/**
* @param {!SDK.CSSModel.CSSModel} cssModel
*/
_removeStyleTracking(cssModel) {
const gridStyleTracker = this._gridStyleTrackerByCSSModel.get(cssModel);
if (!gridStyleTracker) {
return;
}
gridStyleTracker.stop();
this._gridStyleTrackerByCSSModel.delete(cssModel);
gridStyleTracker.removeEventListener(
SDK.CSSModel.CSSPropertyTrackerEvents.TrackedCSSPropertiesUpdated, this._trackedCSSPropertiesUpdated, this);
}
/**
* @param {!Common.EventTarget.EventTargetEvent} event
*/
_trackedCSSPropertiesUpdated(event) {
const domNodes = /** @type {!Array<?SDK.DOMModel.DOMNode>} */ (event.data.domNodes);
for (const domNode of domNodes) {
if (!domNode) {
continue;
}
const treeElement = this._treeElementForNode(domNode);
if (treeElement) {
treeElement.updateStyleAdorners();
}
}
}
}
ElementsPanel._firstInspectElementCompletedForTest = function() {};
ElementsPanel._firstInspectElementNodeNameForTest = '';
/** @enum {symbol} */
export const _splitMode = {
Vertical: Symbol('Vertical'),
Horizontal: Symbol('Horizontal'),
};
const TrackedCSSGridProperties = [
{
name: 'display',
value: 'grid',
},
{
name: 'display',
value: 'inline-grid',
},
{
name: 'display',
value: 'flex',
},
{
name: 'display',
value: 'inline-flex',
},
];
/** @type {!ContextMenuProvider} */
let contextMenuProviderInstance;
/**
* @implements {UI.ContextMenu.Provider}
*/
export class ContextMenuProvider {
/**
* @override
* @param {!Event} event
* @param {!UI.ContextMenu.ContextMenu} contextMenu
* @param {!Object} object
*/
appendApplicableItems(event, contextMenu, object) {
if (!(object instanceof SDK.RemoteObject.RemoteObject &&
(/** @type {!SDK.RemoteObject.RemoteObject} */ (object)).isNode()) &&
!(object instanceof SDK.DOMModel.DOMNode) && !(object instanceof SDK.DOMModel.DeferredDOMNode)) {
return;
}
// Skip adding "Reveal..." menu item for our own tree outline.
if (ElementsPanel.instance().element.isAncestor(/** @type {!Node} */ (event.target))) {
return;
}
/** @type {function():*} */
const commandCallback = Common.Revealer.reveal.bind(Common.Revealer.Revealer, object);
contextMenu.revealSection().appendItem(i18nString(UIStrings.revealInElementsPanel), commandCallback);
}
static instance() {
if (!contextMenuProviderInstance) {
contextMenuProviderInstance = new ContextMenuProvider();
}
return contextMenuProviderInstance;
}
}
/** @type {DOMNodeRevealer} */
let dOMNodeRevealerInstance;
/**
* @implements {Common.Revealer.Revealer}
*/
export class DOMNodeRevealer {
/**
* @param {{forceNew: ?boolean}} opts
*/
static instance(opts = {forceNew: null}) {
const {forceNew} = opts;
if (!dOMNodeRevealerInstance || forceNew) {
dOMNodeRevealerInstance = new DOMNodeRevealer();
}
return dOMNodeRevealerInstance;
}
/**
* @override
* @param {!Object} node
* @param {boolean=} omitFocus
* @return {!Promise<void>}
*/
reveal(node, omitFocus) {
const panel = ElementsPanel.instance();
panel._pendingNodeReveal = true;
return new Promise(revealPromise);
/**
* @param {function():void} resolve
* @param {function(!Error):void} reject
*/
function revealPromise(resolve, reject) {
if (node instanceof SDK.DOMModel.DOMNode) {
onNodeResolved(/** @type {!SDK.DOMModel.DOMNode} */ (node));
} else if (node instanceof SDK.DOMModel.DeferredDOMNode) {
(/** @type {!SDK.DOMModel.DeferredDOMNode} */ (node)).resolve(checkDeferredDOMNodeThenReveal);
} else if (node instanceof SDK.RemoteObject.RemoteObject) {
const domModel =
/** @type {!SDK.RemoteObject.RemoteObject} */ (node).runtimeModel().target().model(SDK.DOMModel.DOMModel);
if (domModel) {
domModel.pushObjectAsNodeToFrontend(node).then(checkRemoteObjectThenReveal);
} else {
reject(new Error('Could not resolve a node to reveal.'));
}
} else {
reject(new Error('Can\'t reveal a non-node.'));
panel._pendingNodeReveal = false;
}
/**
* @param {!SDK.DOMModel.DOMNode} resolvedNode
*/
function onNodeResolved(resolvedNode) {
panel._pendingNodeReveal = false;
// A detached node could still have a parent and ownerDocument
// properties, which means stepping up through the hierarchy to ensure
// that the root node is the document itself. Any break implies
// detachment.
let currentNode = resolvedNode;
while (currentNode.parentNode) {
currentNode = currentNode.parentNode;
}
const isDetached = !(currentNode instanceof SDK.DOMModel.DOMDocument);
const isDocument = node instanceof SDK.DOMModel.DOMDocument;
if (!isDocument && isDetached) {
const msg = i18nString(UIStrings.nodeCannotBeFoundInTheCurrent);
Common.Console.Console.instance().warn(msg);
reject(new Error(msg));
return;
}
if (resolvedNode) {
panel.revealAndSelectNode(resolvedNode, !omitFocus).then(resolve);
return;
}
reject(new Error('Could not resolve node to reveal.'));
}
/**
* @param {?SDK.DOMModel.DOMNode} resolvedNode
*/
function checkRemoteObjectThenReveal(resolvedNode) {
if (!resolvedNode) {
const msg = i18nString(UIStrings.theRemoteObjectCouldNotBe);
Common.Console.Console.instance().warn(msg);
reject(new Error(msg));
return;
}
onNodeResolved(resolvedNode);
}
/**
* @param {?SDK.DOMModel.DOMNode} resolvedNode
*/
function checkDeferredDOMNodeThenReveal(resolvedNode) {
if (!resolvedNode) {
const msg = i18nString(UIStrings.theDeferredDomNodeCouldNotBe);
Common.Console.Console.instance().warn(msg);
reject(new Error(msg));
return;
}
onNodeResolved(resolvedNode);
}
}
}
}
/** @type {CSSPropertyRevealer} */
let cSSPropertyRevealerInstance;
/**
* @implements {Common.Revealer.Revealer}
*/
export class CSSPropertyRevealer {
/**
* @param {{forceNew: ?boolean}} opts
*/
static instance(opts = {forceNew: null}) {
const {forceNew} = opts;
if (!cSSPropertyRevealerInstance || forceNew) {
cSSPropertyRevealerInstance = new CSSPropertyRevealer();
}
return cSSPropertyRevealerInstance;
}
/**
* @override
* @param {!Object} property
* @return {!Promise<void>}
*/
reveal(property) {
const panel = ElementsPanel.instance();
return panel._revealProperty(/** @type {!SDK.CSSProperty.CSSProperty} */ (property));
}
}
/** @type {!ElementsActionDelegate} */
let elementsActionDelegateInstance;
/**
* @implements {UI.ActionRegistration.ActionDelegate}
*/
export class ElementsActionDelegate {
/**
* @override
* @param {!UI.Context.Context} context
* @param {string} actionId
* @return {boolean}
*/
handleAction(context, actionId) {
const node = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode);
if (!node) {
return true;
}
const treeOutline = ElementsTreeOutline.forDOMModel(node.domModel());
if (!treeOutline) {
return true;
}
switch (actionId) {
case 'elements.hide-element':
treeOutline.toggleHideElement(node);
return true;
case 'elements.edit-as-html':
treeOutline.toggleEditAsHTML(node);
return true;
case 'elements.duplicate-element':
treeOutline.duplicateNode(node);
return true;
case 'elements.undo':
SDK.DOMModel.DOMModelUndoStack.instance().undo();
ElementsPanel.instance()._stylesWidget.forceUpdate();
return true;
case 'elements.redo':
SDK.DOMModel.DOMModelUndoStack.instance().redo();
ElementsPanel.instance()._stylesWidget.forceUpdate();
return true;
}
return false;
}
/**
* @param {{forceNew: ?boolean}=} opts
* @return {!ElementsActionDelegate}
*/
static instance(opts = {forceNew: null}) {
const {forceNew} = opts;
if (!elementsActionDelegateInstance || forceNew) {
elementsActionDelegateInstance = new ElementsActionDelegate();
}
return elementsActionDelegateInstance;
}
}
/** @type {!PseudoStateMarkerDecorator} */
let pseudoStateMarkerDecoratorInstance;
/**
* @implements {MarkerDecorator}
*/
export class PseudoStateMarkerDecorator {
/**
* @param {{forceNew: ?boolean}} opts
*/
static instance(opts = {forceNew: null}) {
const {forceNew} = opts;
if (!pseudoStateMarkerDecoratorInstance || forceNew) {
pseudoStateMarkerDecoratorInstance = new PseudoStateMarkerDecorator();
}
return pseudoStateMarkerDecoratorInstance;
}
/**
* @override
* @param {!SDK.DOMModel.DOMNode} node
* @return {?{title: string, color: string}}
*/
decorate(node) {
const pseudoState = node.domModel().cssModel().pseudoState(node);
if (!pseudoState) {
return null;
}
return {color: 'orange', title: i18nString(UIStrings.elementStateS, {PH1: ':' + pseudoState.join(', :')})};
}
}