blob: 793f34476ef47ab607c906c587e5897adc7c32b4 [file] [log] [blame]
// 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?.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 !== node && effectiveNode.pseudoType() === 'column') {
// Ideally we would select the specific column pseudo element, but
// we don't have a way to do that at the moment.
pseudoElementName = '::column' + pseudoElementName;
}
effectiveNode = effectiveNode.parentNode;
}
if (!effectiveNode) {
return;
}
const hidden = node.marker('hidden-marker');
const object = await effectiveNode.resolveToObject('');
if (!object) {
return;
}
await object.callFunction(
(toggleClassAndInjectStyleRule as (this: Object, ...arg1: unknown[]) => void),
[{value: pseudoElementName}, {value: !hidden}]);
object.release();
node.setMarker('hidden-marker', hidden ? null : true);
function toggleClassAndInjectStyleRule(this: Element, pseudoElementName: string|null, hidden: boolean): void {
const classNamePrefix = '__web-inspector-hide';
const classNameSuffix = '-shortcut__';
const styleTagId = '__web-inspector-hide-shortcut-style__';
const pseudoElementNameEscaped = pseudoElementName ? pseudoElementName.replace(/[\(\)\:]/g, '_') : '';
const className = classNamePrefix + pseudoElementNameEscaped + classNameSuffix;
this.classList.toggle(className, hidden);
let localRoot: Element|HTMLHeadElement = this;
while (localRoot.parentNode) {
localRoot = (localRoot.parentNode as Element);
}
if (localRoot.nodeType === Node.DOCUMENT_NODE) {
localRoot = document.head;
}
let style = localRoot.querySelector('style#' + styleTagId);
if (!style) {
const selectors = [];
selectors.push('.__web-inspector-hide-shortcut__');
selectors.push('.__web-inspector-hide-shortcut__ *');
const selector = selectors.join(', ');
const ruleBody = ' visibility: hidden !important;';
const rule = '\n' + selector + '\n{\n' + ruleBody + '\n}\n';
style = document.createElement('style');
style.id = styleTagId;
style.textContent = rule;
localRoot.appendChild(style);
}
// In addition to putting them on the element we want to hide, we will
// also add pseudo element classes to the style element to keep track of
// which pseudo elements we have style rules for.
if (pseudoElementName && !style.classList.contains(className)) {
style.classList.add(className);
style.textContent = `.${className}${pseudoElementName}, ${style.textContent}`;
}
}
}
isToggledToHidden(node: SDK.DOMModel.DOMNode): boolean {
return Boolean(node.marker('hidden-marker'));
}
private reset(): void {
this.rootDOMNode = null;
this.selectDOMNode(null, false);
this.imagePreviewPopover.hide();
delete this.clipboardNodeData;
SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight();
this.updateRecords.clear();
}
wireToDOMModel(domModel: SDK.DOMModel.DOMModel): void {
elementsTreeOutlineByDOMModel.set(domModel, this);
domModel.addEventListener(SDK.DOMModel.Events.MarkersChanged, this.markersChanged, this);
domModel.addEventListener(SDK.DOMModel.Events.NodeInserted, this.nodeInserted, this);
domModel.addEventListener(SDK.DOMModel.Events.NodeRemoved, this.nodeRemoved, this);
domModel.addEventListener(SDK.DOMModel.Events.AttrModified, this.attributeModified, this);
domModel.addEventListener(SDK.DOMModel.Events.AttrRemoved, this.attributeRemoved, this);
domModel.addEventListener(SDK.DOMModel.Events.CharacterDataModified, this.characterDataModified, this);
domModel.addEventListener(SDK.DOMModel.Events.DocumentUpdated, this.documentUpdated, this);
domModel.addEventListener(SDK.DOMModel.Events.ChildNodeCountUpdated, this.childNodeCountUpdated, this);
domModel.addEventListener(SDK.DOMModel.Events.DistributedNodesChanged, this.distributedNodesChanged, this);
domModel.addEventListener(
SDK.DOMModel.Events.AffectedByStartingStylesFlagUpdated, this.affectedByStartingStylesFlagUpdated, this);
domModel.addEventListener(SDK.DOMModel.Events.AdoptedStyleSheetsModified, this.adoptedStyleSheetsModified, this);
}
unwireFromDOMModel(domModel: SDK.DOMModel.DOMModel): void {
domModel.removeEventListener(SDK.DOMModel.Events.MarkersChanged, this.markersChanged, this);
domModel.removeEventListener(SDK.DOMModel.Events.NodeInserted, this.nodeInserted, this);
domModel.removeEventListener(SDK.DOMModel.Events.NodeRemoved, this.nodeRemoved, this);
domModel.removeEventListener(SDK.DOMModel.Events.AttrModified, this.attributeModified, this);
domModel.removeEventListener(SDK.DOMModel.Events.AttrRemoved, this.attributeRemoved, this);
domModel.removeEventListener(SDK.DOMModel.Events.CharacterDataModified, this.characterDataModified, this);
domModel.removeEventListener(SDK.DOMModel.Events.DocumentUpdated, this.documentUpdated, this);
domModel.removeEventListener(SDK.DOMModel.Events.ChildNodeCountUpdated, this.childNodeCountUpdated, this);
domModel.removeEventListener(SDK.DOMModel.Events.DistributedNodesChanged, this.distributedNodesChanged, this);
domModel.removeEventListener(
SDK.DOMModel.Events.AffectedByStartingStylesFlagUpdated, this.affectedByStartingStylesFlagUpdated, this);
domModel.removeEventListener(SDK.DOMModel.Events.AdoptedStyleSheetsModified, this.adoptedStyleSheetsModified, this);
elementsTreeOutlineByDOMModel.delete(domModel);
}
private addUpdateRecord(node: SDK.DOMModel.DOMNode): Elements.ElementUpdateRecord.ElementUpdateRecord {
let record = this.updateRecords.get(node);
if (!record) {
record = new Elements.ElementUpdateRecord.ElementUpdateRecord();
this.updateRecords.set(node, record);
}
return record;
}
private updateRecordForHighlight(node: SDK.DOMModel.DOMNode): Elements.ElementUpdateRecord.ElementUpdateRecord|null {
if (!this.visible) {
return null;
}
return this.updateRecords.get(node) || null;
}
private documentUpdated(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMModel>): void {
const domModel = event.data;
this.reset();
if (domModel.existingDocument()) {
this.rootDOMNode = domModel.existingDocument();
this.#addAllElementIssues();
}
}
private attributeModified(event: Common.EventTarget.EventTargetEvent<{node: SDK.DOMModel.DOMNode, name: string}>):
void {
const {node} = event.data;
this.addUpdateRecord(node).attributeModified(event.data.name);
this.updateModifiedNodesSoon();
}
private attributeRemoved(event: Common.EventTarget.EventTargetEvent<{node: SDK.DOMModel.DOMNode, name: string}>):
void {
const {node} = event.data;
this.addUpdateRecord(node).attributeRemoved(event.data.name);
this.updateModifiedNodesSoon();
}
private characterDataModified(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void {
const node = event.data;
this.addUpdateRecord(node).charDataModified();
// Text could be large and force us to render itself as the child in the tree outline.
if (node.parentNode && node.parentNode.firstChild === node.parentNode.lastChild) {
this.addUpdateRecord(node.parentNode).childrenModified();
}
this.updateModifiedNodesSoon();
}
private nodeInserted(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void {
const node = event.data;
this.addUpdateRecord((node.parentNode as SDK.DOMModel.DOMNode)).nodeInserted(node);
this.updateModifiedNodesSoon();
}
private nodeRemoved(
event: Common.EventTarget.EventTargetEvent<{node: SDK.DOMModel.DOMNode, parent: SDK.DOMModel.DOMNode}>): void {
const {node, parent} = event.data;
this.resetClipboardIfNeeded(node);
this.addUpdateRecord(parent).nodeRemoved(node);
this.updateModifiedNodesSoon();
}
private childNodeCountUpdated(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void {
const node = event.data;
this.addUpdateRecord(node).childrenModified();
this.updateModifiedNodesSoon();
}
private distributedNodesChanged(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void {
const node = event.data;
this.addUpdateRecord(node).childrenModified();
this.updateModifiedNodesSoon();
}
private adoptedStyleSheetsModified(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void {
const node = event.data;
this.addUpdateRecord(node).childrenModified();
this.updateModifiedNodesSoon();
}
private updateModifiedNodesSoon(): void {
if (!this.updateRecords.size) {
return;
}
if (this.updateModifiedNodesTimeout) {
return;
}
this.updateModifiedNodesTimeout = window.setTimeout(this.updateModifiedNodes.bind(this), 50);
}
/**
* TODO: this is made public for unit tests until the ElementsTreeOutline is
* migrated into DOMTreeWidget and highlights are declarative.
*/
updateModifiedNodes(): void {
if (this.updateModifiedNodesTimeout) {
clearTimeout(this.updateModifiedNodesTimeout);
delete this.updateModifiedNodesTimeout;
}
const updatedNodes = [...this.updateRecords.keys()];
const hidePanelWhileUpdating = updatedNodes.length > 10;
let treeOutlineContainerElement;
let originalScrollTop;
if (hidePanelWhileUpdating) {
treeOutlineContainerElement = (this.element.parentNode as Element | null);
originalScrollTop = treeOutlineContainerElement ? treeOutlineContainerElement.scrollTop : 0;
this.elementInternal.classList.add('hidden');
}
const rootNodeUpdateRecords = this.rootDOMNodeInternal && this.updateRecords.get(this.rootDOMNodeInternal);
if (rootNodeUpdateRecords?.hasChangedChildren()) {
// Document's children have changed, perform total update.
this.update();
} else {
for (const [node, record] of this.updateRecords) {
if (record.hasChangedChildren()) {
this.updateModifiedParentNode((node));
} else {
this.updateModifiedNode((node));
}
}
}
if (hidePanelWhileUpdating) {
this.elementInternal.classList.remove('hidden');
if (treeOutlineContainerElement && originalScrollTop) {
treeOutlineContainerElement.scrollTop = originalScrollTop;
}
}
this.updateRecords.clear();
this.fireElementsTreeUpdated(updatedNodes);
}
private updateModifiedNode(node: SDK.DOMModel.DOMNode): void {
const treeElement = this.findTreeElement(node);
if (treeElement) {
treeElement.updateTitle(this.updateRecordForHighlight(node));
}
}
private updateModifiedParentNode(node: SDK.DOMModel.DOMNode): void {
const parentTreeElement = this.findTreeElement(node);
if (parentTreeElement) {
parentTreeElement.setExpandable(this.hasVisibleChildren(node));
parentTreeElement.updateTitle(this.updateRecordForHighlight(node));
if (populatedTreeElements.has(parentTreeElement)) {
this.updateChildren(parentTreeElement);
}
}
}
populateTreeElement(treeElement: ElementsTreeElement): Promise<void> {
if (treeElement.childCount() || !treeElement.isExpandable()) {
return Promise.resolve();
}
return new Promise<void>(resolve => {
treeElement.node().getChildNodes(() => {
populatedTreeElements.add(treeElement);
this.updateModifiedParentNode(treeElement.node());
resolve();
});
});
}
createTopLayerContainer(parent: UI.TreeOutline.TreeElement, document: SDK.DOMModel.DOMDocument): void {
if (!parent.treeOutline || !(parent.treeOutline instanceof ElementsTreeOutline)) {
return;
}
const container = new TopLayerContainer(parent.treeOutline, document);
this.#topLayerContainerByDocument.set(document, container);
parent.appendChild(container);
}
revealInTopLayer(node: SDK.DOMModel.DOMNode): void {
const document = node.ownerDocument;
if (!document) {
return;
}
const container = this.#topLayerContainerByDocument.get(document);
if (container) {
container.revealInTopLayer(node);
}
}
private createElementTreeElement(node: SDK.DOMModel.DOMNode|SDK.DOMModel.AdoptedStyleSheet, isClosingTag?: boolean):
UI.TreeOutline.TreeElement {
if (node instanceof SDK.DOMModel.AdoptedStyleSheet) {
return new AdoptedStyleSheetTreeElement(node);
}
const treeElement = new ElementsTreeElement(node, isClosingTag);
treeElement.setExpandable(!isClosingTag && this.hasVisibleChildren(node));
if (node.nodeType() === Node.ELEMENT_NODE && node.parentNode && node.parentNode.nodeType() === Node.DOCUMENT_NODE &&
!node.parentNode.parentNode) {
treeElement.setCollapsible(false);
}
treeElement.selectable = Boolean(this.selectEnabled);
return treeElement;
}
private showChild(treeElement: ElementsTreeElement, child: SDK.DOMModel.DOMNode): ElementsTreeElement|null {
if (treeElement.isClosingTag()) {
return null;
}
const index = this.visibleChildren(treeElement.node()).indexOf(child);
if (index === -1) {
return null;
}
if (index >= treeElement.expandedChildrenLimit()) {
this.setExpandedChildrenLimit(treeElement, index + 1);
}
return treeElement.childAt(index) as ElementsTreeElement;
}
private visibleChildren(node: SDK.DOMModel.DOMNode): Array<SDK.DOMModel.DOMNode|SDK.DOMModel.AdoptedStyleSheet> {
let visibleChildren = [...node.adoptedStyleSheetsForNode, ...ElementsTreeElement.visibleShadowRoots(node)];
const contentDocument = node.contentDocument();
if (contentDocument) {
visibleChildren.push(contentDocument);
}
const templateContent = node.templateContent();
if (templateContent) {
visibleChildren.push(templateContent);
}
visibleChildren.push(...node.viewTransitionPseudoElements());
const markerPseudoElement = node.markerPseudoElement();
if (markerPseudoElement) {
visibleChildren.push(markerPseudoElement);
}
const checkmarkPseudoElement = node.checkmarkPseudoElement();
if (checkmarkPseudoElement) {
visibleChildren.push(checkmarkPseudoElement);
}
const beforePseudoElement = node.beforePseudoElement();
if (beforePseudoElement) {
visibleChildren.push(beforePseudoElement);
}
visibleChildren.push(...node.carouselPseudoElements());
if (node.childNodeCount()) {
// Children may be stale when the outline is not wired to receive DOMModel updates.
let children: SDK.DOMModel.DOMNode[] = node.children() || [];
if (!this.showHTMLCommentsSetting.get()) {
children = children.filter(n => n.nodeType() !== Node.COMMENT_NODE);
}
visibleChildren = visibleChildren.concat(children);
}
const afterPseudoElement = node.afterPseudoElement();
if (afterPseudoElement) {
visibleChildren.push(afterPseudoElement);
}
const pickerIconPseudoElement = node.pickerIconPseudoElement();
if (pickerIconPseudoElement) {
visibleChildren.push(pickerIconPseudoElement);
}
const backdropPseudoElement = node.backdropPseudoElement();
if (backdropPseudoElement) {
visibleChildren.push(backdropPseudoElement);
}
return visibleChildren;
}
private hasVisibleChildren(node: SDK.DOMModel.DOMNode): boolean {
if (node.isIframe()) {
return true;
}
if (node.contentDocument()) {
return true;
}
if (node.templateContent()) {
return true;
}
if (ElementsTreeElement.visibleShadowRoots(node).length) {
return true;
}
if (node.hasPseudoElements()) {
return true;
}
if (node.isInsertionPoint()) {
return true;
}
return Boolean(node.childNodeCount()) && !ElementsTreeElement.canShowInlineText(node);
}
private createExpandAllButtonTreeElement(treeElement: ElementsTreeElement): UI.TreeOutline.TreeElement {
const button = UI.UIUtils.createTextButton('', handleLoadAllChildren.bind(this));
button.value = '';
const expandAllButtonElement = new UI.TreeOutline.TreeElement(button);
expandAllButtonElement.selectable = false;
expandAllButtonElement.button = button;
return expandAllButtonElement;
function handleLoadAllChildren(this: ElementsTreeOutline, event: Event): void {
const visibleChildCount = this.visibleChildren(treeElement.node()).length;
this.setExpandedChildrenLimit(
treeElement, Math.max(visibleChildCount, treeElement.expandedChildrenLimit() + InitialChildrenLimit));
event.consume();
}
}
setExpandedChildrenLimit(treeElement: ElementsTreeElement, expandedChildrenLimit: number): void {
if (treeElement.expandedChildrenLimit() === expandedChildrenLimit) {
return;
}
treeElement.setExpandedChildrenLimit(expandedChildrenLimit);
if (treeElement.treeOutline && !this.treeElementsBeingUpdated.has(treeElement)) {
this.updateModifiedParentNode(treeElement.node());
}
}
private updateChildren(treeElement: ElementsTreeElement): void {
if (!treeElement.isExpandable()) {
if (!treeElement.treeOutline) {
return;
}
const selectedTreeElement = treeElement.treeOutline.selectedTreeElement;
if (selectedTreeElement?.hasAncestor(treeElement)) {
treeElement.select(true);
}
treeElement.removeChildren();
return;
}
console.assert(!treeElement.isClosingTag());
this.#updateChildren(treeElement);
}
insertChildElement(
treeElement: ElementsTreeElement|TopLayerContainer, child: SDK.DOMModel.DOMNode|SDK.DOMModel.AdoptedStyleSheet,
index: number, isClosingTag?: boolean): UI.TreeOutline.TreeElement {
const newElement = this.createElementTreeElement(child, isClosingTag);
treeElement.insertChild(newElement, index);
return newElement;
}
private moveChild(treeElement: ElementsTreeElement, child: ElementsTreeElement, targetIndex: number): void {
if (treeElement.indexOfChild(child) === targetIndex) {
return;
}
const wasSelected = child.selected;
if (child.parent) {
child.parent.removeChild(child);
}
treeElement.insertChild(child, targetIndex);
if (wasSelected) {
child.select();
}
}
#updateChildren(treeElement: ElementsTreeElement): void {
if (this.treeElementsBeingUpdated.has(treeElement)) {
return;
}
this.treeElementsBeingUpdated.add(treeElement);
const node = treeElement.node();
const visibleChildren = this.visibleChildren(node);
const visibleChildrenSet = new Set<SDK.DOMModel.DOMNode|SDK.DOMModel.AdoptedStyleSheet>(visibleChildren);
// Remove any tree elements that no longer have this node as their parent and save
// all existing elements that could be reused. This also removes closing tag element.
const existingTreeElements =
new Map<SDK.DOMModel.DOMNode|SDK.DOMModel.AdoptedStyleSheet, UI.TreeOutline.TreeElement&ElementsTreeElement>();
for (let i = treeElement.childCount() - 1; i >= 0; --i) {
const existingTreeElement = treeElement.childAt(i);
if (!(existingTreeElement instanceof ElementsTreeElement)) {
// Remove expand all button and shadow host toolbar.
treeElement.removeChildAtIndex(i);
continue;
}
const elementsTreeElement = (existingTreeElement);
const existingNode = elementsTreeElement.node();
if (visibleChildrenSet.has(existingNode)) {
existingTreeElements.set(existingNode, existingTreeElement);
continue;
}
treeElement.removeChildAtIndex(i);
}
// Insert child nodes.
for (let i = 0; i < visibleChildren.length && i < treeElement.expandedChildrenLimit(); ++i) {
const child = visibleChildren[i];
const existingTreeElement = existingTreeElements.get(child) || this.findTreeElement(child);
if (existingTreeElement && existingTreeElement !== treeElement) {
// If an existing element was found, just move it.
this.moveChild(treeElement, existingTreeElement, i);
} else {
// No existing element found, insert a new element.
const newElement = this.insertChildElement(treeElement, child, i);
if (this.updateRecordForHighlight(node) && treeElement.expanded && newElement instanceof ElementsTreeElement) {
ElementsTreeElement.animateOnDOMUpdate(newElement);
}
// If a node was inserted in the middle of existing list dynamically we might need to increase the limit.
if (treeElement.childCount() > treeElement.expandedChildrenLimit()) {
this.setExpandedChildrenLimit(treeElement, treeElement.expandedChildrenLimit() + 1);
}
}
}
// Update expand all button.
const expandedChildCount = treeElement.childCount();
if (visibleChildren.length > expandedChildCount) {
const targetButtonIndex = expandedChildCount;
if (!treeElement.expandAllButtonElement) {
treeElement.expandAllButtonElement = this.createExpandAllButtonTreeElement(treeElement);
}
treeElement.insertChild(treeElement.expandAllButtonElement, targetButtonIndex);
treeElement.expandAllButtonElement.title =
i18nString(UIStrings.showAllNodesDMore, {PH1: visibleChildren.length - expandedChildCount});
} else if (treeElement.expandAllButtonElement) {
treeElement.expandAllButtonElement = null;
}
// Insert shortcuts to distributed children.
if (node.isInsertionPoint()) {
for (const distributedNode of node.distributedNodes()) {
treeElement.appendChild(new ShortcutTreeElement(distributedNode));
}
}
// Insert close tag.
if (node.nodeType() === Node.ELEMENT_NODE && !node.pseudoType() && treeElement.isExpandable()) {
this.insertChildElement(treeElement, node, treeElement.childCount(), true);
}
if (node instanceof SDK.DOMModel.DOMDocument && !this.isXMLMimeType) {
let topLayerContainer = this.#topLayerContainerByDocument.get(node);
if (!topLayerContainer) {
topLayerContainer = new TopLayerContainer(this, node);
this.#topLayerContainerByDocument.set(node, topLayerContainer);
}
treeElement.appendChild(topLayerContainer);
}
this.treeElementsBeingUpdated.delete(treeElement);
}
private markersChanged(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void {
const node = event.data;
const treeElement = this.treeElementByNode.get(node);
if (treeElement) {
treeElement.updateDecorations();
}
}
private affectedByStartingStylesFlagUpdated(event: Common.EventTarget.EventTargetEvent<{node: SDK.DOMModel.DOMNode}>):
void {
const {node} = event.data;
const treeElement = this.treeElementByNode.get(node);
if (treeElement && isOpeningTag(treeElement.tagTypeContext)) {
void treeElement.updateAdorners();
}
}
}
export namespace ElementsTreeOutline {
export enum Events {
/* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
SelectedNodeChanged = 'SelectedNodeChanged',
ElementsTreeUpdated = 'ElementsTreeUpdated',
/* eslint-enable @typescript-eslint/naming-convention */
}
export interface EventTypes {
[Events.SelectedNodeChanged]: {node: SDK.DOMModel.DOMNode|null, focus: boolean};
[Events.ElementsTreeUpdated]: SDK.DOMModel.DOMNode[];
}
}
// clang-format off
export const MappedCharToEntity = new Map<string, string>([
['\xA0', 'nbsp'],
['\xAD', 'shy'],
['\u2002', 'ensp'],
['\u2003', 'emsp'],
['\u2009', 'thinsp'],
['\u200A', 'hairsp'],
['\u200B', 'ZeroWidthSpace'],
['\u200C', 'zwnj'],
['\u200D', 'zwj'],
['\u200E', 'lrm'],
['\u200F', 'rlm'],
['\u202A', '#x202A'],
['\u202B', '#x202B'],
['\u202C', '#x202C'],
['\u202D', '#x202D'],
['\u202E', '#x202E'],
['\u2060', 'NoBreak'],
['\uFEFF', '#xFEFF'],
]);
// clang-format on
export interface MultilineEditorController {
cancel: () => void;
commit: () => void;
resize: () => void;
}
export interface ClipboardData {
node: SDK.DOMModel.DOMNode;
isCut: boolean;
}