// 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 UIComponentUtils from '../../ui/legacy/components/utils/utils.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 {AdoptedStyleSheetSetTreeElement, 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 a button to show all truncated lines in the tree.
   * @example {5} PH1
   */
  showAllLines: 'Show all ({PH1} lines)',
  /**
   * @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 WeakSet<ElementsTreeElement>();

export type View = typeof DEFAULT_VIEW;
export {elementsTreeOutlineStyles};

interface ViewInput {
  omitRootDOMNode: boolean;
  selectEnabled: boolean;
  hideGutter: boolean;
  maxTreeDepth?: number;
  enableContextMenu?: boolean;
  showComments?: boolean;
  showAIButton?: boolean;
  disableEdits?: boolean;
  expandRoot?: boolean;
  visibleWidth?: number;
  visible?: boolean;
  maxRowsShown?: number;
  wrap: boolean;
  showSelectionOnKeyboardFocus: boolean;
  preventTabOrder: boolean;
  deindentSingleNode: boolean;
  currentHighlightedNode: SDK.DOMModel.DOMNode|null;

  selectedNode: 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, input.maxTreeDepth, input.enableContextMenu,
        input.showComments, input.showAIButton, input.disableEdits, input.expandRoot);
    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);
    output.elementsTreeOutline.addEventListener(ElementsTreeOutline.Events.ShowAllRows, () => {
      if (output.elementsTreeOutline?.maxRowsShown) {
        // Set max to undefined to show all rows
        output.elementsTreeOutline.maxRowsShown = undefined;
      }
    }, this);
    target.appendChild(output.elementsTreeOutline.element);
  }

  output.elementsTreeOutline.maxTreeDepth = input.maxTreeDepth;
  output.elementsTreeOutline.enableContextMenu = input.enableContextMenu ?? true;
  output.elementsTreeOutline.showComments = input.showComments ?? true;
  output.elementsTreeOutline.showAIButton = input.showAIButton ?? true;
  output.elementsTreeOutline.disableEdits = input.disableEdits ?? false;
  output.elementsTreeOutline.expandRoot = input.expandRoot ?? false;

  if (input.visibleWidth !== undefined) {
    output.elementsTreeOutline.setVisibleWidth(input.visibleWidth);
  }
  if (input.visible !== undefined) {
    output.elementsTreeOutline.setVisible(input.visible);
  }
  output.elementsTreeOutline.maxRowsShown = input.maxRowsShown;
  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);
    }
    if (input.selectedNode) {
      output.elementsTreeOutline.selectDOMNode(input.selectedNode);
    }

    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 = () => {};

  #maxTreeDepth?: number;
  #enableContextMenu = true;
  #showComments = true;
  #showAIButton = true;
  #disableEdits = false;
  #expandRoot = false;
  #visible = false;
  #visibleWidth?: number;
  #wrap = false;
  #maxRows?: number;

  // If maxRows is undefined, all rows are shown. If it is set to a number, only that many rows are shown.
  set maxRows(maxRows: number|undefined) {
    this.#maxRows = maxRows;
    this.requestUpdate();
  }

  get maxRows(): number|undefined {
    return this.#maxRows;
  }

  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;
  }

  get maxTreeDepth(): number|undefined {
    return this.#maxTreeDepth;
  }

  set maxTreeDepth(maxTreeDepth: number|undefined) {
    this.#maxTreeDepth = maxTreeDepth;
    this.performUpdate();
  }

  get enableContextMenu(): boolean {
    return this.#enableContextMenu;
  }

  set enableContextMenu(enableContextMenu: boolean) {
    this.#enableContextMenu = enableContextMenu;
    this.performUpdate();
  }

  get showComments(): boolean {
    return this.#showComments;
  }

  set showComments(showComments: boolean) {
    this.#showComments = showComments;
    this.performUpdate();
  }

  get showAIButton(): boolean {
    return this.#showAIButton;
  }

  set showAIButton(showAIButton: boolean) {
    this.#showAIButton = showAIButton;
    this.performUpdate();
  }

  get disableEdits(): boolean {
    return this.#disableEdits;
  }

  set disableEdits(disableEdits: boolean) {
    this.#disableEdits = disableEdits;
    this.performUpdate();
  }

  get expandRoot(): boolean {
    return this.#expandRoot;
  }

  set expandRoot(expandRoot: boolean) {
    this.#expandRoot = expandRoot;
    this.performUpdate();
  }

  #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|SDK.DOMModel.AdoptedStyleSheet|null, focus?: boolean): void {
    if (node instanceof SDK.DOMModel.AdoptedStyleSheet) {
      this.#viewOutput?.elementsTreeOutline?.highlightAdoptedStyleSheet(node);
    } else {
      this.#viewOutput?.elementsTreeOutline?.selectDOMNode(node, focus);
    }
  }

  highlightNodeAttribute(node: SDK.DOMModel.DOMNode, attribute: string): void {
    this.#viewOutput?.elementsTreeOutline?.highlightNodeAttribute(node, attribute);
  }

  get wrap(): boolean {
    return this.#wrap;
  }

  set wrap(wrap: boolean) {
    this.#wrap = wrap;
    this.performUpdate();
  }

  setWordWrap(wrap: boolean): void {
    this.wrap = wrap;
  }

  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 {
    const firstRender = !this.#viewOutput.elementsTreeOutline;
    this.#view(
        {
          omitRootDOMNode: this.omitRootDOMNode,
          selectEnabled: this.selectEnabled,
          hideGutter: this.hideGutter,
          maxTreeDepth: this.#maxTreeDepth,
          enableContextMenu: this.#enableContextMenu,
          showComments: this.#showComments,
          showAIButton: this.#showAIButton,
          disableEdits: this.#disableEdits,
          expandRoot: this.#expandRoot,
          visibleWidth: this.#visibleWidth,
          visible: this.#visible,
          wrap: this.#wrap,
          maxRowsShown: this.#maxRows,
          showSelectionOnKeyboardFocus: this.showSelectionOnKeyboardFocus,
          preventTabOrder: this.preventTabOrder,
          deindentSingleNode: this.deindentSingleNode,

          currentHighlightedNode: this.#currentHighlightedNode,
          selectedNode: this.selectedDOMNode(),
          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);
    if (firstRender && this.#viewOutput.elementsTreeOutline) {
      this.#viewOutput.elementsTreeOutline.addEventListener(ElementsTreeOutline.Events.ShowAllRows, () => {
        this.maxRows = undefined;
      });
    }
  }

  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[]>();
  maxTreeDepth?: number;
  enableContextMenu: boolean;
  showComments: boolean;
  showAIButton: boolean;
  disableEdits: boolean;
  expandRoot: boolean;
  #maxRowsShown?: number;
  #showAllButton?: HTMLElement;

  constructor(
      omitRootDOMNode?: boolean, selectEnabled?: boolean, hideGutter?: boolean, maxTreeDepth?: number,
      enableContextMenu?: boolean, showComments?: boolean, showAIButton?: boolean, disableEdits?: boolean,
      expandRoot?: 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');
    this.maxTreeDepth = maxTreeDepth;
    this.enableContextMenu = enableContextMenu ?? true;
    this.showComments = showComments ?? true;
    this.showAIButton = showAIButton ?? true;
    this.disableEdits = disableEdits ?? false;
    this.expandRoot = expandRoot ?? false;
    this.elementInternal.classList.toggle('elements-hide-gutter', hideGutter);
    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('keydown', this.onKeyDown.bind(this), false);

    if (!this.disableEdits) {
      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('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);
    }
    if (this.enableContextMenu) {
      this.elementInternal.addEventListener('contextmenu', this.contextMenuEventFired.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;
        },
        async link => {
          const listItem = UI.UIUtils.enclosingNodeOrSelfWithNodeName(link, 'li');
          if (!listItem) {
            return undefined;
          }

          const treeElement =
              (UI.TreeOutline.TreeElement.getTreeElementBylistItemNode(listItem) as ElementsTreeElement | undefined);
          return await UIComponentUtils.ImagePreview.loadPrecomputedFeatures(treeElement?.node());
        });

    this.updateRecords = new Map();
    this.treeElementsBeingUpdated = new Set();

    this.decoratorExtensions = null;
    if (this.showComments) {
      this.showHTMLCommentsSetting = Common.Settings.Settings.instance().moduleSetting('show-html-comments');
      this.showHTMLCommentsSetting.addChangeListener(this.onShowHTMLCommentsChange.bind(this));
    } else {
      this.showHTMLCommentsSetting = {
        get: () => false,
        addChangeListener: () => {},
        removeChangeListener: () => {},
      } as unknown as Common.Settings.Setting<boolean>;
    }
    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>
                  <devtools-link class="link" @click=${openIssueEvent}>${i18nString(UIStrings.viewIssue)}</devtools-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));
    }
  }

  set maxRowsShown(maxRows: number|undefined) {
    this.#maxRowsShown = maxRows;
    this.#updateShowAllButton();
  }

  #updateShowAllButton(): void {
    const container = this.shadowRoot.querySelector('.elements-disclosure') as HTMLElement;
    if (!container) {
      return;
    }
    if (!this.#maxRowsShown) {
      this.#showAllButton?.classList.add('hidden');
      container.style.removeProperty('--max-rows');
      container.classList.remove('elements-tree-truncated');
      return;
    }

    container.style.setProperty('--max-rows', String(this.#maxRowsShown));
    container.classList.add('elements-tree-truncated');

    // We use a microtask to wait for rendering so all node lines are rendered.
    window.requestAnimationFrame(() => {
      // The container has a max-height (based on --max-rows). If the total content height
      // (scrollHeight) is greater than the visible height (clientHeight), it means
      // some rows are hidden due to truncation, and we should show the "Show all" button.
      const isOverflowing = container.scrollHeight > container.clientHeight;
      if (!isOverflowing) {
        return;
      }
      if (!this.#showAllButton) {
        this.#showAllButton = UI.UIUtils.createTextButton('', () => {
          this.dispatchEventToListeners(ElementsTreeOutline.Events.ShowAllRows);
          this.dispatchEventToListeners(UI.TreeOutline.Events.ElementExpanded, this.rootElement());
        }, {
          jslogContext: 'show-all-nodes',
        });
        this.#showAllButton.classList.add('elements-tree-show-all');
        this.shadowRoot.appendChild(this.#showAllButton);
      }
      this.#showAllButton.classList.remove('hidden');
      const computedStyle = window.getComputedStyle(container);
      const lineHeight = parseFloat(computedStyle.lineHeight) || 16;
      const truncatedLines = Math.round((container.scrollHeight - container.clientHeight) / lineHeight);

      if (truncatedLines > 0) {
        this.#showAllButton.textContent = i18nString(UIStrings.showAllLines, {PH1: truncatedLines});
      } else {
        this.#showAllButton?.classList.add('hidden');
      }
    });
  }

  highlightAdoptedStyleSheet(adoptedStyleSheet: SDK.DOMModel.AdoptedStyleSheet): void {
    const parentDOMNode =
        !this.includeRootDOMNode && adoptedStyleSheet.parent === this.rootDOMNode && this.rootDOMNode ?
        this.rootElement() :
        this.createTreeElementFor(adoptedStyleSheet.parent);
    if (!parentDOMNode) {
      return;
    }
    const parentNode = parentDOMNode.firstChild();
    if (!(parentNode && parentNode instanceof AdoptedStyleSheetSetTreeElement)) {
      return;
    }

    for (const child of parentNode.children()) {
      if (child instanceof AdoptedStyleSheetTreeElement && child.adoptedStyleSheet === adoptedStyleSheet) {
        parentNode.expand();
        child.highlight();
        return;
      }
    }
  }

  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);
      if (this.expandRoot) {
        treeElement.expand();
      }
    } 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 Array) {
      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() || !this.enableContextMenu) {
      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);
    } else if (treeElement.node().nodeType() === Node.PROCESSING_INSTRUCTION_NODE) {
      await treeElement.populateProcessingElementContextMenu(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.DocumentURLChanged, this.documentURLChanged, 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.DocumentURLChanged, this.documentURLChanged, 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 documentURLChanged(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMDocument>): void {
    this.addUpdateRecord(event.data).charDataModified();
    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 isMaxDepthReached(node: SDK.DOMModel.DOMNode): boolean {
    if (this.maxTreeDepth === undefined || this.maxTreeDepth === Infinity) {
      return false;
    }
    // Allow ShadowRoots and Documents to expand one more level.
    if (node.nodeType() === Node.DOCUMENT_NODE || node.isShadowRoot()) {
      return false;
    }
    const maxDepth = this.maxTreeDepth;
    let depth = 0;
    let current: SDK.DOMModel.DOMNode|null = node;
    const rootNode = this.rootDOMNode;
    while (current && current !== rootNode) {
      depth++;
      current = current.parentNode;
    }
    if (this.includeRootDOMNode) {
      depth++;
    }
    if (depth >= maxDepth) {
      return true;
    }
    return false;
  }

  private createElementTreeElement(node: SDK.DOMModel.DOMNode|SDK.DOMModel.AdoptedStyleSheet[], isClosingTag?: boolean):
      UI.TreeOutline.TreeElement {
    if (node instanceof Array) {
      return new AdoptedStyleSheetSetTreeElement(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[]> {
    const visibleChildren: Array<SDK.DOMModel.DOMNode|SDK.DOMModel.AdoptedStyleSheet[]> = [];
    if (node.adoptedStyleSheetsForNode.length) {
      visibleChildren.push(node.adoptedStyleSheetsForNode);
    }
    visibleChildren.push(...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.push(...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 (this.isMaxDepthReached(node)) {
      return false;
    }
    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',
    ShowAllRows = 'ShowAllRows',
    /* eslint-enable @typescript-eslint/naming-convention */
  }

  export interface EventTypes {
    [Events.SelectedNodeChanged]: {node: SDK.DOMModel.DOMNode|null, focus: boolean};
    [Events.ElementsTreeUpdated]: SDK.DOMModel.DOMNode[];
    [Events.ShowAllRows]: void;
  }
}

// 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;
}
