UNPKG

chrome-devtools-frontend

Version:
1,439 lines (1,236 loc) • 75.1 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 @devtools/no-imperative-dom-api */ /* eslint-disable @devtools/no-lit-render-outside-of-view */ /* * 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 i18n from '../../core/i18n/i18n.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as Badges from '../../models/badges/badges.js'; import * as Elements from '../../models/elements/elements.js'; import * as IssuesManager from '../../models/issues_manager/issues_manager.js'; import * as CodeHighlighter from '../../ui/components/code_highlighter/code_highlighter.js'; import * as Highlighting from '../../ui/components/highlighting/highlighting.js'; import * as IssueCounter from '../../ui/components/issue_counter/issue_counter.js'; import * as UI from '../../ui/legacy/legacy.js'; import {html, nothing, render} from '../../ui/lit/lit.js'; import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import {AdoptedStyleSheetTreeElement} from './AdoptedStyleSheetTreeElement.js'; import {getElementIssueDetails} from './ElementIssueUtils.js'; import {ElementsPanel} from './ElementsPanel.js'; import {ElementsTreeElement, InitialChildrenLimit, isOpeningTag} from './ElementsTreeElement.js'; import elementsTreeOutlineStyles from './elementsTreeOutline.css.js'; import {ImagePreviewPopover} from './ImagePreviewPopover.js'; import type {MarkerDecoratorRegistration} from './MarkerDecorator.js'; import {ShortcutTreeElement} from './ShortcutTreeElement.js'; import {TopLayerContainer} from './TopLayerContainer.js'; const UIStrings = { /** * @description ARIA accessible name in Elements Tree Outline of the Elements panel */ pageDom: 'Page DOM', /** * @description A context menu item to store a value as a global variable the Elements Panel */ storeAsGlobalVariable: 'Store as global variable', /** * @description Tree element expand all button element button text content in Elements Tree Outline of the Elements panel * @example {3} PH1 */ showAllNodesDMore: 'Show all nodes ({PH1} more)', /** * @description Text for popover that directs to Issues panel */ viewIssue: 'View Issue:', } as const; const str_ = i18n.i18n.registerUIStrings('panels/elements/ElementsTreeOutline.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const elementsTreeOutlineByDOMModel = new WeakMap<SDK.DOMModel.DOMModel, ElementsTreeOutline>(); const populatedTreeElements = new Set<ElementsTreeElement>(); export type View = typeof DEFAULT_VIEW; interface ViewInput { omitRootDOMNode: boolean; selectEnabled: boolean; hideGutter: boolean; visibleWidth?: number; visible?: boolean; wrap: boolean; showSelectionOnKeyboardFocus: boolean; preventTabOrder: boolean; deindentSingleNode: boolean; currentHighlightedNode: SDK.DOMModel.DOMNode|null; onSelectedNodeChanged: (event: Common.EventTarget.EventTargetEvent<{node: SDK.DOMModel.DOMNode | null, focus: boolean}>) => void; onElementsTreeUpdated: (event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode[]>) => void; onElementCollapsed: () => void; onElementExpanded: () => void; } interface ViewOutput { elementsTreeOutline?: ElementsTreeOutline; highlightedTreeElement: ElementsTreeElement|null; isUpdatingHighlights: boolean; alreadyExpandedParentTreeElement: ElementsTreeElement|null; } export const DEFAULT_VIEW = (input: ViewInput, output: ViewOutput, target: HTMLElement): void => { if (!output.elementsTreeOutline) { // FIXME: this is basically a ref to existing imperative // implementation. Once this is declarative the ref should not be // needed. output.elementsTreeOutline = new ElementsTreeOutline(input.omitRootDOMNode, input.selectEnabled, input.hideGutter); output.elementsTreeOutline.addEventListener( ElementsTreeOutline.Events.SelectedNodeChanged, input.onSelectedNodeChanged, this); output.elementsTreeOutline.addEventListener( ElementsTreeOutline.Events.ElementsTreeUpdated, input.onElementsTreeUpdated, this); output.elementsTreeOutline.addEventListener(UI.TreeOutline.Events.ElementExpanded, input.onElementExpanded, this); output.elementsTreeOutline.addEventListener(UI.TreeOutline.Events.ElementCollapsed, input.onElementCollapsed, this); target.appendChild(output.elementsTreeOutline.element); } if (input.visibleWidth !== undefined) { output.elementsTreeOutline.setVisibleWidth(input.visibleWidth); } if (input.visible !== undefined) { output.elementsTreeOutline.setVisible(input.visible); } output.elementsTreeOutline.setWordWrap(input.wrap); output.elementsTreeOutline.setShowSelectionOnKeyboardFocus(input.showSelectionOnKeyboardFocus, input.preventTabOrder); if (input.deindentSingleNode) { output.elementsTreeOutline.deindentSingleNode(); } // Node highlighting logic. FIXME: express as a lit template. const previousHighlightedNode = output.highlightedTreeElement?.node() ?? null; if (previousHighlightedNode !== input.currentHighlightedNode) { output.isUpdatingHighlights = true; let treeElement: ElementsTreeElement|null = null; if (output.highlightedTreeElement) { let currentTreeElement: ElementsTreeElement|null = output.highlightedTreeElement; while (currentTreeElement && currentTreeElement !== output.alreadyExpandedParentTreeElement) { if (currentTreeElement.expanded) { currentTreeElement.collapse(); } const parent: UI.TreeOutline.TreeElement|null = currentTreeElement.parent; currentTreeElement = parent instanceof ElementsTreeElement ? parent : null; } } output.highlightedTreeElement = null; output.alreadyExpandedParentTreeElement = null; if (input.currentHighlightedNode) { let deepestExpandedParent: SDK.DOMModel.DOMNode|null = input.currentHighlightedNode; const treeElementByNode = output.elementsTreeOutline.treeElementByNode; const treeIsNotExpanded = (deepestExpandedParent: SDK.DOMModel.DOMNode): boolean => { const element = treeElementByNode.get(deepestExpandedParent); return element ? !element.expanded : true; }; while (deepestExpandedParent && treeIsNotExpanded(deepestExpandedParent)) { deepestExpandedParent = deepestExpandedParent.parentNode; } output.alreadyExpandedParentTreeElement = (deepestExpandedParent ? treeElementByNode.get(deepestExpandedParent) : output.elementsTreeOutline.rootElement()) as ElementsTreeElement; treeElement = output.elementsTreeOutline.createTreeElementFor(input.currentHighlightedNode); } output.highlightedTreeElement = treeElement; output.elementsTreeOutline.setHoverEffect(treeElement); treeElement?.reveal(true); output.isUpdatingHighlights = false; } }; /** * The main goal of this presenter is to wrap ElementsTreeOutline until * ElementsTreeOutline can be fully integrated into DOMTreeWidget. * * FIXME: once TreeOutline is declarative, this file needs to be renamed * to DOMTreeWidget.ts. */ export class DOMTreeWidget extends UI.Widget.Widget { omitRootDOMNode = false; selectEnabled = false; hideGutter = false; showSelectionOnKeyboardFocus = false; preventTabOrder = false; deindentSingleNode = false; onSelectedNodeChanged: (event: Common.EventTarget.EventTargetEvent<{node: SDK.DOMModel.DOMNode | null, focus: boolean}>) => void = () => {}; onElementsTreeUpdated: (event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode[]>) => void = () => {}; onDocumentUpdated: (domModel: SDK.DOMModel.DOMModel) => void = () => {}; onElementExpanded: () => void = () => {}; onElementCollapsed: () => void = () => {}; #visible = false; #visibleWidth?: number; #wrap = false; set visibleWidth(width: number) { this.#visibleWidth = width; this.performUpdate(); } // FIXME: this is not declarative because ElementsTreeOutline can // change root node internally. set rootDOMNode(node: SDK.DOMModel.DOMNode|null) { this.performUpdate(); if (!this.#viewOutput.elementsTreeOutline) { throw new Error('Unexpected: missing elementsTreeOutline'); } this.#viewOutput.elementsTreeOutline.rootDOMNode = node; this.performUpdate(); } get rootDOMNode(): SDK.DOMModel.DOMNode|null { return this.#viewOutput.elementsTreeOutline?.rootDOMNode ?? null; } #currentHighlightedNode: SDK.DOMModel.DOMNode|null = null; #view: View; #viewOutput: ViewOutput = { highlightedTreeElement: null, alreadyExpandedParentTreeElement: null, isUpdatingHighlights: false, }; #highlightThrottler = new Common.Throttler.Throttler(100); constructor(element?: HTMLElement, view?: View) { super(element, { useShadowDom: false, delegatesFocus: false, }); this.#view = view ?? DEFAULT_VIEW; if (Common.Settings.Settings.instance().moduleSetting('highlight-node-on-hover-in-overlay').get()) { SDK.TargetManager.TargetManager.instance().addModelListener( SDK.OverlayModel.OverlayModel, SDK.OverlayModel.Events.HIGHLIGHT_NODE_REQUESTED, this.#highlightNode, this, {scoped: true}); SDK.TargetManager.TargetManager.instance().addModelListener( SDK.OverlayModel.OverlayModel, SDK.OverlayModel.Events.INSPECT_MODE_WILL_BE_TOGGLED, this.#clearHighlightedNode, this, {scoped: true}); } } #highlightNode(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void { void this.#highlightThrottler.schedule(() => { this.#currentHighlightedNode = event.data; this.requestUpdate(); }); } #clearHighlightedNode(): void { // Highlighting an element via tree outline will emit the // INSPECT_MODE_WILL_BE_TOGGLED event, therefore, we skip it if the view // informed us that it is updating the element. if (this.#viewOutput.isUpdatingHighlights) { return; } this.#currentHighlightedNode = null; this.performUpdate(); } selectDOMNode(node: SDK.DOMModel.DOMNode|null, focus?: boolean): void { this.#viewOutput?.elementsTreeOutline?.selectDOMNode(node, focus); } highlightNodeAttribute(node: SDK.DOMModel.DOMNode, attribute: string): void { this.#viewOutput?.elementsTreeOutline?.highlightNodeAttribute(node, attribute); } setWordWrap(wrap: boolean): void { this.#wrap = wrap; this.performUpdate(); } selectedDOMNode(): SDK.DOMModel.DOMNode|null { return this.#viewOutput.elementsTreeOutline?.selectedDOMNode() ?? null; } /** * FIXME: this is called to re-render everything from scratch, for * example, if global settings changed. Instead, the setting values * should be the input for the view function. */ reload(): void { this.#viewOutput.elementsTreeOutline?.update(); } /** * Used by layout tests. */ getTreeOutlineForTesting(): ElementsTreeOutline|undefined { return this.#viewOutput.elementsTreeOutline; } treeElementForNode(node: SDK.DOMModel.DOMNode): ElementsTreeElement|null { return this.#viewOutput.elementsTreeOutline?.findTreeElement(node) || null; } override performUpdate(): void { this.#view( { omitRootDOMNode: this.omitRootDOMNode, selectEnabled: this.selectEnabled, hideGutter: this.hideGutter, visibleWidth: this.#visibleWidth, visible: this.#visible, wrap: this.#wrap, showSelectionOnKeyboardFocus: this.showSelectionOnKeyboardFocus, preventTabOrder: this.preventTabOrder, deindentSingleNode: this.deindentSingleNode, currentHighlightedNode: this.#currentHighlightedNode, onElementsTreeUpdated: this.onElementsTreeUpdated.bind(this), onSelectedNodeChanged: event => { this.#clearHighlightedNode(); this.onSelectedNodeChanged(event); }, onElementCollapsed: () => { this.#clearHighlightedNode(); this.onElementCollapsed(); }, onElementExpanded: () => { this.#clearHighlightedNode(); this.onElementExpanded(); }, }, this.#viewOutput, this.contentElement); } modelAdded(domModel: SDK.DOMModel.DOMModel): void { this.performUpdate(); if (!this.#viewOutput.elementsTreeOutline) { throw new Error('Unexpected: missing elementsTreeOutline'); } this.#viewOutput.elementsTreeOutline.wireToDOMModel(domModel); this.performUpdate(); } modelRemoved(domModel: SDK.DOMModel.DOMModel): void { this.#viewOutput.elementsTreeOutline?.unwireFromDOMModel(domModel); this.performUpdate(); } /** * FIXME: which node is expanded should be part of the view input. */ expand(): void { if (this.#viewOutput.elementsTreeOutline?.selectedTreeElement) { this.#viewOutput.elementsTreeOutline.selectedTreeElement.expand(); } } /** * FIXME: which node is selected should be part of the view input. */ selectDOMNodeWithoutReveal(node: SDK.DOMModel.DOMNode): void { this.#viewOutput.elementsTreeOutline?.findTreeElement(node)?.select(); } /** * FIXME: adorners should be part of the view input. */ updateNodeAdorners(node: SDK.DOMModel.DOMNode): void { const element = this.#viewOutput.elementsTreeOutline?.findTreeElement(node); void element?.updateStyleAdorners(); void element?.updateAdorners(); } highlightMatch(node: SDK.DOMModel.DOMNode, query?: string): void { const treeElement = this.#viewOutput.elementsTreeOutline?.findTreeElement(node); if (!treeElement) { return; } if (query) { treeElement.highlightSearchResults(query); } treeElement.reveal(); const matches = treeElement.listItemElement.getElementsByClassName(Highlighting.highlightedSearchResultClassName); if (matches.length) { matches[0].scrollIntoViewIfNeeded(false); } treeElement.select(/* omitFocus */ true); } hideMatchHighlights(node: SDK.DOMModel.DOMNode): void { const treeElement = this.#viewOutput.elementsTreeOutline?.findTreeElement(node); if (!treeElement) { return; } treeElement.hideSearchHighlights(); } toggleHideElement(node: SDK.DOMModel.DOMNode): void { void this.#viewOutput.elementsTreeOutline?.toggleHideElement(node); } toggleEditAsHTML(node: SDK.DOMModel.DOMNode): void { this.#viewOutput.elementsTreeOutline?.toggleEditAsHTML(node); } duplicateNode(node: SDK.DOMModel.DOMNode): void { this.#viewOutput.elementsTreeOutline?.duplicateNode(node); } copyStyles(node: SDK.DOMModel.DOMNode): void { void this.#viewOutput.elementsTreeOutline?.findTreeElement(node)?.copyStyles(); } /** * FIXME: used to determine focus state, probably we can have a better * way to do it. */ empty(): boolean { return !this.#viewOutput.elementsTreeOutline; } override focus(): void { super.focus(); this.#viewOutput.elementsTreeOutline?.focus(); } override wasShown(): void { super.wasShown(); this.#visible = true; this.performUpdate(); } override detach(overrideHideOnDetach?: boolean): void { super.detach(overrideHideOnDetach); this.#visible = false; this.performUpdate(); } override show(parentElement: Element, insertBefore?: Node|null, suppressOrphanWidgetError = false): void { this.performUpdate(); const domModels = SDK.TargetManager.TargetManager.instance().models(SDK.DOMModel.DOMModel, {scoped: true}); for (const domModel of domModels) { if (domModel.parentModel()) { continue; } if (!this.rootDOMNode || this.rootDOMNode.domModel() !== domModel) { if (domModel.existingDocument()) { this.rootDOMNode = domModel.existingDocument(); this.onDocumentUpdated(domModel); } else { void domModel.requestDocument(); } } } super.show(parentElement, insertBefore, suppressOrphanWidgetError); } } export class ElementsTreeOutline extends Common.ObjectWrapper.eventMixin<ElementsTreeOutline.EventTypes, typeof UI.TreeOutline.TreeOutline>( UI.TreeOutline.TreeOutline) { treeElementByNode: WeakMap<SDK.DOMModel.DOMNode, ElementsTreeElement>; private readonly shadowRoot: ShadowRoot; readonly elementInternal: HTMLElement; private includeRootDOMNode: boolean; private selectEnabled: boolean|undefined; private rootDOMNodeInternal: SDK.DOMModel.DOMNode|null; selectedDOMNodeInternal: SDK.DOMModel.DOMNode|null; private visible: boolean; private readonly imagePreviewPopover: ImagePreviewPopover; private updateRecords: Map<SDK.DOMModel.DOMNode, Elements.ElementUpdateRecord.ElementUpdateRecord>; private treeElementsBeingUpdated: Set<ElementsTreeElement>; decoratorExtensions: MarkerDecoratorRegistration[]|null; private showHTMLCommentsSetting: Common.Settings.Setting<boolean>; private multilineEditing?: MultilineEditorController|null; private visibleWidthInternal?: number; private clipboardNodeData?: ClipboardData; private isXMLMimeTypeInternal?: boolean|null; suppressRevealAndSelect = false; private previousHoveredElement?: UI.TreeOutline.TreeElement; private treeElementBeingDragged?: ElementsTreeElement; private dragOverTreeElement?: ElementsTreeElement; private updateModifiedNodesTimeout?: number; #topLayerContainerByDocument = new WeakMap<SDK.DOMModel.DOMDocument, TopLayerContainer>(); #issuesManager?: IssuesManager.IssuesManager.IssuesManager; #popupHelper?: UI.PopoverHelper.PopoverHelper; #nodeElementToIssues = new Map<Element, IssuesManager.Issue.Issue[]>(); constructor(omitRootDOMNode?: boolean, selectEnabled?: boolean, hideGutter?: boolean) { super(); this.#issuesManager = IssuesManager.IssuesManager.IssuesManager.instance(); this.#issuesManager.addEventListener(IssuesManager.IssuesManager.Events.ISSUE_ADDED, this.#onIssueAdded, this); this.treeElementByNode = new WeakMap(); const shadowContainer = document.createElement('div'); this.shadowRoot = UI.UIUtils.createShadowRootWithCoreStyles( shadowContainer, {cssFile: [elementsTreeOutlineStyles, CodeHighlighter.codeHighlighterStyles]}); const outlineDisclosureElement = this.shadowRoot.createChild('div', 'elements-disclosure'); this.elementInternal = this.element; this.elementInternal.classList.add('elements-tree-outline', 'source-code'); if (hideGutter) { this.elementInternal.classList.add('elements-hide-gutter'); } UI.ARIAUtils.setLabel(this.elementInternal, i18nString(UIStrings.pageDom)); this.elementInternal.addEventListener('focusout', this.onfocusout.bind(this), false); this.elementInternal.addEventListener('mousedown', this.onmousedown.bind(this), false); this.elementInternal.addEventListener('mousemove', this.onmousemove.bind(this), false); this.elementInternal.addEventListener('mouseleave', this.onmouseleave.bind(this), false); this.elementInternal.addEventListener('dragstart', this.ondragstart.bind(this), false); this.elementInternal.addEventListener('dragover', this.ondragover.bind(this), false); this.elementInternal.addEventListener('dragleave', this.ondragleave.bind(this), false); this.elementInternal.addEventListener('drop', this.ondrop.bind(this), false); this.elementInternal.addEventListener('dragend', this.ondragend.bind(this), false); this.elementInternal.addEventListener('contextmenu', this.contextMenuEventFired.bind(this), false); this.elementInternal.addEventListener('clipboard-beforecopy', this.onBeforeCopy.bind(this), false); this.elementInternal.addEventListener('clipboard-copy', this.onCopyOrCut.bind(this, false), false); this.elementInternal.addEventListener('clipboard-cut', this.onCopyOrCut.bind(this, true), false); this.elementInternal.addEventListener('clipboard-paste', this.onPaste.bind(this), false); this.elementInternal.addEventListener('keydown', this.onKeyDown.bind(this), false); outlineDisclosureElement.appendChild(this.elementInternal); this.element = shadowContainer; this.contentElement.setAttribute('jslog', `${VisualLogging.tree('elements')}`); this.includeRootDOMNode = !omitRootDOMNode; this.selectEnabled = selectEnabled; this.rootDOMNodeInternal = null; this.selectedDOMNodeInternal = null; this.visible = false; this.imagePreviewPopover = new ImagePreviewPopover( this.contentElement, event => { let link: (Element|null) = (event.target as Element | null); while (link && !ImagePreviewPopover.getImageURL(link)) { link = link.parentElementOrShadowHost(); } return link; }, link => { const listItem = UI.UIUtils.enclosingNodeOrSelfWithNodeName(link, 'li'); if (!listItem) { return null; } const treeElement = (UI.TreeOutline.TreeElement.getTreeElementBylistItemNode(listItem) as ElementsTreeElement | undefined); if (!treeElement) { return null; } return treeElement.node(); }); this.updateRecords = new Map(); this.treeElementsBeingUpdated = new Set(); this.decoratorExtensions = null; this.showHTMLCommentsSetting = Common.Settings.Settings.instance().moduleSetting('show-html-comments'); this.showHTMLCommentsSetting.addChangeListener(this.onShowHTMLCommentsChange.bind(this)); this.setUseLightSelectionColor(true); // TODO(changhaohan): refactor the popover to use tooltip component. this.#popupHelper = new UI.PopoverHelper.PopoverHelper(this.elementInternal, event => { const hoveredNode = event.composedPath()[0] as Element; if (!hoveredNode?.matches('.violating-element')) { return null; } const issues = this.#nodeElementToIssues.get(hoveredNode); if (!issues) { return null; } return { box: hoveredNode.boxInWindow(), show: async (popover: UI.GlassPane.GlassPane) => { popover.setIgnoreLeftMargin(true); // clang-format off render(html` <div class="squiggles-content"> ${issues.map(issue => { const elementIssueDetails = getElementIssueDetails(issue); if (!elementIssueDetails) { // This shouldn't happen, but add this if check to pass ts check. return nothing; } const issueKindIconName = IssueCounter.IssueCounter.getIssueKindIconName(issue.getKind()); const openIssueEvent = (): Promise<void> => Common.Revealer.reveal(issue); return html` <div class="squiggles-content-item"> <devtools-icon .name=${issueKindIconName} @click=${openIssueEvent}></devtools-icon> <x-link class="link" @click=${openIssueEvent}>${i18nString(UIStrings.viewIssue)}</x-link> <span>${elementIssueDetails.tooltip}</span> </div>`;})} </div>`, popover.contentElement); // clang-format on return true; }, }; }, 'elements.issue'); this.#popupHelper.setTimeout(300); } static forDOMModel(domModel: SDK.DOMModel.DOMModel): ElementsTreeOutline|null { return elementsTreeOutlineByDOMModel.get(domModel) || null; } #onIssueAdded(event: Common.EventTarget.EventTargetEvent<IssuesManager.IssuesManager.IssueAddedEvent>): void { void this.#addTreeElementIssue(event.data.issue); } #addAllElementIssues(): void { if (!this.#issuesManager) { return; } for (const issue of this.#issuesManager.issues()) { void this.#addTreeElementIssue(issue); } } async #addTreeElementIssue(issue: IssuesManager.Issue.Issue): Promise<void> { const elementIssueDetails = getElementIssueDetails(issue); if (!elementIssueDetails) { return; } const {nodeId} = elementIssueDetails; if (!this.rootDOMNode || !nodeId) { return; } const deferredDOMNode = new SDK.DOMModel.DeferredDOMNode(this.rootDOMNode.domModel().target(), nodeId); const node = await deferredDOMNode.resolvePromise(); if (!node) { return; } const treeElement = this.findTreeElement(node); if (treeElement) { treeElement.addIssue(issue); const treeElementNodeElementsToIssues = treeElement.issuesByNodeElement; // This element could be the treeElement tags name or an attribute. for (const [element, issues] of treeElementNodeElementsToIssues) { this.#nodeElementToIssues.set(element, issues); } } } deindentSingleNode(): void { const firstChild = this.firstChild(); if (!firstChild || (firstChild && !firstChild.isExpandable())) { this.shadowRoot.querySelector('.elements-disclosure')?.classList.add('single-node'); } } updateNodeElementToIssue(element: Element, issues: IssuesManager.Issue.Issue[]): void { this.#nodeElementToIssues.set(element, issues); } private onShowHTMLCommentsChange(): void { const selectedNode = this.selectedDOMNode(); if (selectedNode && selectedNode.nodeType() === Node.COMMENT_NODE && !this.showHTMLCommentsSetting.get()) { this.selectDOMNode(selectedNode.parentNode); } this.update(); } setWordWrap(wrap: boolean): void { this.elementInternal.classList.toggle('elements-tree-nowrap', !wrap); } setMultilineEditing(multilineEditing: MultilineEditorController|null): void { this.multilineEditing = multilineEditing; } visibleWidth(): number { return this.visibleWidthInternal || 0; } setVisibleWidth(width: number): void { this.visibleWidthInternal = width; if (this.multilineEditing) { this.multilineEditing.resize(); } } private setClipboardData(data: ClipboardData|null): void { if (this.clipboardNodeData) { const treeElement = this.findTreeElement(this.clipboardNodeData.node); if (treeElement) { treeElement.setInClipboard(false); } delete this.clipboardNodeData; } if (data) { const treeElement = this.findTreeElement(data.node); if (treeElement) { treeElement.setInClipboard(true); } this.clipboardNodeData = data; } } resetClipboardIfNeeded(removedNode: SDK.DOMModel.DOMNode): void { if (this.clipboardNodeData?.node === removedNode) { this.setClipboardData(null); } } private onBeforeCopy(event: Event): void { event.handled = true; } private onCopyOrCut(isCut: boolean, event: Event): void { this.setClipboardData(null); // @ts-expect-error this bound in the main entry point const originalEvent = event['original']; if (!originalEvent?.target) { return; } // Don't prevent the normal copy if the user has a selection. if (originalEvent.target instanceof Node && originalEvent.target.hasSelection()) { return; } // Do not interfere with text editing. if (UI.UIUtils.isEditing()) { return; } const targetNode = this.selectedDOMNode(); if (!targetNode) { return; } if (!originalEvent.clipboardData) { return; } originalEvent.clipboardData.clearData(); event.handled = true; this.performCopyOrCut(isCut, targetNode); } performCopyOrCut(isCut: boolean, node: SDK.DOMModel.DOMNode|null, includeShadowRoots = false): void { if (!node) { return; } if (isCut && (node.isShadowRoot() || node.ancestorUserAgentShadowRoot())) { return; } void node.getOuterHTML(includeShadowRoots).then(outerHTML => { if (outerHTML !== null) { UI.UIUtils.copyTextToClipboard(outerHTML); } }); this.setClipboardData({node, isCut}); } canPaste(targetNode: SDK.DOMModel.DOMNode): boolean { if (targetNode.isShadowRoot() || targetNode.ancestorUserAgentShadowRoot()) { return false; } if (!this.clipboardNodeData) { return false; } const node = this.clipboardNodeData.node; if (this.clipboardNodeData.isCut && (node === targetNode || node.isAncestor(targetNode))) { return false; } if (targetNode.domModel() !== node.domModel()) { return false; } return true; } pasteNode(targetNode: SDK.DOMModel.DOMNode): void { if (this.canPaste(targetNode)) { this.performPaste(targetNode); } } duplicateNode(targetNode: SDK.DOMModel.DOMNode): void { this.performDuplicate(targetNode); } private onPaste(event: Event): void { // Do not interfere with text editing. if (UI.UIUtils.isEditing()) { return; } const targetNode = this.selectedDOMNode(); if (!targetNode || !this.canPaste(targetNode)) { return; } event.handled = true; this.performPaste(targetNode); } private performPaste(targetNode: SDK.DOMModel.DOMNode): void { if (!this.clipboardNodeData) { return; } if (this.clipboardNodeData.isCut) { this.clipboardNodeData.node.moveTo(targetNode, null, expandCallback.bind(this)); this.setClipboardData(null); } else { this.clipboardNodeData.node.copyTo(targetNode, null, expandCallback.bind(this)); } function expandCallback( this: ElementsTreeOutline, error: string|null, pastedNode: SDK.DOMModel.DOMNode|null): void { if (error || !pastedNode) { return; } this.selectDOMNode(pastedNode); } } private performDuplicate(targetNode: SDK.DOMModel.DOMNode): void { if (targetNode.isInShadowTree()) { return; } const parentNode = targetNode.parentNode ? targetNode.parentNode : targetNode; if (parentNode.nodeName() === '#document') { return; } targetNode.copyTo(parentNode, targetNode.nextSibling); } setVisible(visible: boolean): void { if (visible === this.visible) { return; } this.visible = visible; if (!this.visible) { this.imagePreviewPopover.hide(); if (this.multilineEditing) { this.multilineEditing.cancel(); } return; } this.runPendingUpdates(); if (this.selectedDOMNodeInternal) { this.revealAndSelectNode(this.selectedDOMNodeInternal, false); } } get rootDOMNode(): SDK.DOMModel.DOMNode|null { return this.rootDOMNodeInternal; } set rootDOMNode(x: SDK.DOMModel.DOMNode|null) { if (this.rootDOMNodeInternal === x) { return; } this.rootDOMNodeInternal = x; this.isXMLMimeTypeInternal = x?.isXMLNode(); this.update(); } get isXMLMimeType(): boolean { return Boolean(this.isXMLMimeTypeInternal); } selectedDOMNode(): SDK.DOMModel.DOMNode|null { return this.selectedDOMNodeInternal; } selectDOMNode(node: SDK.DOMModel.DOMNode|null, focus?: boolean): void { if (this.selectedDOMNodeInternal === node) { this.revealAndSelectNode(node, !focus); return; } this.selectedDOMNodeInternal = node; this.revealAndSelectNode(node, !focus); // The revealAndSelectNode() method might find a different element if there is inlined text, // and the select() call would change the selectedDOMNode and reenter this setter. So to // avoid calling selectedNodeChanged() twice, first check if selectedDOMNodeInternal is the same // node as the one passed in. if (this.selectedDOMNodeInternal === node) { this.selectedNodeChanged(Boolean(focus)); } } editing(): boolean { const node = this.selectedDOMNode(); if (!node) { return false; } const treeElement = this.findTreeElement(node); if (!treeElement) { return false; } return treeElement.isEditing() || false; } update(): void { const selectedNode = this.selectedDOMNode(); this.removeChildren(); if (!this.rootDOMNode) { return; } if (this.includeRootDOMNode) { const treeElement = this.createElementTreeElement(this.rootDOMNode); this.appendChild(treeElement); } else { // FIXME: this could use findTreeElement to reuse a tree element if it already exists const children = this.visibleChildren(this.rootDOMNode); for (const child of children) { const treeElement = this.createElementTreeElement(child); this.appendChild(treeElement); } } if (this.rootDOMNode instanceof SDK.DOMModel.DOMDocument) { void this.createTopLayerContainer(this.rootElement(), this.rootDOMNode); } if (selectedNode) { this.revealAndSelectNode(selectedNode, true); } } selectedNodeChanged(focus: boolean): void { this.dispatchEventToListeners( ElementsTreeOutline.Events.SelectedNodeChanged, {node: this.selectedDOMNodeInternal, focus}); } private fireElementsTreeUpdated(nodes: SDK.DOMModel.DOMNode[]): void { this.dispatchEventToListeners(ElementsTreeOutline.Events.ElementsTreeUpdated, nodes); } findTreeElement(node: SDK.DOMModel.DOMNode|SDK.DOMModel.AdoptedStyleSheet): ElementsTreeElement|null { if (node instanceof SDK.DOMModel.AdoptedStyleSheet) { return null; } let treeElement = this.lookUpTreeElement(node); if (!treeElement && node.nodeType() === Node.TEXT_NODE) { // The text node might have been inlined if it was short, so try to find the parent element. treeElement = this.lookUpTreeElement(node.parentNode); } return treeElement as ElementsTreeElement | null; } private lookUpTreeElement(node: SDK.DOMModel.DOMNode|null): UI.TreeOutline.TreeElement|null { if (!node) { return null; } const cachedElement = this.treeElementByNode.get(node); if (cachedElement) { return cachedElement; } // Walk up the parent pointers from the desired node const ancestors = []; let currentNode; for (currentNode = node.parentNode; currentNode; currentNode = currentNode.parentNode) { ancestors.push(currentNode); if (this.treeElementByNode.has(currentNode)) { // stop climbing as soon as we hit break; } } if (!currentNode) { return null; } // Walk down to populate each ancestor's children, to fill in the tree and the cache. for (let i = ancestors.length - 1; i >= 0; --i) { const child = ancestors[i - 1] || node; const treeElement = this.treeElementByNode.get(ancestors[i]); if (treeElement) { void treeElement.onpopulate(); // fill the cache with the children of treeElement if (child.index && child.index >= treeElement.expandedChildrenLimit()) { this.setExpandedChildrenLimit(treeElement, child.index + 1); } } } return this.treeElementByNode.get(node) || null; } createTreeElementFor(node: SDK.DOMModel.DOMNode): ElementsTreeElement|null { let treeElement = this.findTreeElement(node); if (treeElement) { return treeElement; } if (!node.parentNode) { return null; } treeElement = this.createTreeElementFor(node.parentNode); return treeElement ? this.showChild(treeElement, node) : null; } private revealAndSelectNode(node: SDK.DOMModel.DOMNode|null, omitFocus: boolean): void { if (this.suppressRevealAndSelect) { return; } if (!this.includeRootDOMNode && node === this.rootDOMNode && this.rootDOMNode) { node = this.rootDOMNode.firstChild; } if (!node) { return; } const treeElement = this.createTreeElementFor(node); if (!treeElement) { return; } treeElement.revealAndSelect(omitFocus); } highlightNodeAttribute(node: SDK.DOMModel.DOMNode, attribute: string): void { const treeElement = this.findTreeElement(node); if (!treeElement) { return; } treeElement.reveal(); treeElement.highlightAttribute(attribute); } treeElementFromEventInternal(event: MouseEvent): UI.TreeOutline.TreeElement|null { const scrollContainer = this.element.parentElement; if (!scrollContainer) { return null; } const x = event.pageX; const y = event.pageY; // Our list items have 1-pixel cracks between them vertically. We avoid // the cracks by checking slightly above and slightly below the mouse // and seeing if we hit the same element each time. const elementUnderMouse = this.treeElementFromPoint(x, y); const elementAboveMouse = this.treeElementFromPoint(x, y - 2); let element; if (elementUnderMouse === elementAboveMouse) { element = elementUnderMouse; } else { element = this.treeElementFromPoint(x, y + 2); } return element; } private onfocusout(_event: Event): void { SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(); } private onmousedown(event: MouseEvent): void { const element = this.treeElementFromEventInternal(event); if (element) { element.select(); } } setHoverEffect(treeElement: UI.TreeOutline.TreeElement|null): void { if (this.previousHoveredElement === treeElement) { return; } if (this.previousHoveredElement instanceof ElementsTreeElement) { this.previousHoveredElement.hovered = false; delete this.previousHoveredElement; } if (treeElement instanceof ElementsTreeElement) { treeElement.hovered = true; this.previousHoveredElement = treeElement; } } private onmousemove(event: MouseEvent): void { const element = this.treeElementFromEventInternal(event); if (element && this.previousHoveredElement === element) { return; } this.setHoverEffect(element); this.highlightTreeElement( (element as UI.TreeOutline.TreeElement), !UI.KeyboardShortcut.KeyboardShortcut.eventHasEitherCtrlOrMeta(event)); } private highlightTreeElement(element: UI.TreeOutline.TreeElement, showInfo: boolean): void { if (element instanceof ElementsTreeElement) { element.node().domModel().overlayModel().highlightInOverlay( {node: element.node(), selectorList: undefined}, 'all', showInfo); return; } if (element instanceof ShortcutTreeElement) { element.domModel().overlayModel().highlightInOverlay( {deferredNode: element.deferredNode(), selectorList: undefined}, 'all', showInfo); } } private onmouseleave(_event: MouseEvent): void { this.setHoverEffect(null); SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(); } private ondragstart(event: DragEvent): boolean|undefined { const node = (event.target as Node | null); if (!node || node.hasSelection()) { return false; } if (node.nodeName === 'A') { return false; } const treeElement = this.validDragSourceOrTarget(this.treeElementFromEventInternal(event)); if (!treeElement) { return false; } if (treeElement.node().nodeName() === 'BODY' || treeElement.node().nodeName() === 'HEAD') { return false; } if (!event.dataTransfer || !treeElement.listItemElement.textContent) { return; } event.dataTransfer.setData('text/plain', treeElement.listItemElement.textContent.replace(/\u200b/g, '')); event.dataTransfer.effectAllowed = 'copyMove'; this.treeElementBeingDragged = treeElement; SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(); return true; } private ondragover(event: DragEvent): boolean { if (!this.treeElementBeingDragged) { return false; } const treeElement = this.validDragSourceOrTarget(this.treeElementFromEventInternal(event)); if (!treeElement) { return false; } let node: (SDK.DOMModel.DOMNode|null) = (treeElement.node() as SDK.DOMModel.DOMNode | null); while (node) { if (node === this.treeElementBeingDragged.nodeInternal) { return false; } node = node.parentNode; } treeElement.listItemElement.classList.add('elements-drag-over'); this.dragOverTreeElement = treeElement; event.preventDefault(); if (event.dataTransfer) { event.dataTransfer.dropEffect = 'move'; } return false; } private ondragleave(event: DragEvent): boolean { this.clearDragOverTreeElementMarker(); event.preventDefault(); return false; } private validDragSourceOrTarget(treeElement: UI.TreeOutline.TreeElement|null): ElementsTreeElement|null { if (!treeElement) { return null; } if (!(treeElement instanceof ElementsTreeElement)) { return null; } const elementsTreeElement = (treeElement); const node = elementsTreeElement.node(); if (!node.parentNode || node.parentNode.nodeType() !== Node.ELEMENT_NODE) { return null; } return elementsTreeElement; } private ondrop(event: DragEvent): void { event.preventDefault(); const treeElement = this.treeElementFromEventInternal(event); if (treeElement instanceof ElementsTreeElement) { this.doMove(treeElement); } } private doMove(treeElement: ElementsTreeElement): void { if (!this.treeElementBeingDragged) { return; } let parentNode; let anchorNode; if (treeElement.isClosingTag()) { // Drop onto closing tag -> insert as last child. parentNode = treeElement.node(); anchorNode = null; } else { const dragTargetNode = treeElement.node(); parentNode = dragTargetNode.parentNode; anchorNode = dragTargetNode; } if (!parentNode) { return; } const wasExpanded = this.treeElementBeingDragged.expanded; this.treeElementBeingDragged.nodeInternal.moveTo( parentNode, anchorNode, this.selectNodeAfterEdit.bind(this, wasExpanded)); delete this.treeElementBeingDragged; } private ondragend(event: DragEvent): void { event.preventDefault(); this.clearDragOverTreeElementMarker(); delete this.treeElementBeingDragged; } private clearDragOverTreeElementMarker(): void { if (this.dragOverTreeElement) { this.dragOverTreeElement.listItemElement.classList.remove('elements-drag-over'); delete this.dragOverTreeElement; } } private contextMenuEventFired(event: MouseEvent): void { const treeElement = this.treeElementFromEventInternal(event); if (treeElement instanceof ElementsTreeElement) { void this.showContextMenu(treeElement, event); } } async showContextMenu(treeElement: ElementsTreeElement, event: Event): Promise<void> { if (UI.UIUtils.isEditing()) { return; } const node = (event.target as Node | null); if (!node) { return; } // The context menu construction may be async. In order to // make sure that no other (default) context menu shows up, we need // to stop propagating and prevent the default action. event.stopPropagation(); event.preventDefault(); const contextMenu = new UI.ContextMenu.ContextMenu(event); const isPseudoElement = Boolean(treeElement.node().pseudoType()); const isTag = treeElement.node().nodeType() === Node.ELEMENT_NODE && !isPseudoElement; let textNode: Element|null = node.enclosingNodeOrSelfWithClass('webkit-html-text-node'); if (textNode?.classList.contains('bogus')) { textNode = null; } const commentNode = node.enclosingNodeOrSelfWithClass('webkit-html-comment'); contextMenu.saveSection().appendItem( i18nString(UIStrings.storeAsGlobalVariable), this.saveNodeToTempVariable.bind(this, treeElement.node()), {jslogContext: 'store-as-global-variable'}); if (textNode) { await treeElement.populateTextContextMenu(contextMenu, textNode); } else if (isTag) { await treeElement.populateTagContextMenu(contextMenu, event); } else if (commentNode) { await treeElement.populateNodeContextMenu(contextMenu); } else if (isPseudoElement) { treeElement.populatePseudoElementContextMenu(contextMenu); } ElementsPanel.instance().populateAdornerSettingsContextMenu(contextMenu); contextMenu.appendApplicableItems(treeElement.node()); void contextMenu.show(); } private async saveNodeToTempVariable(node: SDK.DOMModel.DOMNode): Promise<void> { const remoteObjectForConsole = await node.resolveToObject(); const consoleModel = remoteObjectForConsole?.runtimeModel().target()?.model(SDK.ConsoleModel.ConsoleModel); await consoleModel?.saveToTempVariable( UI.Context.Context.instance().flavor(SDK.RuntimeModel.ExecutionContext), remoteObjectForConsole); } runPendingUpdates(): void { this.updateModifiedNodes(); } private onKeyDown(event: Event): void { const keyboardEvent = (event as KeyboardEvent); if (UI.UIUtils.isEditing()) { return; } const node = this.selectedDOMNode(); if (!node) { return; } const treeElement = this.treeElementByNode.get(node); if (!treeElement) { return; } if (UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(keyboardEvent) && node.parentNode) { if (keyboardEvent.key === 'ArrowUp' && node.previousSibling) { node.moveTo(node.parentNode, node.previousSibling, this.selectNodeAfterEdit.bind(this, treeElement.expanded)); keyboardEvent.consume(true); return; } if (keyboardEvent.key === 'ArrowDown' && node.nextSibling) { node.moveTo( node.parentNode, node.nextSibling.nextSibling, this.selectNodeAfterEdit.bind(this, treeElement.expanded)); keyboardEvent.consume(true); return; } } } toggleEditAsHTML(node: SDK.DOMModel.DOMNode, startEditing?: boolean, callback?: (() => void)): void { const treeElement = this.treeElementByNode.get(node); if (!treeElement?.hasEditableNode()) { return; } if (node.pseudoType()) { return; } const parentNode = node.parentNode; const index = node.index; const wasExpanded = treeElement.expanded; treeElement.toggleEditAsHTML(editingFinished.bind(this), startEditing); function editingFinished(this: ElementsTreeOutline, success: boolean): void { if (callback) { callback(); } if (!success) { return; } Badges.UserBadges.instance().recordAction(Badges.BadgeAction.DOM_ELEMENT_OR_ATTRIBUTE_EDITED); // Select it and expand if necessary. We force tree update so that it processes dom events and is up to date. this.runPendingUpdates(); if (!index) { return; } const children = parentNode?.children(); const newNode = children ? children[index] || parentNode : parentNode; if (!newNode) { return; } this.selectDOMNode(newNode, true); if (wasExpanded) { const newTreeItem = this.findTreeElement(newNode); if (newTreeItem) { newTreeItem.expand(); } } } } selectNodeAfterEdit(wasExpanded: boolean, error: string|null, newNode: SDK.DOMModel.DOMNode|null): ElementsTreeElement |null { if (error) { return null; } // Select it and expand if necessary. We force tree update so that it processes dom events and is up to date. this.runPendingUpdates(); if (!newNode) { return null; } this.selectDOMNode(newNode, true); const newTreeItem = this.findTreeElement(newNode); if (wasExpanded) { if (newTreeItem) { newTreeItem.expand(); } } return newTreeItem; } /** * Runs a script on the node's remote object that toggles a class name on * the node and injects a stylesheet into the head of the node's document * containing a rule to set "visibility: hidden" on the class and all it's * ancestors. */ async toggleHideElement(node: SDK.DOMModel.DOMNode): Promise<void> { let pseudoElementName = node.pseudoType() ? node.nodeName() : null; if (pseudoElementName && node.pseudoIdentifier()) { pseudoElementName += `(${node.pseudoIdentifier()})`; } let effectiveNode: SDK.DOMModel.DOMNode|null = node; while (effectiveNode?.pseudoType()) { if (effectiveNode