UNPKG

chrome-devtools-frontend

Version:
1,286 lines (1,112 loc) • 54.2 kB
// Copyright 2021 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 rulesdir/no-imperative-dom-api */ /* * 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 '../../core/common/common.js'; import * as Host from '../../core/host/host.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as Platform from '../../core/platform/platform.js'; import * as Root from '../../core/root/root.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as Extensions from '../../models/extensions/extensions.js'; import type * as Adorners from '../../ui/components/adorners/adorners.js'; import * as Buttons from '../../ui/components/buttons/buttons.js'; import * as TreeOutline from '../../ui/components/tree_outline/tree_outline.js'; import * as UI from '../../ui/legacy/legacy.js'; import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import type {AXTreeNodeData} from './AccessibilityTreeUtils.js'; import {AccessibilityTreeView} from './AccessibilityTreeView.js'; import {ColorSwatchPopoverIcon} from './ColorSwatchPopoverIcon.js'; import * as ElementsComponents from './components/components.js'; import {ComputedStyleModel} from './ComputedStyleModel.js'; import {ComputedStyleWidget} from './ComputedStyleWidget.js'; import elementsPanelStyles from './elementsPanel.css.js'; import {DOMTreeWidget, type ElementsTreeOutline} from './ElementsTreeOutline.js'; import {LayoutPane} from './LayoutPane.js'; import type {MarkerDecorator} from './MarkerDecorator.js'; import {MetricsSidebarPane} from './MetricsSidebarPane.js'; import { Events as StylesSidebarPaneEvents, StylesSidebarPane, type StylesUpdateCompletedEvent, } from './StylesSidebarPane.js'; const UIStrings = { /** * @description Placeholder text for the search box the Elements Panel. Selector refers to CSS * selectors. */ findByStringSelectorOrXpath: 'Find by string, selector, or `XPath`', /** * @description Button text for a button that takes the user to the Accessibility Tree View from the * DOM tree view, in the Elements panel. */ switchToAccessibilityTreeView: 'Switch to Accessibility Tree view', /** * @description Button text for a button that takes the user to the DOM tree view from the * Accessibility Tree View, in the Elements panel. */ switchToDomTreeView: 'Switch to DOM Tree view', /** * @description Tooltip for the the Computed Styles sidebar toggle in the Styles pane. Command to * open/show the sidebar. */ showComputedStylesSidebar: 'Show Computed Styles sidebar', /** * @description Tooltip for the the Computed Styles sidebar toggle in the Styles pane. Command to * close/hide the sidebar. */ hideComputedStylesSidebar: 'Hide Computed Styles sidebar', /** * @description Screen reader announcement when the computed styles sidebar is shown in the Elements panel. */ computedStylesShown: 'Computed Styles sidebar shown', /** * @description Screen reader announcement when the computed styles sidebar is hidden in the Elements panel. */ computedStylesHidden: 'Computed Styles sidebar hidden', /** * @description Title of a pane in the Elements panel that shows computed styles for the selected * HTML element. Computed styles are the final, actual styles of the element, including all * implicit and specified styles. */ computed: 'Computed', /** * @description Title of a pane in the Elements panel that shows the CSS styles for the selected * HTML element. */ styles: 'Styles', /** * @description A context menu item to reveal a node in the DOM tree of the Elements Panel */ openInElementsPanel: 'Open in Elements panel', /** * @description Warning/error text displayed when a node cannot be found in the current 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. A remote * object is a JavaScript object that is not stored in DevTools, that DevTools has a connection to. * It should correspond to a local node. */ theRemoteObjectCouldNotBe: 'The remote object could not be resolved to a valid node.', /** * @description Console warning when the user tries to reveal a deferred DOM Node that resolves as * null. A deferred DOM node is a node we know about but have not yet fetched from the backend (we * defer the work until later). */ theDeferredDomNodeCouldNotBe: 'The deferred `DOM` Node could not be resolved to a valid node.', /** * @description Text in Elements Panel of the Elements panel. Shows the current CSS Pseudo-classes * applicable to the selected HTML element. * @example {::after, ::before} PH1 */ elementStateS: 'Element state: {PH1}', /** * @description Accessible name for side panel toolbar. */ sidePanelToolbar: 'Side panel toolbar', /** * @description Accessible name for side panel contents. */ sidePanelContent: 'Side panel content', /** * @description Accessible name for the DOM tree explorer view. */ domTreeExplorer: 'DOM tree explorer', /** * @description A context menu item to reveal a submenu with badge settings. */ adornerSettings: 'Badge settings', } as const; const str_ = i18n.i18n.registerUIStrings('panels/elements/ElementsPanel.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); /** * These strings need to match the `SidebarPaneCodes` in UserMetrics.ts. DevTools * collects usage metrics for the different sidebar tabs. */ export const enum SidebarPaneTabId { COMPUTED = 'computed', STYLES = 'styles', } type RevealAndSelectNodeOptsSelectionAndFocus = { showPanel?: false, focusNode?: never, }|{ showPanel: true, focusNode?: boolean, }; type RevealAndSelectNodeOpts = RevealAndSelectNodeOptsSelectionAndFocus&{ highlightInOverlay?: boolean, }; const createAccessibilityTreeToggleButton = (isActive: boolean): HTMLElement => { const button = new Buttons.Button.Button(); const title = isActive ? i18nString(UIStrings.switchToDomTreeView) : i18nString(UIStrings.switchToAccessibilityTreeView); button.data = { active: isActive, variant: Buttons.Button.Variant.TOOLBAR, iconName: 'person', title, jslogContext: 'toggle-accessibility-tree', }; button.tabIndex = 0; button.classList.add('axtree-button'); if (isActive) { button.classList.add('active'); } return button; }; let elementsPanelInstance: ElementsPanel; export class ElementsPanel extends UI.Panel.Panel implements UI.SearchableView.Searchable, SDK.TargetManager.SDKModelObserver<SDK.DOMModel.DOMModel>, UI.View.ViewLocationResolver { private splitWidget: UI.SplitWidget.SplitWidget; readonly #searchableView: UI.SearchableView.SearchableView; private mainContainer: HTMLDivElement; private domTreeContainer: HTMLDivElement; private splitMode: SplitMode|null; private readonly accessibilityTreeView: AccessibilityTreeView|undefined; private breadcrumbs: ElementsComponents.ElementsBreadcrumbs.ElementsBreadcrumbs; stylesWidget: StylesSidebarPane; private readonly computedStyleWidget: ComputedStyleWidget; private readonly metricsWidget: MetricsSidebarPane; private searchResults!: Array<{ domModel: SDK.DOMModel.DOMModel, index: number, node: ((SDK.DOMModel.DOMNode | undefined)|null), }>|undefined; private currentSearchResultIndex: number; pendingNodeReveal: boolean; private readonly adornerManager: ElementsComponents.AdornerManager.AdornerManager; private readonly adornersByName: Map<string, Set<Adorners.Adorner.Adorner>>; accessibilityTreeButton?: HTMLElement; domTreeButton?: HTMLElement; private selectedNodeOnReset?: SDK.DOMModel.DOMNode; private hasNonDefaultSelectedNode?: boolean; private searchConfig?: UI.SearchableView.SearchConfig; private omitDefaultSelection?: boolean; private notFirstInspectElement?: boolean; sidebarPaneView?: UI.View.TabbedViewLocation; private stylesViewToReveal?: UI.View.SimpleView; private nodeInsertedTaskRunner = { queue: Promise.resolve(), run(task: () => Promise<void>): void { this.queue = this.queue.then(task); }, }; private cssStyleTrackerByCSSModel: Map<SDK.CSSModel.CSSModel, SDK.CSSModel.CSSPropertyTracker>; #domTreeWidget: DOMTreeWidget; getTreeOutlineForTesting(): ElementsTreeOutline|undefined { return this.#domTreeWidget.getTreeOutlineForTesting(); } constructor() { super('elements'); this.registerRequiredCSS(elementsPanelStyles); this.splitWidget = new UI.SplitWidget.SplitWidget(true, true, 'elements-panel-split-view-state', 325, 325); this.splitWidget.addEventListener( UI.SplitWidget.Events.SIDEBAR_SIZE_CHANGED, this.updateTreeOutlineVisibleWidth.bind(this)); this.splitWidget.show(this.element); this.#searchableView = new UI.SearchableView.SearchableView(this, null); this.#searchableView.setMinimalSearchQuerySize(0); this.#searchableView.setMinimumSize(25, 28); this.#searchableView.setPlaceholder(i18nString(UIStrings.findByStringSelectorOrXpath)); const stackElement = this.#searchableView.element; this.mainContainer = document.createElement('div'); this.domTreeContainer = document.createElement('div'); const crumbsContainer = document.createElement('div'); if (Root.Runtime.experiments.isEnabled('full-accessibility-tree')) { this.initializeFullAccessibilityTreeView(); } this.mainContainer.appendChild(this.domTreeContainer); stackElement.appendChild(this.mainContainer); stackElement.appendChild(crumbsContainer); UI.ARIAUtils.markAsMain(this.domTreeContainer); UI.ARIAUtils.setLabel(this.domTreeContainer, i18nString(UIStrings.domTreeExplorer)); this.splitWidget.setMainWidget(this.#searchableView); this.splitMode = null; this.mainContainer.id = 'main-content'; this.domTreeContainer.id = 'elements-content'; this.domTreeContainer.tabIndex = -1; // FIXME: crbug.com/425984 if (Common.Settings.Settings.instance().moduleSetting('dom-word-wrap').get()) { this.domTreeContainer.classList.add('elements-wrap'); } Common.Settings.Settings.instance() .moduleSetting('dom-word-wrap') .addChangeListener(this.domWordWrapSettingChanged.bind(this)); crumbsContainer.id = 'elements-crumbs'; if (this.domTreeButton) { this.accessibilityTreeView = new AccessibilityTreeView(this.domTreeButton, new TreeOutline.TreeOutline.TreeOutline<AXTreeNodeData>()); } this.breadcrumbs = new ElementsComponents.ElementsBreadcrumbs.ElementsBreadcrumbs(); this.breadcrumbs.addEventListener('breadcrumbsnodeselected', event => { this.crumbNodeSelected(event); }); crumbsContainer.appendChild(this.breadcrumbs); const computedStyleModel = new ComputedStyleModel(); this.stylesWidget = new StylesSidebarPane(computedStyleModel); this.computedStyleWidget = new ComputedStyleWidget(computedStyleModel); this.metricsWidget = new MetricsSidebarPane(computedStyleModel); Common.Settings.Settings.instance() .moduleSetting('sidebar-position') .addChangeListener(this.updateSidebarPosition.bind(this)); this.updateSidebarPosition(); this.cssStyleTrackerByCSSModel = new Map(); this.currentSearchResultIndex = -1; // -1 represents the initial invalid state this.pendingNodeReveal = false; this.adornerManager = new ElementsComponents.AdornerManager.AdornerManager( Common.Settings.Settings.instance().moduleSetting('adorner-settings')); this.adornersByName = new Map(); this.#domTreeWidget = new DOMTreeWidget(); this.#domTreeWidget.omitRootDOMNode = true; this.#domTreeWidget.selectEnabled = true; this.#domTreeWidget.onSelectedNodeChanged = this.selectedNodeChanged.bind(this); this.#domTreeWidget.onElementsTreeUpdated = this.updateBreadcrumbIfNeeded.bind(this); this.#domTreeWidget.onDocumentUpdated = this.documentUpdated.bind(this); this.#domTreeWidget.setWordWrap(Common.Settings.Settings.instance().moduleSetting('dom-word-wrap').get()); SDK.TargetManager.TargetManager.instance().observeModels(SDK.DOMModel.DOMModel, this, {scoped: true}); SDK.TargetManager.TargetManager.instance().addEventListener( SDK.TargetManager.Events.NAME_CHANGED, event => this.targetNameChanged(event.data)); Common.Settings.Settings.instance() .moduleSetting('show-ua-shadow-dom') .addChangeListener(this.showUAShadowDOMChanged.bind(this)); Extensions.ExtensionServer.ExtensionServer.instance().addEventListener( Extensions.ExtensionServer.Events.SidebarPaneAdded, this.extensionSidebarPaneAdded, this); } private initializeFullAccessibilityTreeView(): void { this.accessibilityTreeButton = createAccessibilityTreeToggleButton(false); this.accessibilityTreeButton.addEventListener('click', this.showAccessibilityTree.bind(this)); this.domTreeButton = createAccessibilityTreeToggleButton(true); this.domTreeButton.addEventListener('click', this.showDOMTree.bind(this)); this.mainContainer.appendChild(this.accessibilityTreeButton); } private showAccessibilityTree(): void { if (this.accessibilityTreeView) { this.splitWidget.setMainWidget(this.accessibilityTreeView); } } private showDOMTree(): void { this.splitWidget.setMainWidget(this.#searchableView); const selectedNode = this.selectedDOMNode(); if (!selectedNode) { return; } this.#domTreeWidget.selectDOMNodeWithoutReveal(selectedNode); } toggleAccessibilityTree(): void { if (!this.domTreeButton) { return; } if (this.splitWidget.mainWidget() === this.accessibilityTreeView) { this.showDOMTree(); } else { this.showAccessibilityTree(); } } static instance(opts: { forceNew: boolean|null, }|undefined = {forceNew: null}): ElementsPanel { const {forceNew} = opts; if (!elementsPanelInstance || forceNew) { elementsPanelInstance = new ElementsPanel(); } return elementsPanelInstance; } revealProperty(cssProperty: SDK.CSSProperty.CSSProperty): Promise<void> { if (!this.sidebarPaneView || !this.stylesViewToReveal) { return Promise.resolve(); } return this.sidebarPaneView.showView(this.stylesViewToReveal).then(() => { this.stylesWidget.revealProperty((cssProperty)); }); } resolveLocation(_locationName: string): UI.View.ViewLocation|null { return this.sidebarPaneView || null; } showToolbarPane(widget: UI.Widget.Widget|null, toggle: UI.Toolbar.ToolbarToggle|null): void { // TODO(luoe): remove this function once its providers have an alternative way to reveal their views. this.stylesWidget.showToolbarPane(widget, toggle); } modelAdded(domModel: SDK.DOMModel.DOMModel): void { this.setupStyleTracking(domModel.cssModel()); this.#domTreeWidget.modelAdded(domModel); // Perform attach if necessary. if (this.isShowing()) { this.wasShown(); } if (this.domTreeContainer.hasFocus()) { this.#domTreeWidget.focus(); } domModel.addEventListener(SDK.DOMModel.Events.DocumentUpdated, this.documentUpdatedEvent, this); domModel.addEventListener(SDK.DOMModel.Events.NodeInserted, this.handleNodeInserted, this); } modelRemoved(domModel: SDK.DOMModel.DOMModel): void { domModel.removeEventListener(SDK.DOMModel.Events.DocumentUpdated, this.documentUpdatedEvent, this); domModel.removeEventListener(SDK.DOMModel.Events.NodeInserted, this.handleNodeInserted, this); this.#domTreeWidget.modelRemoved(domModel); if (!domModel.parentModel()) { this.#domTreeWidget.detach(); } this.removeStyleTracking(domModel.cssModel()); } private handleNodeInserted(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void { // Queue the task for the case when all the view transitions are added // around the same time. Otherwise there is a race condition on // accessing `cssText` of inspector stylesheet causing some rules // to be not added. this.nodeInsertedTaskRunner.run(async () => { const node = event.data; if (!node.isViewTransitionPseudoNode()) { return; } const cssModel = node.domModel().cssModel(); const styleSheetHeader = await cssModel.requestViaInspectorStylesheet(node.frameId()); if (!styleSheetHeader) { return; } const cssText = await cssModel.getStyleSheetText(styleSheetHeader.id); // Do not add a rule for the view transition pseudo if there already is a rule for it. if (cssText?.includes(`${node.simpleSelector()} {`)) { return; } await cssModel.setStyleSheetText(styleSheetHeader.id, `${cssText}\n${node.simpleSelector()} {}`, false); }); } private targetNameChanged(target: SDK.Target.Target): void { const domModel = target.model(SDK.DOMModel.DOMModel); if (!domModel) { return; } } private updateTreeOutlineVisibleWidth(): void { let width = this.splitWidget.element.offsetWidth; if (this.splitWidget.isVertical()) { width -= this.splitWidget.sidebarSize(); } this.#domTreeWidget.visibleWidth = width; } override focus(): void { if (this.#domTreeWidget.empty()) { this.domTreeContainer.focus(); } else { this.#domTreeWidget.focus(); } } override searchableView(): UI.SearchableView.SearchableView { return this.#searchableView; } override wasShown(): void { super.wasShown(); UI.Context.Context.instance().setFlavor(ElementsPanel, this); this.#domTreeWidget.show(this.domTreeContainer); } override willHide(): void { SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(); this.#domTreeWidget.detach(); super.willHide(); UI.Context.Context.instance().setFlavor(ElementsPanel, null); } override onResize(): void { this.element.window().requestAnimationFrame(this.updateSidebarPosition.bind(this)); // Do not force layout. this.updateTreeOutlineVisibleWidth(); } private selectedNodeChanged( event: Common.EventTarget.EventTargetEvent<{node: SDK.DOMModel.DOMNode | null, focus: boolean}>): void { let selectedNode = event.data.node; // If the selectedNode is a pseudoNode, we want to ensure that it has a valid parentNode if (selectedNode?.pseudoType() && !selectedNode.parentNode) { selectedNode = null; } const {focus} = event.data; if (!selectedNode) { this.#domTreeWidget.selectDOMNode(null); } if (selectedNode) { const activeNode = ElementsComponents.Helper.legacyNodeToElementsComponentsNode(selectedNode); const crumbs = [activeNode]; for (let current: (SDK.DOMModel.DOMNode|null) = selectedNode.parentNode; current; current = current.parentNode) { crumbs.push(ElementsComponents.Helper.legacyNodeToElementsComponentsNode(current)); } this.breadcrumbs.data = { crumbs, selectedNode: ElementsComponents.Helper.legacyNodeToElementsComponentsNode(selectedNode), }; if (this.accessibilityTreeView) { void this.accessibilityTreeView.selectedNodeChanged(selectedNode); } } else { this.breadcrumbs.data = {crumbs: [], selectedNode: null}; } UI.Context.Context.instance().setFlavor(SDK.DOMModel.DOMNode, selectedNode); if (!selectedNode) { return; } void selectedNode.setAsInspectedNode(); 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; } } } private documentUpdatedEvent(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMModel>): void { const domModel = event.data; this.documentUpdated(domModel); this.removeStyleTracking(domModel.cssModel()); this.setupStyleTracking(domModel.cssModel()); } private documentUpdated(domModel: SDK.DOMModel.DOMModel): void { this.#searchableView.cancelSearch(); if (!domModel.existingDocument()) { if (this.isShowing()) { void domModel.requestDocument(); } return; } this.hasNonDefaultSelectedNode = false; if (this.omitDefaultSelection) { return; } const savedSelectedNodeOnReset = this.selectedNodeOnReset; void restoreNode.call(this, domModel, this.selectedNodeOnReset || null); async function restoreNode( this: ElementsPanel, domModel: SDK.DOMModel.DOMModel, staleNode: SDK.DOMModel.DOMNode|null): Promise<void> { const nodePath = staleNode ? staleNode.path() : null; const restoredNodeId = nodePath ? await domModel.pushNodeByPathToFrontend(nodePath) : null; if (savedSelectedNodeOnReset !== this.selectedNodeOnReset) { return; } let node = domModel.nodeForId(restoredNodeId); 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(); } } } private lastSelectedNodeSelectedForTest(): void { } private setDefaultSelectedNode(node: SDK.DOMModel.DOMNode|null): void { if (!node || this.hasNonDefaultSelectedNode || this.pendingNodeReveal) { return; } this.selectDOMNode(node); this.#domTreeWidget.expand(); } onSearchClosed(): void { const selectedNode = this.selectedDOMNode(); if (!selectedNode) { return; } this.#domTreeWidget.selectDOMNodeWithoutReveal(selectedNode); } onSearchCanceled(): void { this.searchConfig = undefined; this.hideSearchHighlights(); this.#searchableView.updateSearchMatchesCount(0); this.currentSearchResultIndex = -1; delete this.searchResults; SDK.DOMModel.DOMModel.cancelSearch(); } performSearch(searchConfig: UI.SearchableView.SearchConfig, shouldJump: boolean, jumpBackwards?: boolean): void { const query = searchConfig.query; const whitespaceTrimmedQuery = query.trim(); if (!whitespaceTrimmedQuery.length) { return; } if (!this.searchConfig || this.searchConfig.query !== query) { this.onSearchCanceled(); } else { this.hideSearchHighlights(); } this.searchConfig = searchConfig; const showUAShadowDOM = Common.Settings.Settings.instance().moduleSetting('show-ua-shadow-dom').get(); const domModels = SDK.TargetManager.TargetManager.instance().models(SDK.DOMModel.DOMModel, {scoped: true}); const promises = domModels.map(domModel => domModel.performSearch(whitespaceTrimmedQuery, showUAShadowDOM)); void 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: (0|- 1)|number = this.currentSearchResultIndex; if (shouldJump) { if (this.currentSearchResultIndex === -1) { index = jumpBackwards ? -1 : 0; } else { index = jumpBackwards ? index - 1 : index + 1; } this.jumpToSearchResult(index); } }); } private domWordWrapSettingChanged(event: Common.EventTarget.EventTargetEvent<boolean>): void { this.domTreeContainer.classList.toggle('elements-wrap', event.data); this.#domTreeWidget.setWordWrap(event.data); } private jumpToSearchResult(index: number): void { if (!this.searchResults) { return; } this.currentSearchResultIndex = (index + this.searchResults.length) % this.searchResults.length; this.highlightCurrentSearchResult(); } jumpToNextSearchResult(): void { if (!this.searchResults || !this.searchConfig) { return; } this.performSearch(this.searchConfig, true); } jumpToPreviousSearchResult(): void { if (!this.searchResults || !this.searchConfig) { return; } this.performSearch(this.searchConfig, true, true); } supportsCaseSensitiveSearch(): boolean { return false; } supportsWholeWordSearch(): boolean { return false; } supportsRegexSearch(): boolean { return false; } private highlightCurrentSearchResult(): void { 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. void 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; } void searchResult.node.scrollIntoView(); if (searchResult.node) { this.#domTreeWidget.highlightMatch(searchResult.node, this.searchConfig?.query); } } private hideSearchHighlights(): void { if (!this.searchResults?.length || this.currentSearchResultIndex === -1) { return; } const searchResult = this.searchResults[this.currentSearchResultIndex]; if (!searchResult.node) { return; } this.#domTreeWidget.hideMatchHighlights(searchResult.node); } selectedDOMNode(): SDK.DOMModel.DOMNode|null { return this.#domTreeWidget.selectedDOMNode(); } selectDOMNode(node: SDK.DOMModel.DOMNode, focus?: boolean): void { this.#domTreeWidget.selectDOMNode(node, focus); } highlightNodeAttribute(node: SDK.DOMModel.DOMNode, attribute: string): void { this.#domTreeWidget.highlightNodeAttribute(node, attribute); } selectAndShowSidebarTab(tabId: SidebarPaneTabId): void { if (!this.sidebarPaneView) { return; } this.sidebarPaneView.tabbedPane().selectTab(tabId); if (!this.isShowing()) { void UI.ViewManager.ViewManager.instance().showView('elements'); } } private updateBreadcrumbIfNeeded(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode[]>): void { const nodes = 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 = ElementsComponents.Helper.legacyNodeToElementsComponentsNode(selectedNode); const existingCrumbs = [activeNode]; for (let current: (SDK.DOMModel.DOMNode|null) = selectedNode.parentNode; current; current = current.parentNode) { existingCrumbs.push(ElementsComponents.Helper.legacyNodeToElementsComponentsNode(current)); } /* Get the change nodes from the event & convert them to breadcrumb nodes */ const newNodes = nodes.map(ElementsComponents.Helper.legacyNodeToElementsComponentsNode); const nodesThatHaveChangedMap = new Map<number, ElementsComponents.Helper.DOMNode>(); 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, }; } private crumbNodeSelected(event: ElementsComponents.ElementsBreadcrumbs.NodeSelectedEvent): void { this.selectDOMNode(event.legacyDomNode, true); } private leaveUserAgentShadowDOM(node: SDK.DOMModel.DOMNode): SDK.DOMModel.DOMNode { let userAgentShadowRoot; while ((userAgentShadowRoot = node.ancestorUserAgentShadowRoot()) && userAgentShadowRoot.parentNode) { node = userAgentShadowRoot.parentNode; } return node; } async revealAndSelectNode(nodeToReveal: SDK.DOMModel.DOMNode, opts?: RevealAndSelectNodeOpts): Promise<void> { const {showPanel = true, focusNode = false, highlightInOverlay = true} = opts ?? {}; this.omitDefaultSelection = true; const node = Common.Settings.Settings.instance().moduleSetting('show-ua-shadow-dom').get() ? nodeToReveal : this.leaveUserAgentShadowDOM(nodeToReveal); if (highlightInOverlay) { node.highlightForTwoSeconds(); } if (this.accessibilityTreeView) { void this.accessibilityTreeView.revealAndSelectNode(nodeToReveal); } if (showPanel) { await UI.ViewManager.ViewManager.instance().showView('elements', false, !focus); } this.selectDOMNode(node, focusNode); delete this.omitDefaultSelection; if (!this.notFirstInspectElement) { ElementsPanel.firstInspectElementNodeNameForTest = node.nodeName(); ElementsPanel.firstInspectElementCompletedForTest(); Host.InspectorFrontendHost.InspectorFrontendHostInstance.inspectElementCompleted(); } this.notFirstInspectElement = true; } private showUAShadowDOMChanged(): void { this.#domTreeWidget.reload(); } private setupTextSelectionHack(stylePaneWrapperElement: HTMLElement): void { // 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 = (event: Event): void => { if ((event as MouseEvent).buttons === 0) { uninstallHack.call(this); } }; stylePaneWrapperElement.addEventListener('mousedown', (event: Event) => { if ((event as MouseEvent).button !== 0) { 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); function uninstallHack(this: ElementsPanel): void { 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); } } private initializeSidebarPanes(splitMode: SplitMode): void { 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'); matchedStylePanesWrapper.element.setAttribute('jslog', `${VisualLogging.pane('styles').track({resize: true})}`); this.stylesWidget.show(matchedStylePanesWrapper.element); this.setupTextSelectionHack(matchedStylePanesWrapper.element); const computedStylePanesWrapper = new UI.Widget.VBox(); computedStylePanesWrapper.element.classList.add('style-panes-wrapper'); computedStylePanesWrapper.element.setAttribute('jslog', `${VisualLogging.pane('computed').track({resize: true})}`); 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.SHOW_MODE_CHANGED, () => { showMetricsWidgetInStylesPane(); }); this.stylesWidget.addEventListener(StylesSidebarPaneEvents.INITIAL_UPDATE_COMPLETED, () => { this.stylesWidget.appendToolbarItem(stylesSplitWidget.createShowHideSidebarButton( i18nString(UIStrings.showComputedStylesSidebar), i18nString(UIStrings.hideComputedStylesSidebar), i18nString(UIStrings.computedStylesShown), i18nString(UIStrings.computedStylesHidden), 'computed-styles')); }); const showMetricsWidgetInComputedPane = (): void => { this.metricsWidget.show(computedStylePanesWrapper.element, this.computedStyleWidget.element); this.metricsWidget.toggleVisibility(true /* visible */); this.stylesWidget.removeEventListener(StylesSidebarPaneEvents.STYLES_UPDATE_COMPLETED, toggleMetricsWidget); }; const showMetricsWidgetInStylesPane = (): void => { 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.STYLES_UPDATE_COMPLETED, toggleMetricsWidget); } }; const toggleMetricsWidget = (event: Common.EventTarget.EventTargetEvent<StylesUpdateCompletedEvent>): void => { this.metricsWidget.toggleVisibility(event.data.hasMatchedStyles); }; const tabSelected = (event: Common.EventTarget.EventTargetEvent<UI.TabbedPane.EventData>): void => { const {tabId} = event.data; if (tabId === SidebarPaneTabId.COMPUTED) { computedStylePanesWrapper.show(computedView.element); showMetricsWidgetInComputedPane(); } else if (tabId === SidebarPaneTabId.STYLES) { stylesSplitWidget.setSidebarWidget(computedStylePanesWrapper); showMetricsWidgetInStylesPane(); } }; this.sidebarPaneView = UI.ViewManager.ViewManager.instance().createTabbedLocation( () => UI.ViewManager.ViewManager.instance().showView('elements'), 'styles-pane-sidebar', true, true); const tabbedPane = this.sidebarPaneView.tabbedPane(); tabbedPane.headerElement().setAttribute( 'jslog', `${VisualLogging.toolbar('sidebar').track({keydown: 'ArrowUp|ArrowLeft|ArrowDown|ArrowRight|Enter|Space'})}`); if (this.splitMode !== SplitMode.VERTICAL) { this.splitWidget.installResizer(tabbedPane.headerElement()); } const headerElement = tabbedPane.headerElement(); UI.ARIAUtils.markAsNavigation(headerElement); UI.ARIAUtils.setLabel(headerElement, i18nString(UIStrings.sidePanelToolbar)); const contentElement = tabbedPane.tabbedPaneContentElement(); UI.ARIAUtils.markAsComplementary(contentElement); UI.ARIAUtils.setLabel(contentElement, i18nString(UIStrings.sidePanelContent)); const stylesView = new UI.View.SimpleView({ title: i18nString(UIStrings.styles), viewId: SidebarPaneTabId.STYLES as Lowercase<string>, }); this.sidebarPaneView.appendView(stylesView); stylesView.element.classList.add('flex-auto'); stylesSplitWidget.show(stylesView.element); const computedView = new UI.View.SimpleView({ title: i18nString(UIStrings.computed), viewId: SidebarPaneTabId.COMPUTED as Lowercase<string>, }); 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()); } private updateSidebarPosition(): void { if (this.sidebarPaneView?.tabbedPane().shouldHideOnDetach()) { return; } // We can't reparent extension iframes. const position = Common.Settings.Settings.instance().moduleSetting('sidebar-position').get(); let splitMode = SplitMode.HORIZONTAL; if (position === 'right' || (position === 'auto' && this.splitWidget.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()); } } private extensionSidebarPaneAdded( event: Common.EventTarget.EventTargetEvent<Extensions.ExtensionPanel.ExtensionSidebarPane>): void { this.addExtensionSidebarPane(event.data); } private addExtensionSidebarPane(pane: Extensions.ExtensionPanel.ExtensionSidebarPane): void { if (this.sidebarPaneView && pane.panelName() === this.name) { this.sidebarPaneView.appendView(pane); } } getComputedStyleWidget(): ComputedStyleWidget { return this.computedStyleWidget; } private setupStyleTracking(cssModel: SDK.CSSModel.CSSModel): void { const cssPropertyTracker = cssModel.createCSSPropertyTracker(TrackedCSSProperties); cssPropertyTracker.start(); this.cssStyleTrackerByCSSModel.set(cssModel, cssPropertyTracker); cssPropertyTracker.addEventListener( SDK.CSSModel.CSSPropertyTrackerEvents.TRACKED_CSS_PROPERTIES_UPDATED, this.trackedCSSPropertiesUpdated, this); } private removeStyleTracking(cssModel: SDK.CSSModel.CSSModel): void { const cssPropertyTracker = this.cssStyleTrackerByCSSModel.get(cssModel); if (!cssPropertyTracker) { return; } cssPropertyTracker.stop(); this.cssStyleTrackerByCSSModel.delete(cssModel); cssPropertyTracker.removeEventListener( SDK.CSSModel.CSSPropertyTrackerEvents.TRACKED_CSS_PROPERTIES_UPDATED, this.trackedCSSPropertiesUpdated, this); } private trackedCSSPropertiesUpdated({data: domNodes}: Common.EventTarget.EventTargetEvent<Array<SDK.DOMModel.DOMNode|null>>): void { for (const domNode of domNodes) { if (!domNode) { continue; } this.#domTreeWidget.updateNodeAdorners(domNode); } LayoutPane.instance().requestUpdate(); } populateAdornerSettingsContextMenu(contextMenu: UI.ContextMenu.ContextMenu): void { const adornerSubMenu = contextMenu.viewSection().appendSubMenuItem( i18nString(UIStrings.adornerSettings), false, 'show-adorner-settings'); const adornerSettings = this.adornerManager.getSettings(); for (const [adorner, isEnabled] of adornerSettings) { adornerSubMenu.defaultSection().appendCheckboxItem(adorner, () => { const updatedIsEnabled = !isEnabled; const adornersToUpdate = this.adornersByName.get(adorner); if (adornersToUpdate) { for (const adornerToUpdate of adornersToUpdate) { updatedIsEnabled ? adornerToUpdate.show() : adornerToUpdate.hide(); } } this.adornerManager.getSettings().set(adorner, updatedIsEnabled); this.adornerManager.updateSettings(adornerSettings); }, {checked: isEnabled, jslogContext: adorner}); } } isAdornerEnabled(adornerText: string): boolean { return this.adornerManager.isAdornerEnabled(adornerText); } registerAdorner(adorner: Adorners.Adorner.Adorner): void { let adornerSet = this.adornersByName.get(adorner.name); if (!adornerSet) { adornerSet = new Set(); this.adornersByName.set(adorner.name, adornerSet); } adornerSet.add(adorner); if (!this.isAdornerEnabled(adorner.name)) { adorner.hide(); } } deregisterAdorner(adorner: Adorners.Adorner.Adorner): void { const adornerSet = this.adornersByName.get(adorner.name); if (!adornerSet) { return; } adornerSet.delete(adorner); } toggleHideElement(node: SDK.DOMModel.DOMNode): void { this.#domTreeWidget.toggleHideElement(node); } toggleEditAsHTML(node: SDK.DOMModel.DOMNode): void { this.#domTreeWidget.toggleEditAsHTML(node); } duplicateNode(node: SDK.DOMModel.DOMNode): void { this.#domTreeWidget.duplicateNode(node); } copyStyles(node: SDK.DOMModel.DOMNode): void { this.#domTreeWidget.copyStyles(node); } protected static firstInspectElementCompletedForTest = function(): void {}; protected static firstInspectElementNodeNameForTest = ''; } // @ts-expect-error exported for Tests.js globalThis.Elements = globalThis.Elements || {}; // @ts-expect-error exported for Tests.js globalThis.Elements.ElementsPanel = ElementsPanel; const enum SplitMode { VERTICAL = 'Vertical', HORIZONTAL = 'Horizontal', } const TrackedCSSProperties = [ { name: 'display', value: 'grid', }, { name: 'display', value: 'inline-grid', }, { name: 'display', value: 'flex', }, { name: 'display', value: 'inline-flex', }, { name: 'container-type', value: 'inline-size', }, { name: 'container-type', value: 'block-size', }, { name: 'container-type', value: 'size', }, ]; export class ContextMenuProvider implements UI.ContextMenu.Provider<SDK.RemoteObject.RemoteObject|SDK.DOMModel.DOMNode|SDK.DOMModel.DeferredDOMNode> { appendApplicableItems( event: Event, contextMenu: UI.ContextMenu.ContextMenu, object: SDK.RemoteObject.RemoteObject|SDK.DOMModel.DOMNode|SDK.DOMModel.DeferredDOMNode): void { if (object instanceof SDK.RemoteObject.RemoteObject && !object.isNode()) { return; } if (ElementsPanel.instance().element.isAncestor(event.target as (Node | null))) { return; } contextMenu.revealSection().appendItem( i18nString(UIStrings.openInElementsPanel), () => Common.Revealer.reveal(object), {jslogContext: 'elements.reveal-node'}); } } export class DOMNodeRevealer implements Common.Revealer.Revealer<SDK.DOMModel.DOMNode|SDK.DOMModel.DeferredDOMNode|SDK.RemoteObject.RemoteObject> { reveal(node: SDK.DOMModel.DOMNode|SDK.DOMModel.DeferredDOMNode|SDK.RemoteObject.RemoteObject, omitFocus?: boolean): Promise<void> { const panel = ElementsPanel.instance(); panel.pendingNodeReveal = true; return (new Promise<void>(revealPromise)).catch((reason: Error) => { let message: string; if (Platform.UserVisibleError.isUserVisibleError(reason)) { message = reason.message; } else { message = i18nString(UIStrings.nodeCannotBeFoundInTheCurrent); } Common.Console.Console.instance().warn(message); // Blink tests expect an exception to be raised and unhandled here to detect that the node // was actually not successfully viewed. throw reason; }); function revealPromise( resolve: () => void, reject: (arg0: Platform.UserVisibleError.UserVisibleError) => void): void { if (node instanceof SDK.DOMModel.DOMNode) { onNodeResolved((node)); } else if (node instanceof SDK.DOMModel.DeferredDOMNode) { (node).resolve(checkDeferredDOMNodeThenReveal); } else { const domModel = node.runtimeModel().target().model(SDK.DOMModel.DOMModel); if (domModel) { void domModel.pushObjectAsNodeToFrontend(node).then(checkRemoteObjectThenReveal); } else { const msg = i18nString(UIStrings.nodeCannotBeFoundInTheCurrent); reject(new Platform.UserVisibleError.UserVisibleError(msg)); } } function onNodeResolved(resolvedNode: SDK.DOMModel.DOMNode): void { 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: SDK.DOMModel.DOMNode = 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.node