| // 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 @devtools/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 type * as Protocol from '../../generated/protocol.js'; |
| import * as Annotations from '../../models/annotations/annotations.js'; |
| import * as ComputedStyle from '../../models/computed_style/computed_style.js'; |
| import * as PanelCommon from '../../panels/common/common.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 {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 const DEFAULT_COMPUTED_STYLES_DEBOUNCE_MS = 100; |
| |
| 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; |
| 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; |
| #restorationGeneration = 0; |
| 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; |
| #computedStyleModel: ComputedStyle.ComputedStyleModel.ComputedStyleModel; |
| |
| 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(Root.ExperimentNames.ExperimentName.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); |
| |
| this.#computedStyleModel = new ComputedStyle.ComputedStyleModel.ComputedStyleModel( |
| UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode)); |
| UI.Context.Context.instance().addFlavorChangeListener(SDK.DOMModel.DOMNode, event => { |
| this.#computedStyleModel.node = event.data; |
| this.evaluateTrackingComputedStyleUpdatesForNode(); |
| }); |
| |
| UI.Context.Context.instance().addFlavorChangeListener( |
| StylesSidebarPane, this.evaluateTrackingComputedStyleUpdatesForNode, this); |
| UI.Context.Context.instance().addFlavorChangeListener( |
| ComputedStyleWidget, this.evaluateTrackingComputedStyleUpdatesForNode, this); |
| |
| this.stylesWidget = new StylesSidebarPane(this.#computedStyleModel); |
| this.#computedStyleWidget = new ComputedStyleWidget(); |
| this.#computedStyleWidget.computedStyleModel = this.#computedStyleModel; |
| this.#computedStyleModel.addEventListener( |
| ComputedStyle.ComputedStyleModel.Events.COMPUTED_STYLE_CHANGED, this.#updateComputedStyles, this); |
| this.#computedStyleModel.addEventListener( |
| ComputedStyle.ComputedStyleModel.Events.CSS_MODEL_CHANGED, this.#updateComputedStyles, this); |
| |
| this.metricsWidget = new MetricsSidebarPane(this.#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.onElementExpanded = this.handleElementExpanded.bind(this); |
| this.#domTreeWidget.onElementCollapsed = this.handleElementCollapsed.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)); |
| PanelCommon.ExtensionServer.ExtensionServer.instance().addEventListener( |
| PanelCommon.ExtensionServer.Events.SidebarPaneAdded, this.extensionSidebarPaneAdded, this); |
| |
| if (Annotations.AnnotationRepository.annotationsEnabled()) { |
| PanelCommon.AnnotationManager.instance().initializePlacementForAnnotationType( |
| Annotations.AnnotationType.ELEMENT_NODE, this.resolveInitialState.bind(this), this.#domTreeWidget.element); |
| } |
| } |
| |
| // This is a debounced method because the user might be navigated from Styles tab to Computed Style tab and vice versa. |
| // For that case, we want to only run this function once. |
| private evaluateTrackingComputedStyleUpdatesForNode = Common.Debouncer.debounce((): void => { |
| const selectedNode = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode); |
| if (!selectedNode) { |
| return; |
| } |
| |
| const isComputedStyleWidgetVisible = Boolean(UI.Context.Context.instance().flavor(ComputedStyleWidget)); |
| const isStylesTabVisible = Boolean(UI.Context.Context.instance().flavor(StylesSidebarPane)); |
| const shouldTrackComputedStyleUpdates = isComputedStyleWidgetVisible || |
| (isStylesTabVisible && Root.Runtime.hostConfig.devToolsAnimationStylesInStylesTab?.enabled); |
| |
| void selectedNode.domModel()?.cssModel()?.trackComputedStyleUpdatesForNode( |
| shouldTrackComputedStyleUpdates ? selectedNode.id : undefined); |
| }, 100); |
| |
| async #updateComputedStyles(): Promise<void> { |
| const computedStyle = await this.#computedStyleModel.fetchComputedStyle(); |
| const matchedCascade = await this.#computedStyleModel.fetchMatchedCascade(); |
| this.#computedStyleWidget.nodeStyle = computedStyle; |
| this.#computedStyleWidget.matchedStyles = matchedCascade; |
| } |
| |
| private handleElementExpanded(): void { |
| if (Annotations.AnnotationRepository.annotationsEnabled()) { |
| void PanelCommon.AnnotationManager.instance().resolveAnnotationsOfType(Annotations.AnnotationType.ELEMENT_NODE); |
| } |
| } |
| |
| private handleElementCollapsed(): void { |
| if (Annotations.AnnotationRepository.annotationsEnabled()) { |
| void PanelCommon.AnnotationManager.instance().resolveAnnotationsOfType(Annotations.AnnotationType.ELEMENT_NODE); |
| } |
| } |
| |
| 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); |
| |
| if (Annotations.AnnotationRepository.annotationsEnabled()) { |
| void PanelCommon.AnnotationManager.instance().resolveAnnotationsOfType(Annotations.AnnotationType.ELEMENT_NODE); |
| } |
| } |
| |
| 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; |
| this.#restorationGeneration++; |
| } |
| |
| 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 this.restoreSelectedNodeAfterUpdate(domModel, this.selectedNodeOnReset || null, savedSelectedNodeOnReset); |
| } |
| |
| /** |
| * Best-effort restoration of the previously focused node after a reload. |
| * |
| * The CDP path-based mechanism works well for stable DOMs, but can be |
| * unreliable for pages that render asynchronously after the initial |
| * document update. To improve reliability we retry a few times, and also |
| * fall back to evaluating a JS path (document.querySelector(...)) when |
| * possible. |
| * |
| * Node resolution (computation) is separated from view state updates: |
| * resolveNode returns a DOMNode|null, and this method handles selection. |
| */ |
| private async restoreSelectedNodeAfterUpdate( |
| domModel: SDK.DOMModel.DOMModel, staleNode: SDK.DOMModel.DOMNode|null, |
| savedSelectedNodeOnReset: SDK.DOMModel.DOMNode|undefined): Promise<void> { |
| // Fast path: no previous node to restore -- just select the fallback |
| // synchronously so callers that check selection immediately still work. |
| if (!staleNode) { |
| this.trySetFallbackSelection(domModel); |
| return; |
| } |
| |
| const nodePath = staleNode.path(); |
| |
| // Keep the panel usable quickly by selecting a reasonable default node as |
| // soon as we can, but continue trying to restore the stale node. |
| let didSetFallbackSelection = false; |
| |
| // Retry with exponential-ish backoff, capping total wait at ~3s. |
| // Most async-rendered pages settle well within this window. |
| const attemptDelaysMs = [0, 250, 500, 1000, 1500]; |
| |
| // Capture the restoration generation so any user interaction (node |
| // selection, style editing, node reveal, etc.) cancels pending retries. |
| const restorationGeneration = this.#restorationGeneration; |
| |
| for (let attempt = 0; attempt < attemptDelaysMs.length; ++attempt) { |
| if (savedSelectedNodeOnReset !== this.selectedNodeOnReset) { |
| return; |
| } |
| if (this.hasNonDefaultSelectedNode || this.pendingNodeReveal || |
| restorationGeneration !== this.#restorationGeneration) { |
| return; |
| } |
| |
| if (attemptDelaysMs[attempt]) { |
| await new Promise<void>(resolve => window.setTimeout(resolve, attemptDelaysMs[attempt])); |
| } |
| |
| if (savedSelectedNodeOnReset !== this.selectedNodeOnReset) { |
| return; |
| } |
| if (this.hasNonDefaultSelectedNode || this.pendingNodeReveal || |
| restorationGeneration !== this.#restorationGeneration) { |
| return; |
| } |
| |
| // Computation: resolve the node without touching view state. |
| const restoredNode = await this.resolveNodeForRestoration(domModel, nodePath); |
| |
| if (restoredNode) { |
| this.setDefaultSelectedNode(restoredNode); |
| this.lastSelectedNodeSelectedForTest(); |
| return; |
| } |
| |
| if (!didSetFallbackSelection) { |
| // If we cannot compute a fallback selection yet, the document likely |
| // has not been transmitted from the backend and isn't in a valid state |
| // to have a default-selected node. Another document update should be |
| // forthcoming. In the meantime, don't notify tests that selection is |
| // ready, because it isn't. |
| if (!this.trySetFallbackSelection(domModel)) { |
| return; |
| } |
| didSetFallbackSelection = true; |
| } |
| } |
| } |
| |
| /** |
| * Attempts to resolve a DOM node by its CDP path. |
| * Pure computation -- does not modify view state. |
| */ |
| private async resolveNodeForRestoration(domModel: SDK.DOMModel.DOMModel, nodePath: string|null): |
| Promise<SDK.DOMModel.DOMNode|null> { |
| try { |
| if (nodePath) { |
| const restoredNodeId = await domModel.pushNodeByPathToFrontend(nodePath); |
| const restoredNode = domModel.nodeForId(restoredNodeId); |
| if (restoredNode) { |
| return restoredNode; |
| } |
| } |
| } catch { |
| // CDP calls (pushNodeByPathToFrontend) can reject when the target or |
| // session is closed, e.g. if the page navigates again while we are |
| // retrying. Safe to swallow: we either retry on the next iteration or |
| // fall through to the fallback node. |
| } |
| return null; |
| } |
| |
| private trySetFallbackSelection(domModel: SDK.DOMModel.DOMModel): boolean { |
| const inspectedDocument = domModel.existingDocument(); |
| const fallbackNode = inspectedDocument ? inspectedDocument.body || inspectedDocument.documentElement : null; |
| if (!fallbackNode) { |
| return false; |
| } |
| |
| this.setDefaultSelectedNode(fallbackNode); |
| this.lastSelectedNodeSelectedForTest(); |
| return true; |
| } |
| |
| cancelPendingRestoration(): void { |
| this.#restorationGeneration++; |
| } |
| |
| 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?.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|SDK.DOMModel.AdoptedStyleSheet, 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; |
| } |
| |
| async revealAndSelectAdoptedStyleSheet(nodeToReveal: SDK.DOMModel.AdoptedStyleSheet, opts?: RevealAndSelectNodeOpts): |
| Promise<void> { |
| const {showPanel = true, focusNode = false} = opts ?? {}; |
| this.omitDefaultSelection = true; |
| |
| if (showPanel) { |
| await UI.ViewManager.ViewManager.instance().showView('elements', false, !focus); |
| } |
| this.selectDOMNode(nodeToReveal, focusNode); |
| delete this.omitDefaultSelection; |
| } |
| |
| 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.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.hideWidget(); |
| } |
| this.stylesWidget.addEventListener(StylesSidebarPaneEvents.STYLES_UPDATE_COMPLETED, toggleMetricsWidget); |
| } |
| }; |
| |
| const toggleMetricsWidget = (event: Common.EventTarget.EventTargetEvent<StylesUpdateCompletedEvent>): void => { |
| if (event.data.hasMatchedStyles) { |
| this.metricsWidget.showWidget(); |
| } else { |
| this.metricsWidget.hideWidget(); |
| } |
| }; |
| |
| 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 = PanelCommon.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<PanelCommon.ExtensionPanel.ExtensionSidebarPane>): void { |
| this.addExtensionSidebarPane(event.data); |
| } |
| |
| private addExtensionSidebarPane(pane: PanelCommon.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); |
| } |
| |
| async resolveInitialState( |
| parentElement: Element, reveal: boolean, lookupId: string, |
| anchor?: SDK.DOMModel.DOMNode|SDK.NetworkRequest.NetworkRequest): Promise<{x: number, y: number}|null> { |
| if (!this.isShowing()) { |
| return null; |
| } |
| |
| if (!anchor) { |
| const backendNodeId = Number(lookupId) as Protocol.DOM.BackendNodeId; |
| if (isNaN(backendNodeId)) { |
| return null; |
| } |
| const rootDOMNode = this.#domTreeWidget.rootDOMNode; |
| if (!rootDOMNode) { |
| return null; |
| } |
| const domModel = rootDOMNode.domModel(); |
| const nodes = await domModel.pushNodesByBackendIdsToFrontend(new Set([backendNodeId])); |
| if (!nodes) { |
| return null; |
| } |
| const foundNode = nodes.get(backendNodeId); |
| if (!foundNode) { |
| return null; |
| } |
| anchor = foundNode; |
| } |
| |
| const element = this.#domTreeWidget.treeElementForNode(anchor as SDK.DOMModel.DOMNode); |
| if (!element) { |
| return null; |
| } |
| |
| if (reveal) { |
| // The node must have been revealed in order to calculate its position. |
| await Common.Revealer.reveal(anchor); |
| } |
| |
| // The tree element element starts at the top-left of the expand/collapse arrow). We |
| // want to aim for the tagname instead. |
| const offsetToTagName = 22; |
| const yPadding = 5; |
| |
| const targetRect = element.listItemElement.getBoundingClientRect(); |
| const parentRect = parentElement.getBoundingClientRect(); |
| const relativeX = targetRect.x - parentRect.x + offsetToTagName; |
| const relativeY = targetRect.y - parentRect.y + yPadding; |
| return {x: relativeX, y: relativeY}; |
| } |
| |
| 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|SDK.DOMModel.AdoptedStyleSheet> { |
| reveal( |
| node: SDK.DOMModel.DOMNode|SDK.DOMModel.DeferredDOMNode|SDK.RemoteObject.RemoteObject| |
| SDK.DOMModel.AdoptedStyleSheet, |
| omitFocus?: boolean): Promise<void> { |
| const panel = ElementsPanel.instance(); |
| panel.pendingNodeReveal = true; |
| panel.cancelPendingRestoration(); |
| |
| 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 || node instanceof SDK.DOMModel.AdoptedStyleSheet) { |
| 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|SDK.DOMModel.AdoptedStyleSheet): 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 instanceof SDK.DOMModel.AdoptedStyleSheet ? resolvedNode.parent : 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); |
| reject(new Platform.UserVisibleError.UserVisibleError(msg)); |
| return; |
| } |
| |
| if (resolvedNode) { |
| const opts: RevealAndSelectNodeOpts = {showPanel: true, focusNode: !omitFocus}; |
| const promise = resolvedNode instanceof SDK.DOMModel.AdoptedStyleSheet ? |
| panel.revealAndSelectAdoptedStyleSheet(resolvedNode, opts) : |
| panel.revealAndSelectNode(resolvedNode, opts); |
| void promise.then(resolve); |
| return; |
| } |
| const msg = i18nString(UIStrings.nodeCannotBeFoundInTheCurrent); |
| reject(new Platform.UserVisibleError.UserVisibleError(msg)); |
| } |
| |
| function checkRemoteObjectThenReveal(resolvedNode: SDK.DOMModel.DOMNode|null): void { |
| if (!resolvedNode) { |
| const msg = i18nString(UIStrings.theRemoteObjectCouldNotBe); |
| reject(new Platform.UserVisibleError.UserVisibleError(msg)); |
| return; |
| } |
| onNodeResolved(resolvedNode); |
| } |
| |
| function checkDeferredDOMNodeThenReveal(resolvedNode: SDK.DOMModel.DOMNode|null): void { |
| if (!resolvedNode) { |
| const msg = i18nString(UIStrings.theDeferredDomNodeCouldNotBe); |
| reject(new Platform.UserVisibleError.UserVisibleError(msg)); |
| return; |
| } |
| onNodeResolved(resolvedNode); |
| } |
| } |
| } |
| } |
| |
| export class CSSPropertyRevealer implements Common.Revealer.Revealer<SDK.CSSProperty.CSSProperty> { |
| reveal(property: SDK.CSSProperty.CSSProperty): Promise<void> { |
| const panel = ElementsPanel.instance(); |
| return panel.revealProperty(property); |
| } |
| } |
| |
| export class ElementsActionDelegate implements UI.ActionRegistration.ActionDelegate { |
| handleAction(context: UI.Context.Context, actionId: string): boolean { |
| const node = context.flavor(SDK.DOMModel.DOMNode); |
| if (!node) { |
| return true; |
| } |
| |
| switch (actionId) { |
| case 'elements.hide-element': |
| ElementsPanel.instance().toggleHideElement(node); |
| return true; |
| case 'elements.edit-as-html': |
| ElementsPanel.instance().toggleEditAsHTML(node); |
| return true; |
| case 'elements.duplicate-element': |
| ElementsPanel.instance().duplicateNode(node); |
| return true; |
| case 'elements.copy-styles': |
| ElementsPanel.instance().copyStyles(node); |
| return true; |
| case 'elements.undo': |
| void SDK.DOMModel.DOMModelUndoStack.instance().undo(); |
| ElementsPanel.instance().stylesWidget.forceUpdate(); |
| return true; |
| case 'elements.redo': |
| void SDK.DOMModel.DOMModelUndoStack.instance().redo(); |
| ElementsPanel.instance().stylesWidget.forceUpdate(); |
| return true; |
| case 'elements.toggle-a11y-tree': |
| ElementsPanel.instance().toggleAccessibilityTree(); |
| return true; |
| case 'elements.toggle-word-wrap': { |
| const setting = Common.Settings.Settings.instance().moduleSetting<boolean>('dom-word-wrap'); |
| setting.set(!setting.get()); |
| return true; |
| } |
| case 'elements.show-styles': |
| ElementsPanel.instance().selectAndShowSidebarTab(SidebarPaneTabId.STYLES); |
| return true; |
| case 'elements.show-computed': |
| ElementsPanel.instance().selectAndShowSidebarTab(SidebarPaneTabId.COMPUTED); |
| return true; |
| case 'elements.toggle-eye-dropper': { |
| const colorSwatchPopoverIcon = UI.Context.Context.instance().flavor(ColorSwatchPopoverIcon); |
| if (!colorSwatchPopoverIcon) { |
| return false; |
| } |
| |
| void colorSwatchPopoverIcon.toggleEyeDropper(); |
| } |
| } |
| return false; |
| } |
| } |
| |
| let pseudoStateMarkerDecoratorInstance: PseudoStateMarkerDecorator; |
| export class PseudoStateMarkerDecorator implements MarkerDecorator { |
| static instance(opts: { |
| forceNew: boolean|null, |
| } = {forceNew: null}): PseudoStateMarkerDecorator { |
| const {forceNew} = opts; |
| if (!pseudoStateMarkerDecoratorInstance || forceNew) { |
| pseudoStateMarkerDecoratorInstance = new PseudoStateMarkerDecorator(); |
| } |
| |
| return pseudoStateMarkerDecoratorInstance; |
| } |
| |
| decorate(node: SDK.DOMModel.DOMNode): { |
| title: string, |
| color: string, |
| }|null { |
| const pseudoState = node.domModel().cssModel().pseudoState(node); |
| if (!pseudoState) { |
| return null; |
| } |
| return { |
| color: '--sys-color-orange-bright', |
| title: i18nString(UIStrings.elementStateS, {PH1: ':' + pseudoState.join(', :')}), |
| }; |
| } |
| } |