blob: d059d54355f50a55d397cf000cb95a800faf2156 [file]
// 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.
/*
* Copyright (C) 2007, 2008 Apple Inc. All rights reserved.
* Copyright (C) 2008 Matt Lilek <webkit@mattlilek.com>
* Copyright (C) 2009 Joseph Pecoraro
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of
* its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Badges from '../../models/badges/badges.js';
import * as ChangeTracker from '../../models/change_tracker/change_tracker.js';
import * as Elements from '../../models/elements/elements.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as CodeHighlighter from '../../ui/components/code_highlighter/code_highlighter.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 * as Lit from '../../ui/lit/lit.js';
import * as SettingsUI from '../../ui/settings/settings.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import {AdoptedStyleSheetContentsWidget} from './AdoptedStyleSheetTreeElement.js';
import * as ElementsComponents from './components/components.js';
import {cssPath, jsPath, xPath} from './DOMPath.js';
import {showContextMenu} from './DOMTreeContextMenu.js';
import {
adornerRef,
buildChangeSelector,
DOMTreeElementWidget,
ForbiddenClosingTagElements,
handleAdornerKeydown,
InitialChildrenLimit,
type InitialEditState,
} from './DOMTreeElementWidget.js';
import domTreeWidgetStyles from './domTreeWidget.css.js';
import {getElementIssueDetails} from './ElementIssueUtils.js';
import {ImagePreviewPopover} from './ImagePreviewPopover.js';
const {html, nothing, render, Directives: {classMap, ifDefined, repeat, styleMap}} = Lit;
const {clonedRef} = UI.UIUtils;
/**
* Scrolls the rendered row of the highlighted node into view. The function has a
* stable identity, so that `clonedRef` calls it once per row that it is attached to.
*/
const scrollHighlightedRowIntoView = (element: Element|undefined): void => {
element?.scrollIntoViewIfNeeded(/* center= */ true);
};
const UIStrings = {
/**
* @description ARIA accessible name in the DOM tree outline of the Elements panel.
*/
pageDom: 'Page DOM',
/**
* @description Text for the button to expand all tree nodes in the DOM 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 the Issues panel.
*/
viewIssue: 'View issue:',
/**
* @description Link text content in the DOM tree outline of the Elements panel.
*/
reveal: 'reveal',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/elements/DOMTreeWidget.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export type View = (input: ViewInput, output: undefined, target: HTMLElement) => void;
export {domTreeWidgetStyles};
interface ViewInput {
rootDOMNode: SDK.DOMModel.DOMNode|null;
omitRootDOMNode: boolean;
selectEnabled: boolean;
hideGutter: boolean;
maxTreeDepth?: number;
showComments: boolean;
showAIButton: boolean;
disableEdits: boolean;
maxRowsShown?: number;
wrap: boolean;
showSelectionOnKeyboardFocus: boolean;
deindentSingleNode: boolean;
currentHighlightedNode: SDK.DOMModel.DOMNode|null;
hoveredNode: SDK.DOMModel.DOMNode|null;
hoveredClosingTag: boolean;
searchMatchNode: SDK.DOMModel.DOMNode|null;
searchMatchQuery: string|null;
selectedNode: SDK.DOMModel.DOMNode|null;
selectedClosingTag: boolean;
onSelect: (node: SDK.DOMModel.DOMNode, isClosingTag: boolean, selectedByUser: boolean) => void;
onExpand: (node: SDK.DOMModel.DOMNode, expanded: boolean, recursively?: boolean) => void;
onContextMenu: (node: SDK.DOMModel.DOMNode, event: MouseEvent) => void;
onClearMaxRows: () => void;
onHoverNode: (node: SDK.DOMModel.DOMNode|null, showInfo: boolean, isClosingTag: boolean) => void;
onLeave: () => void;
onToggleHideElement: (node: SDK.DOMModel.DOMNode) => Promise<void>;
onKeyDown: (event: KeyboardEvent) => void;
isToggledToHidden: (node: SDK.DOMModel.DOMNode) => boolean;
isNodeExpanded: (node: SDK.DOMModel.DOMNode) => boolean;
isNodeInClipboard: (node: SDK.DOMModel.DOMNode) => boolean;
onCopyOrCut: (isCut: boolean, event: Event) => void;
onPaste: (event: Event) => void;
onSelectNodeAfterEdit: (wasExpanded: boolean, error: string|null, newNode: SDK.DOMModel.DOMNode|null,
moveDirection?: string) => void;
nodeToEdit: ({node: SDK.DOMModel.DOMNode}&InitialEditState)|null;
onInitialEditCompleted: () => void;
attributeToHighlight: {node: SDK.DOMModel.DOMNode, attribute: string}|null;
onAttributeHighlighted: () => void;
nodesWithDirtyAdorners: Set<SDK.DOMModel.DOMNode>;
nodeAdornerVersions: WeakMap<SDK.DOMModel.DOMNode, number>;
onDirtyAdornersUpdated: () => void;
forceOpenPopovers: WeakSet<SDK.DOMModel.DOMNode>;
onPopoverAdornerToggled: (node: SDK.DOMModel.DOMNode, active: boolean) => void;
forceOpenInterests: WeakSet<SDK.DOMModel.DOMNode>;
onInterestAdornerToggled: (node: SDK.DOMModel.DOMNode, active: boolean) => void;
multilineEditingNode: SDK.DOMModel.DOMNode|null;
onSetMultilineEditing: (editing: MultilineEditorController|null, node: SDK.DOMModel.DOMNode) => void;
onRevealInTopLayer: (node: SDK.DOMModel.DOMNode) => void;
visibleWidth: () => number;
dragOverNode: {node: SDK.DOMModel.DOMNode, isClosingTag: boolean}|null;
isValidDragSource: (node: SDK.DOMModel.DOMNode) => boolean;
onDragStart: (node: SDK.DOMModel.DOMNode, event: DragEvent, textContent?: string) => boolean;
onDragOver: (node: SDK.DOMModel.DOMNode, isClosingTag: boolean, event: DragEvent) => boolean;
onDragLeave: (event: DragEvent) => void;
onDrop: (node: SDK.DOMModel.DOMNode, isClosingTag: boolean, event: DragEvent) => void;
onDragEnd: (event: DragEvent) => void;
getTopLayerShortcuts: (doc: SDK.DOMModel.DOMDocument) => SDK.DOMModel.DOMNodeShortcut[];
isTopLayerExpanded: (doc: SDK.DOMModel.DOMDocument) => boolean;
onToggleTopLayerExpanded: (doc: SDK.DOMModel.DOMDocument, expanded: boolean) => void;
onSelectTopLayerContainer: (doc: SDK.DOMModel.DOMDocument) => void;
isTopLayerShortcutExpanded: (shortcut: SDK.DOMModel.DOMNodeShortcut) => boolean;
onToggleTopLayerShortcutExpanded: (shortcut: SDK.DOMModel.DOMNodeShortcut, expanded: boolean) => void;
selectedTopLayerShortcut: SDK.DOMModel.DOMNodeShortcut|null;
onSelectTopLayerShortcut: (shortcut: SDK.DOMModel.DOMNodeShortcut) => void;
onRevealTopLayerShortcut: (shortcut: SDK.DOMModel.DOMNodeShortcut) => void;
isAdoptedStyleSheetsExpanded: (node: SDK.DOMModel.DOMNode) => boolean;
onToggleAdoptedStyleSheetsExpanded: (node: SDK.DOMModel.DOMNode, expanded: boolean) => void;
onSelectAdoptedStyleSheets: (node: SDK.DOMModel.DOMNode) => void;
isAdoptedStyleSheetExpanded: (sheet: SDK.DOMModel.AdoptedStyleSheet) => boolean;
onToggleAdoptedStyleSheetExpanded: (sheet: SDK.DOMModel.AdoptedStyleSheet, expanded: boolean) => void;
selectedAdoptedStyleSheet: SDK.DOMModel.AdoptedStyleSheet|null;
onSelectAdoptedStyleSheet: (sheet: SDK.DOMModel.AdoptedStyleSheet) => void;
expandedChildrenLimit: (node: SDK.DOMModel.DOMNode) => number;
onExpandAllChildren: (node: SDK.DOMModel.DOMNode) => void;
updateRecordForNode: (node: SDK.DOMModel.DOMNode) => Elements.ElementUpdateRecord.ElementUpdateRecord | null;
}
function isMaxDepthReached(node: SDK.DOMModel.DOMNode, rootDOMNode: SDK.DOMModel.DOMNode|null, maxTreeDepth?: number,
omitRootDOMNode?: boolean): boolean {
if (maxTreeDepth === undefined || maxTreeDepth === Infinity) {
return false;
}
if (node.nodeType() === Node.DOCUMENT_NODE || node.isShadowRoot()) {
return false;
}
let depth = 0;
let current: SDK.DOMModel.DOMNode|null = node;
while (current && current !== rootDOMNode) {
depth++;
current = current.parentNode;
}
if (!omitRootDOMNode) {
depth++;
}
return depth >= maxTreeDepth;
}
/** The settings that determine which nodes the tree shows. Both the view input and the presenter satisfy it. */
type TreeOptions = Pick<ViewInput, 'rootDOMNode'|'maxTreeDepth'|'omitRootDOMNode'|'showComments'>;
function nodeHasVisibleChildren(node: SDK.DOMModel.DOMNode, options: TreeOptions): boolean {
if (isMaxDepthReached(node, options.rootDOMNode, options.maxTreeDepth, options.omitRootDOMNode)) {
return false;
}
if (node.isIframe() || node.contentDocument() || node.templateContent() ||
DOMTreeElementWidget.visibleShadowRoots(node).length || node.hasPseudoElements() || node.isInsertionPoint() ||
node.adoptedStyleSheetsForNode.length) {
return true;
}
return Boolean(node.childNodeCount()) && !DOMTreeElementWidget.canShowInlineText(node);
}
function nodeNeedsClosingTag(node: SDK.DOMModel.DOMNode, hasChildren: boolean): boolean {
return hasChildren && node.nodeType() === Node.ELEMENT_NODE &&
!ForbiddenClosingTagElements.has(node.nodeName().toLowerCase()) && !node.pseudoType();
}
/** Children of the document element (e.g. `<html>`) are always shown expanded. */
function isNotCollapsible(node: SDK.DOMModel.DOMNode): boolean {
return node.nodeType() === Node.ELEMENT_NODE && node.parentNode?.nodeType() === Node.DOCUMENT_NODE &&
!node.parentNode.parentNode;
}
function isBeingEditedAsHTML(state: Pick<ViewInput, 'multilineEditingNode'|'nodeToEdit'>,
node: SDK.DOMModel.DOMNode): boolean {
return state.multilineEditingNode === node ||
(state.nodeToEdit?.node === node && Boolean(state.nodeToEdit.isEditAsHTML));
}
function getVisibleChildren(node: SDK.DOMModel.DOMNode, {showComments}: TreeOptions): SDK.DOMModel.DOMNode[] {
const children: SDK.DOMModel.DOMNode[] = [];
const contentDocument = node.contentDocument();
if (contentDocument) {
children.push(contentDocument);
}
const templateContent = node.templateContent();
if (templateContent) {
children.push(templateContent);
}
children.push(...node.viewTransitionPseudoElements());
const markerPseudo = node.markerPseudoElement();
if (markerPseudo) {
children.push(markerPseudo);
}
const checkmarkPseudo = node.checkmarkPseudoElement();
if (checkmarkPseudo) {
children.push(checkmarkPseudo);
}
const beforePseudo = node.beforePseudoElement();
if (beforePseudo) {
children.push(beforePseudo);
}
children.push(...DOMTreeElementWidget.visibleShadowRoots(node));
children.push(...node.carouselPseudoElements());
if (node.childNodeCount()) {
const nodeChildren = node.children();
if (nodeChildren) {
let filteredChildren = nodeChildren;
if (!showComments) {
filteredChildren = filteredChildren.filter(n => n.nodeType() !== Node.COMMENT_NODE);
}
children.push(...filteredChildren);
}
}
const afterPseudo = node.afterPseudoElement();
if (afterPseudo) {
children.push(afterPseudo);
}
const pickerIconPseudo = node.pickerIconPseudoElement();
if (pickerIconPseudo) {
children.push(pickerIconPseudo);
}
const interestButtonPseudo = node.interestButtonPseudoElement();
if (interestButtonPseudo) {
children.push(interestButtonPseudo);
}
const backdropPseudo = node.backdropPseudoElement();
if (backdropPseudo) {
children.push(backdropPseudo);
}
return children;
}
function isAncestorOf(ancestor: SDK.DOMModel.DOMNode, descendant: SDK.DOMModel.DOMNode): boolean {
let current: SDK.DOMModel.DOMNode|null = descendant.parentNode;
while (current) {
if (current === ancestor) {
return true;
}
current = current.parentNode;
}
return false;
}
/**
* Returns the closest sibling of `node` that is part of `visibleChildren`,
* preferring following siblings over preceding ones.
*/
function findVisibleSibling(node: SDK.DOMModel.DOMNode, visibleChildren: SDK.DOMModel.DOMNode[]): SDK.DOMModel.DOMNode|
null {
for (let candidate = node.nextSibling; candidate; candidate = candidate.nextSibling) {
if (visibleChildren.includes(candidate)) {
return candidate;
}
}
for (let candidate = node.previousSibling; candidate; candidate = candidate.previousSibling) {
if (visibleChildren.includes(candidate)) {
return candidate;
}
}
return null;
}
/** How a node is laid out in the tree: whether it has children, is expanded, and which children are shown. */
interface NodeShape {
isEditingAsHTML: boolean;
hasChildren: boolean;
isCollapsible: boolean;
isExpanded: boolean;
/** The children that are rendered, i.e. the visible ones up to the node's children limit. */
children: SDK.DOMModel.DOMNode[];
remainingChildrenCount: number;
needsClosingTag: boolean;
}
function computeLeftIndent(depth: number, isExpandable: boolean): number {
return 12 * (depth - 1) + (isExpandable ? 1 : 12);
}
export const DEFAULT_VIEW: View = (input: ViewInput, _output: undefined, target: HTMLElement): void => {
let allRootNodes: SDK.DOMModel.DOMNode[] = [];
let rootNodes: SDK.DOMModel.DOMNode[] = [];
const rootDOMNode = input.rootDOMNode;
if (rootDOMNode) {
if (input.omitRootDOMNode) {
allRootNodes = getVisibleChildren(rootDOMNode, input);
const rootLimit = input.expandedChildrenLimit(rootDOMNode);
rootNodes = allRootNodes.slice(0, rootLimit);
} else {
rootNodes = [rootDOMNode];
}
}
const on = Lit.Directive.directive(Lit.CustomDirectives.InterceptBindingDirective);
const treeItemJslog = (context?: string, drag?: boolean): ReturnType<typeof VisualLogging.treeItem> =>
VisualLogging.treeItem(context).parent('elementsTreeOutline').track({
keydown: 'ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Backspace|Delete|Enter|Space|Home|End',
resize: true,
...(drag ? {drag: true} : {}),
click: true,
});
const renderShortcut = (shortcut: SDK.DOMModel.DOMNodeShortcut, shortcutDepth: number): Lit.LitTemplate => {
const hasShortcutChildren = shortcut.childShortcuts.length > 0;
const isShortcutExpanded = input.isTopLayerShortcutExpanded(shortcut);
const isShortcutSelected = input.selectedTopLayerShortcut === shortcut;
let title = shortcut.nodeName.toLowerCase();
if (shortcut.nodeType === Node.ELEMENT_NODE) {
title = '<' + title + '>';
}
const onShortcutExpand = (event: UI.TreeOutline.TreeViewElement.ExpandEvent): void => {
input.onToggleTopLayerShortcutExpanded(shortcut, event.detail.expanded);
};
const onShortcutSelect = (event: UI.TreeOutline.TreeViewElement.SelectEvent): void => {
if (event.detail?.selectedByUser) {
input.onSelectTopLayerShortcut(shortcut);
}
};
const onReveal = (event: Event): void => {
event.stopPropagation();
input.onRevealTopLayerShortcut(shortcut);
};
const onShortcutMouseMove = (event: MouseEvent): void => {
event.stopPropagation();
shortcut.deferredNode.highlight();
};
const onShortcutMouseLeave = (event: MouseEvent): void => {
event.stopPropagation();
SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(SDK.TargetManager.TargetManager.instance());
};
// clang-format off
return html`
<li role="treeitem"
?selected=${isShortcutSelected}
?open=${isShortcutExpanded}
class="elements-tree-shortcut"
style=${styleMap({'--indent': `${computeLeftIndent(shortcutDepth, hasShortcutChildren)}px`})}
@select=${on(onShortcutSelect)}
@expand=${on(onShortcutExpand)}
@mousemove=${on(onShortcutMouseMove)}
@mouseleave=${on(onShortcutMouseLeave)}
jslog=${treeItemJslog()}>
<span class="elements-tree-shortcut-title">\u21AA ${title}</span>
<devtools-adorner
.name=${ElementsComponents.AdornerManager.RegisteredAdorners.REVEAL}
class="adorner-reveal clickable"
role=button
tabindex=0
jslog=${VisualLogging.adorner('reveal').track({click: true})}
aria-label=${i18nString(UIStrings.reveal)}
@click=${on(onReveal)}
@keydown=${handleAdornerKeydown(onReveal)}
@mousedown=${(e: Event) => e.consume()}
${adornerRef()}>
<span class="adorner-with-icon">
<devtools-icon name="select-element"></devtools-icon>
<span>${ElementsComponents.AdornerManager.RegisteredAdorners.REVEAL}</span>
</span>
</devtools-adorner>
${hasShortcutChildren ? html`
<ul role="group">
${shortcut.childShortcuts.map(child => renderShortcut(child, shortcutDepth + 1))}
</ul>
` : nothing}
</li>
`;
// clang-format on
};
const renderTopLayerContainer =
(doc: SDK.DOMModel.DOMDocument, containerDepth: number): Lit.LitTemplate|typeof nothing => {
const shortcuts = input.getTopLayerShortcuts(doc);
if (shortcuts.length === 0) {
return nothing;
}
const isTopLayerExpanded = input.isTopLayerExpanded(doc);
const onTopLayerExpand = (event: UI.TreeOutline.TreeViewElement.ExpandEvent): void => {
input.onToggleTopLayerExpanded(doc, event.detail.expanded);
};
const onTopLayerSelect = (event: UI.TreeOutline.TreeViewElement.SelectEvent): void => {
if (event.detail?.selectedByUser) {
input.onSelectTopLayerContainer(doc);
}
};
// clang-format off
return html`
<li role="treeitem"
?open=${isTopLayerExpanded}
class="elements-tree-top-layer-container"
style=${styleMap({'--indent': `${computeLeftIndent(containerDepth, true)}px`})}
@select=${on(onTopLayerSelect)}
@expand=${on(onTopLayerExpand)}
jslog=${treeItemJslog()}>
<span class="elements-tree-shortcut-title">#top-layer</span>
<ul role="group">
${shortcuts.map(sc => renderShortcut(sc, containerDepth + 1))}
</ul>
</li>
`;
// clang-format on
};
const renderAdoptedStyleSheet = (sheet: SDK.DOMModel.AdoptedStyleSheet, depth: number): Lit.LitTemplate => {
const header = sheet.cssModel.styleSheetHeaderForId(sheet.id);
const linkText = header?.sourceURL;
const isExpanded = input.isAdoptedStyleSheetExpanded(sheet);
const isSelected = input.selectedAdoptedStyleSheet === sheet;
const onExpand = (event: UI.TreeOutline.TreeViewElement.ExpandEvent): void => {
input.onToggleAdoptedStyleSheetExpanded(sheet, event.detail.expanded);
};
const onSelect = (event: UI.TreeOutline.TreeViewElement.SelectEvent): void => {
if (event.detail?.selectedByUser) {
input.onSelectAdoptedStyleSheet(sheet);
}
};
// clang-format off
return html`
<li role="treeitem"
?selected=${isSelected}
?open=${isExpanded}
class="elements-tree-adopted-style-sheet"
style=${styleMap({'--indent': `${computeLeftIndent(depth, true)}px`})}
@select=${on(onSelect)}
@expand=${on(onExpand)}
jslog=${treeItemJslog('adopted-style-sheet')}>
<span class="elements-tree-shortcut-title">#adopted-style-sheet${linkText ? html` (${UIComponentUtils.Linkifier.Linkifier.linkifyURL(linkText, {
text: linkText,
preventClick: true,
showColumnNumber: false,
})})` : nothing}</span>
<ul role="group">
${UI.TreeOutline.ifExpanded(html`
${header ? html`
<li role="treeitem"
class="elements-tree-adopted-style-sheet-contents"
style=${styleMap({'--indent': `${computeLeftIndent(depth + 1, false)}px`})}
jslog=${treeItemJslog('adopted-style-sheet-contents')}>
${UI.Widget.widget(AdoptedStyleSheetContentsWidget, {styleSheetHeader: header})}
</li>
` : nothing}
`)}
</ul>
</li>
`;
// clang-format on
};
const renderAdoptedStyleSheets = (node: SDK.DOMModel.DOMNode, depth: number): Lit.LitTemplate|typeof nothing => {
const sheets = node.adoptedStyleSheetsForNode;
if (!sheets || sheets.length === 0) {
return nothing;
}
const isExpanded = input.isAdoptedStyleSheetsExpanded(node);
const onExpand = (event: UI.TreeOutline.TreeViewElement.ExpandEvent): void => {
input.onToggleAdoptedStyleSheetsExpanded(node, event.detail.expanded);
};
const onSelect = (event: UI.TreeOutline.TreeViewElement.SelectEvent): void => {
if (event.detail?.selectedByUser) {
input.onSelectAdoptedStyleSheets(node);
}
};
// clang-format off
return html`
<li role="treeitem"
?open=${isExpanded}
class="elements-tree-adopted-style-sheets"
style=${styleMap({'--indent': `${computeLeftIndent(depth, true)}px`})}
@select=${on(onSelect)}
@expand=${on(onExpand)}
jslog=${treeItemJslog('adopted-style-sheets')}>
<span class="elements-tree-shortcut-title">#adopted-style-sheets</span>
<ul role="group">
${UI.TreeOutline.ifExpanded(html`
${sheets.map(sheet => renderAdoptedStyleSheet(sheet, depth + 1))}
`)}
</ul>
</li>
`;
// clang-format on
};
const resolveNodeShape = (node: SDK.DOMModel.DOMNode): NodeShape => {
const editingAsHTML = isBeingEditedAsHTML(input, node);
const hasChildren = !editingAsHTML && nodeHasVisibleChildren(node, input);
const isCollapsible = !editingAsHTML && !isNotCollapsible(node);
const isExpanded = !isCollapsible ||
Boolean(input.currentHighlightedNode && isAncestorOf(node, input.currentHighlightedNode)) ||
input.isNodeExpanded(node);
const allChildren = hasChildren && isExpanded ? getVisibleChildren(node, input) : [];
const children = allChildren.slice(0, input.expandedChildrenLimit(node));
return {
isEditingAsHTML: editingAsHTML,
hasChildren,
isCollapsible,
isExpanded,
children,
remainingChildrenCount: allChildren.length - children.length,
needsClosingTag: nodeNeedsClosingTag(node, hasChildren),
};
};
const countTopLayerRows = (doc: SDK.DOMModel.DOMDocument): number => {
const shortcuts = input.getTopLayerShortcuts(doc);
if (shortcuts.length === 0) {
return 0;
}
const countShortcutRows = (shortcut: SDK.DOMModel.DOMNodeShortcut): number => {
let shortcutRows = 1;
if (shortcut.childShortcuts.length > 0 && input.isTopLayerShortcutExpanded(shortcut)) {
for (const child of shortcut.childShortcuts) {
shortcutRows += countShortcutRows(child);
}
}
return shortcutRows;
};
let rows = 1;
if (input.isTopLayerExpanded(doc)) {
for (const shortcut of shortcuts) {
rows += countShortcutRows(shortcut);
}
}
return rows;
};
const countAdoptedStyleSheetsRows = (node: SDK.DOMModel.DOMNode): number => {
const sheets = node.adoptedStyleSheetsForNode;
if (!sheets || sheets.length === 0) {
return 0;
}
let rows = 1; // The "#adopted-style-sheets" container
if (input.isAdoptedStyleSheetsExpanded(node)) {
for (const sheet of sheets) {
rows += 1; // The sheet row
if (input.isAdoptedStyleSheetExpanded(sheet) && sheet.cssModel.styleSheetHeaderForId(sheet.id)) {
rows += 1; // The AdoptedStyleSheetContentsWidget row
}
}
}
return rows;
};
const countVisibleRowsForNode = (node: SDK.DOMModel.DOMNode): number => {
let rows = 1;
const shape = resolveNodeShape(node);
if (shape.hasChildren && shape.isExpanded) {
rows += countAdoptedStyleSheetsRows(node);
for (const child of shape.children) {
rows += countVisibleRowsForNode(child);
}
if (shape.remainingChildrenCount > 0) {
rows += 1;
}
if (node instanceof SDK.DOMModel.DOMDocument) {
rows += countTopLayerRows(node);
}
if (shape.needsClosingTag) {
rows += 1;
}
}
return rows;
};
const countAllVisibleRows = (): number => {
let totalRows = 0;
if (input.omitRootDOMNode && input.rootDOMNode) {
totalRows += countAdoptedStyleSheetsRows(input.rootDOMNode);
}
for (const node of rootNodes) {
totalRows += countVisibleRowsForNode(node);
}
if (input.omitRootDOMNode && input.rootDOMNode) {
const remaining = allRootNodes.length - rootNodes.length;
if (remaining > 0) {
totalRows += 1;
}
if (input.rootDOMNode instanceof SDK.DOMModel.DOMDocument) {
totalRows += countTopLayerRows(input.rootDOMNode);
}
}
return totalRows;
};
const renderNode = (node: SDK.DOMModel.DOMNode, depth = 0): Lit.LitTemplate => {
const isDOMNodeSelected = input.selectedNode === node;
const isSelected = Boolean(input.selectEnabled) && isDOMNodeSelected;
const isOpeningHovered =
(input.currentHighlightedNode === node) || (input.hoveredNode === node && !input.hoveredClosingTag);
const isClosingHovered = input.hoveredNode === node && input.hoveredClosingTag;
const {isEditingAsHTML, hasChildren, isCollapsible, isExpanded, children, remainingChildrenCount, needsClosingTag} =
resolveNodeShape(node);
const isExpandable = hasChildren && isCollapsible;
const onSelect = (isClosingTag = false, selectedByUser = false): void => {
input.onSelect(node, isClosingTag, selectedByUser);
};
const onExpand = (event: UI.TreeOutline.TreeViewElement.ExpandEvent): void => {
if (!isCollapsible) {
return;
}
input.onExpand(node, event.detail.expanded, event.detail.recursively);
};
const isDragOver = input.dragOverNode?.node === node && !input.dragOverNode.isClosingTag;
const isClosingTagDragOver = input.dragOverNode?.node === node && Boolean(input.dragOverNode.isClosingTag);
const isDraggable = !input.disableEdits && input.isValidDragSource(node);
const classes = classMap({
selected: isSelected && !input.selectedClosingTag,
hovered: isOpeningHovered,
'in-clipboard': input.isNodeInClipboard(node),
'elements-drag-over': isDragOver,
'always-parent': !isCollapsible && !isEditingAsHTML,
// Draws the vertical guide line next to the shadow root children.
'shadow-root': node.isShadowRoot(),
});
// The opening and the closing tag are separate rows that report the
// same node with a different `isClosingTag`.
const onMouseMove = (isClosingTag: boolean) => (event: MouseEvent): void => {
const currentTarget = event.currentTarget as Element | null;
if (currentTarget && (event.target as Element).closest('li[role="treeitem"]') !== currentTarget) {
return;
}
const showInfo = !UI.KeyboardShortcut.KeyboardShortcut.eventHasEitherCtrlOrMeta(event);
input.onHoverNode(node, showInfo, isClosingTag);
};
const onContextMenu = (event: MouseEvent): void => {
event.stopPropagation();
input.onContextMenu(node, event);
};
// Prevent the native word selection on double click. The double click
// starts inline editing instead. Inside an active inline editor the
// native selection must stay enabled.
const onMouseDown = (event: MouseEvent): void => {
if (event.detail < 2 || UI.UIUtils.isBeingEdited(event.target as Node)) {
return;
}
event.preventDefault();
};
const onDragStart = (event: DragEvent): void => {
event.stopPropagation();
const currentTarget = event.currentTarget as HTMLElement | null;
// Only use the row's own content, not the nested child rows.
const textContent = currentTarget?.querySelector(':scope > devtools-widget')?.textContent ||
currentTarget?.textContent || undefined;
input.onDragStart(node, event, textContent);
};
const onDragOver = (isClosingTag: boolean) => (event: DragEvent): void => {
event.stopPropagation();
input.onDragOver(node, isClosingTag, event);
};
const onDragLeave = (event: DragEvent): void => {
event.stopPropagation();
input.onDragLeave(event);
};
const onDrop = (isClosingTag: boolean) => (event: DragEvent): void => {
event.stopPropagation();
input.onDrop(node, isClosingTag, event);
};
const onDragEnd = (event: DragEvent): void => {
event.stopPropagation();
input.onDragEnd(event);
};
// clang-format off
return html`
<li role="treeitem"
data-backend-node-id=${ifDefined(node.backendNodeId())}
data-target-id=${ifDefined(node.domModel().target().id())}
selectable=${input.selectEnabled ? 'true' : 'false'}
?selected=${isSelected && !input.selectedClosingTag}
${input.currentHighlightedNode === node ? clonedRef(scrollHighlightedRowIntoView) : nothing}
class=${classes}
style=${styleMap({'--indent': `${computeLeftIndent(depth, isExpandable)}px`})}
?open=${hasChildren && isExpanded && !isEditingAsHTML}
draggable=${isDraggable ? 'true' : 'false'}
@select=${on((event: UI.TreeOutline.TreeViewElement.SelectEvent) => onSelect(false, Boolean(event.detail?.selectedByUser)))}
@expand=${on(onExpand)}
@mousemove=${on(onMouseMove(/* isClosingTag= */ false))}
@mousedown=${on(onMouseDown)}
@contextmenu=${on(onContextMenu)}
@dragstart=${on(onDragStart)}
@dragover=${on(onDragOver(/* isClosingTag= */ false))}
@dragleave=${on(onDragLeave)}
@drop=${on(onDrop(/* isClosingTag= */ false))}
@dragend=${on(onDragEnd)}
jslog=${treeItemJslog(undefined, true)}>${UI.Widget.widget(DOMTreeElementWidget, {
node,
isClosingTag: false,
renderSelection: false,
expanded: isExpanded && !isEditingAsHTML,
isExpandable: hasChildren,
selected: isSelected && !input.selectedClosingTag,
isDOMNodeSelected,
hovered: isOpeningHovered,
searchQuery: input.searchMatchNode === node ? input.searchMatchQuery : null,
computeLeftIndent: computeLeftIndent(depth, isExpandable),
disableEdits: input.disableEdits,
showAIButton: input.showAIButton,
initialEdit: input.nodeToEdit?.node === node ? input.nodeToEdit : null,
onInitialEditCompleted: input.onInitialEditCompleted,
attributeToHighlight:
input.attributeToHighlight?.node === node ? input.attributeToHighlight.attribute : null,
onAttributeHighlighted: input.onAttributeHighlighted,
adornersUpdateVersion: input.nodeAdornerVersions.get(node) ?? 0,
popoverAdornerActive: input.forceOpenPopovers.has(node),
onPopoverAdornerToggled: input.onPopoverAdornerToggled,
interestAdornerActive: input.forceOpenInterests.has(node),
onInterestAdornerToggled: input.onInterestAdornerToggled,
expandNode: isCollapsible ? () => input.onExpand(node, true) : undefined,
revealInTopLayer: input.onRevealInTopLayer,
setMultilineEditing: (multilineEditing, n) => input.onSetMultilineEditing(multilineEditing, n ?? node),
visibleWidth: input.visibleWidth,
selectDOMNode: (n: SDK.DOMModel.DOMNode, selectedByUser?: boolean) => input.onSelect(n, false, Boolean(selectedByUser)),
selectNodeAfterEdit: (wasExpanded: boolean, error: string|null, newNode: SDK.DOMModel.DOMNode|null, moveDirection?: string) => {
input.onSelectNodeAfterEdit(wasExpanded, error, newNode, moveDirection);
},
toggleHideElement: (n: SDK.DOMModel.DOMNode) => {
return input.onToggleHideElement(n) ?? Promise.resolve();
},
isToggledToHidden: (n: SDK.DOMModel.DOMNode) => input.isToggledToHidden(n) ?? false,
showContextMenu: (event: Event) => {
if (event instanceof MouseEvent) {
input.onContextMenu(node, event);
}
},
updateRecord: input.updateRecordForNode(node) ?? null,
})}${hasChildren ? html`<ul role="group">
${isExpanded && !isEditingAsHTML ? html`
${node.adoptedStyleSheetsForNode.length > 0 ? renderAdoptedStyleSheets(node, depth + 1) : nothing}
${repeat(children, child => child.id, child => renderNode(child, depth + 1))}
${remainingChildrenCount > 0 ? html`
<li role="treeitem"
selectable="false"
class="elements-tree-expand-all"
style=${styleMap({'--indent': `${computeLeftIndent(depth + 1, false)}px`})}
jslog=${treeItemJslog('show-all-nodes')}><devtools-button
.variant=${Buttons.Button.Variant.OUTLINED}
title=${i18nString(UIStrings.showAllNodesDMore, {PH1: remainingChildrenCount})}
@click=${on((event: Event) => {
event.stopPropagation();
input.onExpandAllChildren(node);
})}>${i18nString(UIStrings.showAllNodesDMore, {PH1: remainingChildrenCount})}</devtools-button></li>
` : nothing}
${node.isInsertionPoint() ? node.distributedNodes().map(distributedNode => renderShortcut(distributedNode, depth + 1)) : nothing}
${node instanceof SDK.DOMModel.DOMDocument ? renderTopLayerContainer(node, depth + 1) : nothing}
${needsClosingTag ? html`
<li role="treeitem"
data-backend-node-id=${ifDefined(node.backendNodeId())}
data-target-id=${ifDefined(node.domModel().target().id())}
selectable=${input.selectEnabled ? 'true' : 'false'}
?selected=${isSelected && input.selectedClosingTag}
class=${classMap({
selected: isSelected && input.selectedClosingTag,
hovered: isClosingHovered,
'elements-drag-over': isClosingTagDragOver,
})}
style=${styleMap({'--indent': `${computeLeftIndent(depth + 1, false)}px`})}
draggable=${isDraggable ? 'true' : 'false'}
jslog=${treeItemJslog(undefined, true)}
@select=${on((event: UI.TreeOutline.TreeViewElement.SelectEvent) => onSelect(true, Boolean(event.detail?.selectedByUser)))}
@contextmenu=${on(onContextMenu)}
@mousemove=${on(onMouseMove(/* isClosingTag= */ true))}
@mousedown=${on(onMouseDown)}
@dragstart=${on(onDragStart)}
@dragover=${on(onDragOver(/* isClosingTag= */ true))}
@dragleave=${on(onDragLeave)}
@drop=${on(onDrop(/* isClosingTag= */ true))}
@dragend=${on(onDragEnd)}>${UI.Widget.widget(DOMTreeElementWidget, {
node,
isClosingTag: true,
renderSelection: false,
expanded: false,
isExpandable: false,
selected: isSelected && input.selectedClosingTag,
isDOMNodeSelected,
hovered: isClosingHovered,
computeLeftIndent: computeLeftIndent(depth + 1, false),
disableEdits: input.disableEdits,
showAIButton: false,
showContextMenu: (event: Event) => {
if (event instanceof MouseEvent) {
input.onContextMenu(node, event);
}
},
updateRecord: input.updateRecordForNode(node) ?? null,
})}</li>
` : nothing}
` : nothing}
</ul>` : nothing}</li>
`;
// clang-format on
};
const isSingleNode =
Boolean(input.deindentSingleNode && rootNodes.length === 1 && !nodeHasVisibleChildren(rootNodes[0], input));
const disclosureClasses = classMap({
'elements-disclosure': true,
'single-node': isSingleNode,
'elements-tree-truncated': Boolean(input.maxRowsShown),
});
const disclosureStyles = styleMap({
'--max-rows': input.maxRowsShown ? String(input.maxRowsShown) : null,
});
const totalVisibleRows = input.maxRowsShown ? countAllVisibleRows() : 0;
const truncatedLines = input.maxRowsShown ? Math.max(0, totalVisibleRows - input.maxRowsShown) : 0;
// This widget renders without a shadow root, so you must scope its
// stylesheets. Otherwise, broad rules such as `* {min-width: 0}` in
// `inspectorCommon.css` apply to the host's whole tree scope and break the
// layout of hosts such as the AI assistance chat.
// clang-format off
render(html`
<style>@scope to (devtools-widget > *) { ${UI.inspectorCommonStyles} }</style>
<style>@scope to (devtools-widget > *) { ${domTreeWidgetStyles} }</style>
<style>@scope to (devtools-widget > *) { ${CodeHighlighter.codeHighlighterStyles} }</style>
<div class=${disclosureClasses} style=${disclosureStyles}>
<devtools-tree
class="elements-tree-outline ${input.wrap ? '' : 'elements-tree-nowrap'} ${input.hideGutter ? 'elements-hide-gutter' : ''} ${isSingleNode ? 'single-node' : ''}"
disclosure-class="elements-disclosure ${isSingleNode ? 'single-node' : ''} ${input.maxRowsShown ? 'elements-tree-truncated' : ''}"
jslog=${VisualLogging.tree('elements')}
?show-selection-on-keyboard-focus=${input.showSelectionOnKeyboardFocus}
@enter=${(event: Event) => event.preventDefault()}
@keydown=${input.onKeyDown}
@clipboard-copy=${(event: Event) => input.onCopyOrCut(false, event)}
@clipboard-cut=${(event: Event) => input.onCopyOrCut(true, event)}
@clipboard-paste=${(event: Event) => input.onPaste(event)}
@mouseleave=${input.onLeave}
.template=${html`
<style>${UI.inspectorCommonStyles}</style>
<style>${domTreeWidgetStyles}</style>
<style>${CodeHighlighter.codeHighlighterStyles}</style>
<ul role="tree" class="elements-tree-outline source-code ${input.wrap ? '' : 'elements-tree-nowrap'} ${input.hideGutter ? 'elements-hide-gutter' : ''} ${isSingleNode ? 'single-node' : ''}" aria-label=${i18nString(UIStrings.pageDom)}>
${input.omitRootDOMNode && input.rootDOMNode && input.rootDOMNode.adoptedStyleSheetsForNode.length > 0 ?
renderAdoptedStyleSheets(input.rootDOMNode, 0) : nothing}
${repeat(rootNodes, node => node.id, node => renderNode(node))}
${input.omitRootDOMNode && input.rootDOMNode ? (() => {
const remaining = allRootNodes.length - rootNodes.length;
if (remaining <= 0) {
return nothing;
}
const root = input.rootDOMNode;
return html`
<li role="treeitem"
selectable="false"
class="elements-tree-expand-all"
style=${styleMap({'--indent': `${computeLeftIndent(0, false)}px`})}
jslog=${treeItemJslog('show-all-nodes')}><devtools-button
.variant=${Buttons.Button.Variant.OUTLINED}
title=${i18nString(UIStrings.showAllNodesDMore, {PH1: remaining})}
@click=${on((event: Event) => {
event.stopPropagation();
input.onExpandAllChildren(root);
})}>${i18nString(UIStrings.showAllNodesDMore, {PH1: remaining})}</devtools-button></li>
`;
})() : nothing}
${input.omitRootDOMNode && input.rootDOMNode instanceof SDK.DOMModel.DOMDocument ? renderTopLayerContainer(input.rootDOMNode, 0) : nothing}
</ul>
`}>
</devtools-tree>
</div>
${truncatedLines > 0 ? html`
<button
type="button"
class="elements-tree-show-all"
jslog=${VisualLogging.action('show-all-nodes').track({click: true})}
@click=${input.onClearMaxRows}>
${i18nString(UIStrings.showAllLines, {PH1: truncatedLines})}
</button>
` : nothing}
`, target);
// clang-format on
if (input.nodesWithDirtyAdorners.size > 0) {
input.onDirtyAdornersUpdated();
}
};
function getElementsTreeWidgetAndNode(element: Element): {node?: SDK.DOMModel.DOMNode, widget?: DOMTreeElementWidget} {
let current: Element|null = element;
while (current) {
if (current.tagName === 'LI') {
const devtoolsWidget =
current.querySelector(':scope > devtools-widget, :scope > .tree-element-title > devtools-widget');
if (devtoolsWidget) {
const widget = UI.Widget.Widget.get(devtoolsWidget);
if (widget instanceof DOMTreeElementWidget) {
return {node: widget.node, widget};
}
}
}
current = current.parentElementOrShadowHost();
}
return {};
}
/**
* Presenter for the DOM tree shown in the Elements panel. Renders the tree
* declaratively via `<devtools-tree>` and `ElementsTreeWidget`.
*/
export class DOMTreeWidget extends UI.Widget.Widget {
static override readonly INJECT: readonly[typeof ChangeTracker.ChangeTracker.ChangeTracker] =
[ChangeTracker.ChangeTracker.ChangeTracker] as const;
omitRootDOMNode = false;
selectEnabled = false;
hideGutter = false;
showSelectionOnKeyboardFocus = 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 = () => {};
onElementCollapsed: () => void = () => {};
onElementExpanded: () => void = () => {};
onDocumentUpdated: (domModel: SDK.DOMModel.DOMModel) => void = () => {};
#maxTreeDepth?: number;
#enableContextMenu = true;
#showHTMLCommentsSetting =
Common.Settings.Settings.instance().resolve(SettingsUI.ElementsSettings.showHTMLCommentsSettingDescriptor);
#lastObservedShowHTMLCommentsSetting = this.#showHTMLCommentsSetting.get();
#showComments = this.#lastObservedShowHTMLCommentsSetting;
#showAIButton = true;
#disableEdits = false;
#expandRoot = false;
#visibleWidth?: number;
#wrap = false;
#maxRows?: number;
#changeTracker?: ChangeTracker.ChangeTracker.ChangeTracker;
// 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;
}
get visibleWidth(): number {
return this.#visibleWidth ?? this.contentElement.offsetWidth;
}
set visibleWidth(width: number) {
this.#visibleWidth = width;
this.performUpdate();
}
#rootDOMNode: SDK.DOMModel.DOMNode|null = null;
#selectedDOMNode: SDK.DOMModel.DOMNode|null = null;
#expandedNodes = new Set<SDK.DOMModel.DOMNode>();
#expandedChildrenLimitByNode = new WeakMap<SDK.DOMModel.DOMNode, number>();
#expandRootNode(node: SDK.DOMModel.DOMNode): void {
if (!this.#expandRoot && !this.omitRootDOMNode) {
return;
}
this.#expandedNodes.add(node);
const expandChildren = (): void => {
for (const child of node.children() ?? []) {
if (isNotCollapsible(child) || (this.#expandRoot && this.omitRootDOMNode)) {
this.setNodeExpanded(child, true);
}
}
};
if (node.children()) {
expandChildren();
} else if (node.childNodeCount()) {
void node.getChildNodes(() => {
expandChildren();
this.performUpdate();
});
}
}
set rootDOMNode(node: SDK.DOMModel.DOMNode|null) {
this.#rootDOMNode = node;
if (node) {
this.#expandRootNode(node);
}
this.performUpdate();
}
get rootDOMNode(): SDK.DOMModel.DOMNode|null {
return this.#rootDOMNode;
}
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.#validateSelectedNode();
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;
if (this.#rootDOMNode) {
this.#expandRootNode(this.#rootDOMNode);
}
this.performUpdate();
}
#currentHighlightedNode: SDK.DOMModel.DOMNode|null = null;
#domModelListeners = new Map<SDK.DOMModel.DOMModel, Common.EventTarget.EventDescriptor[]>();
#topLayerShortcutsByDocument = new WeakMap<SDK.DOMModel.DOMDocument, SDK.DOMModel.DOMNodeShortcut[]>();
#topLayerContainerExpandedByDocument = new WeakMap<SDK.DOMModel.DOMDocument, boolean>();
#topLayerShortcutExpanded = new WeakMap<SDK.DOMModel.DOMNodeShortcut, boolean>();
#selectedTopLayerShortcut: SDK.DOMModel.DOMNodeShortcut|null = null;
#adoptedStyleSheetsExpandedByNode = new WeakMap<SDK.DOMModel.DOMNode, boolean>();
#adoptedStyleSheetExpanded = new WeakMap<SDK.DOMModel.AdoptedStyleSheet, boolean>();
#selectedAdoptedStyleSheet: SDK.DOMModel.AdoptedStyleSheet|null = null;
// TODO: Move #imagePreviewPopover and #issuePopoverHelper to the view once declarative versions exist.
#imagePreviewPopover?: ImagePreviewPopover;
#issuePopoverHelper?: UI.PopoverHelper.PopoverHelper;
#view: View;
#highlightThrottler = new Common.Throttler.Throttler(100);
get changeTracker(): ChangeTracker.ChangeTracker.ChangeTracker|undefined {
// The widget is also created without dependency injection (e.g. by
// ElementsPanel), so the changeTracker can be empty, relying on the manual
// resolution. Note that this only works once the widget is attached to the
// DOM.
this.#changeTracker ??=
UI.Widget.lookupUniverseForElement(this.contentElement)?.get(ChangeTracker.ChangeTracker.ChangeTracker);
return this.#changeTracker;
}
constructor(
element?: HTMLElement,
[changeTracker]:
UI.Widget.WidgetDependencies<typeof DOMTreeWidget>|[ChangeTracker.ChangeTracker.ChangeTracker?] = [],
view: View = DEFAULT_VIEW,
) {
super(element, {
useShadowDom: false,
delegatesFocus: false,
});
this.#view = view;
this.#changeTracker = changeTracker;
this.#setupPopovers();
}
// Global listeners are registered while the widget is showing (see
// wasShown/willHide) so that hidden or abandoned trees (e.g. the ones the
// Console creates per logged DOM node) do not stay alive through them.
#globalListenersRegistered = false;
#registerGlobalListeners(): void {
if (this.#globalListenersRegistered) {
return;
}
this.#globalListenersRegistered = true;
this.#showHTMLCommentsSetting.addChangeListener(this.#onShowHTMLCommentsChange, this);
if (Common.Settings.Settings.instance()
.resolve(SettingsUI.ElementsSettings.highlightNodeOnHoverInOverlaySettingDescriptor)
.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.#onInspectModeWillBeToggled, this, {scoped: true});
}
}
#unregisterGlobalListeners(): void {
if (!this.#globalListenersRegistered) {
return;
}
this.#globalListenersRegistered = false;
this.#showHTMLCommentsSetting.removeChangeListener(this.#onShowHTMLCommentsChange, this);
SDK.TargetManager.TargetManager.instance().removeModelListener(
SDK.OverlayModel.OverlayModel, SDK.OverlayModel.Events.HIGHLIGHT_NODE_REQUESTED, this.#highlightNode, this);
SDK.TargetManager.TargetManager.instance().removeModelListener(SDK.OverlayModel.OverlayModel,
SDK.OverlayModel.Events.INSPECT_MODE_WILL_BE_TOGGLED,
this.#onInspectModeWillBeToggled, this);
}
// TODO: Move imagePreviewPopover and issuePopoverHelper to the view once declarative versions exist.
#setupPopovers(): void {
this.#imagePreviewPopover = new ImagePreviewPopover(
this.contentElement,
event => {
let link: (Element|null) = (event.composedPath()[0] as Element | null);
while (link && link !== this.contentElement && !ImagePreviewPopover.getImageURL(link)) {
link = link.parentElementOrShadowHost();
}
return (link && link !== this.contentElement) ? link : null;
},
async link => {
const {node} = getElementsTreeWidgetAndNode(link);
return await UIComponentUtils.ImagePreview.loadPrecomputedFeatures(node);
});
this.#issuePopoverHelper = new UI.PopoverHelper.PopoverHelper(this.contentElement, event => {
const hoveredNode = event.composedPath()[0];
if (!(hoveredNode instanceof Element) || !hoveredNode.matches('.violating-element')) {
return null;
}
const {node, widget} = getElementsTreeWidgetAndNode(hoveredNode);
if (!node) {
return null;
}
let issues = widget?.issues ?? [];
if (hoveredNode.classList.contains('webkit-html-attribute-name')) {
const attrName = hoveredNode.textContent;
issues = issues.filter(issue => getElementIssueDetails(issue)?.attribute === attrName);
} else if (hoveredNode.classList.contains('webkit-html-tag-name')) {
issues = issues.filter(issue => {
const details = getElementIssueDetails(issue);
return Boolean(details && !details.attribute);
});
}
if (issues.length === 0) {
return null;
}
return {
box: hoveredNode.boxInWindow(),
show: async (popover: UI.GlassPane.GlassPane) => {
popover.setIgnoreLeftMargin(true);
// The popover content is rendered imperatively into the GlassPane
// provided by PopoverHelper; this is a one-off render outside of
// the view until the popover is made declarative.
// clang-format off
// eslint-disable-next-line @devtools/no-lit-render-outside-of-view
render(html`
<div class="squiggles-content">
${issues.map(issue => {
const elementIssueDetails = getElementIssueDetails(issue);
if (!elementIssueDetails) {
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.#issuePopoverHelper.setTimeout(300);
}
#updateRecords = new Map<SDK.DOMModel.DOMNode, Elements.ElementUpdateRecord.ElementUpdateRecord>();
updateRecordsForTest(): Map<SDK.DOMModel.DOMNode, Elements.ElementUpdateRecord.ElementUpdateRecord> {
return this.#updateRecords;
}
#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;
}
#onDocumentUpdated(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMModel>): void {
const domModel = event.data;
this.#resetTreeState();
if (domModel.existingDocument()) {
this.rootDOMNode = domModel.existingDocument();
}
}
/** Forgets selection, expansion and pending changes, e.g. when the document is replaced. */
#resetTreeState(): void {
this.#selectedDOMNode = null;
this.#selectedClosingTag = false;
this.#expandedNodes.clear();
this.#currentHighlightedNode = null;
this.#updateRecords.clear();
}
#updateModifiedNodesTimeout?: number;
#updateModifiedNodesSoon(): void {
if (!this.#updateRecords.size) {
return;
}
if (this.#updateModifiedNodesTimeout) {
return;
}
this.#updateModifiedNodesTimeout = window.setTimeout(this.updateModifiedNodes.bind(this), 50);
}
#cancelPendingModifiedNodesUpdate(): void {
if (this.#updateModifiedNodesTimeout) {
clearTimeout(this.#updateModifiedNodesTimeout);
this.#updateModifiedNodesTimeout = undefined;
}
}
/** Drops changes that have been recorded but not rendered yet, e.g. when the tree is hidden. */
#discardPendingUpdates(): void {
this.#cancelPendingModifiedNodesUpdate();
this.#updateRecords.clear();
}
#onNodeInserted(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void {
const node = event.data;
if (node.parentNode) {
this.#addUpdateRecord(node.parentNode).nodeInserted(node);
}
this.#updateModifiedNodesSoon();
}
#findNextNodeOnRemoval(node: SDK.DOMModel.DOMNode, parent: SDK.DOMModel.DOMNode|null): SDK.DOMModel.DOMNode|null {
const visibleChildren = parent ? getVisibleChildren(parent, this) : [];
const sibling = findVisibleSibling(node, visibleChildren);
if (sibling) {
return sibling;
}
if (parent) {
if (parent.nodeType() === Node.DOCUMENT_NODE) {
return parent.parentNode;
}
return parent;
}
return null;
}
#onNodeRemoved(
event: Common.EventTarget.EventTargetEvent<{node: SDK.DOMModel.DOMNode, parent: SDK.DOMModel.DOMNode}>): void {
const {node, parent} = event.data;
this.resetClipboardIfNeeded(node);
if (parent) {
this.#addUpdateRecord(parent).nodeRemoved(node);
}
if (this.#selectedDOMNode && (this.#selectedDOMNode === node || node.isAncestor(this.#selectedDOMNode))) {
this.selectDOMNode(this.#findNextNodeOnRemoval(node, parent), true);
}
this.#updateModifiedNodesSoon();
}
#onCharacterDataModified(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void {
const node = event.data;
this.#addUpdateRecord(node).charDataModified();
if (node.parentNode && node.parentNode.firstChild === node.parentNode.lastChild) {
this.#addUpdateRecord(node.parentNode).childrenModified();
}
this.#updateModifiedNodesSoon();
}
#onMarkersChanged(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void {
const node = event.data;
if (node) {
this.#addUpdateRecord(node);
}
this.performUpdate();
}
updateModifiedNodes(): void {
this.#cancelPendingModifiedNodesUpdate();
this.#validateSelectedNode();
this.performUpdate();
}
#wireDOMModel(domModel: SDK.DOMModel.DOMModel): void {
const {Events} = SDK.DOMModel;
// Records a change for the event's node and schedules a re-render.
const recordAndUpdate = (record: (node: SDK.DOMModel.DOMNode) => void) =>
(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void => {
record(event.data);
this.#updateModifiedNodesSoon();
};
const recordAttributeAndUpdate = (record: (node: SDK.DOMModel.DOMNode, name: string) => void) =>
(event: Common.EventTarget.EventTargetEvent<{node: SDK.DOMModel.DOMNode, name: string}>): void => {
record(event.data.node, event.data.name);
this.#updateModifiedNodesSoon();
};
const listeners: Common.EventTarget.EventDescriptor[] = [
domModel.addEventListener(Events.DocumentUpdated, this.#onDocumentUpdated, this),
domModel.addEventListener(Events.NodeInserted, this.#onNodeInserted, this),
domModel.addEventListener(Events.NodeRemoved, this.#onNodeRemoved, this),
domModel.addEventListener(
Events.AttrModified,
recordAttributeAndUpdate((node, name) => this.#addUpdateRecord(node).attributeModified(name))),
domModel.addEventListener(
Events.AttrRemoved,
recordAttributeAndUpdate((node, name) => this.#addUpdateRecord(node).attributeRemoved(name))),
domModel.addEventListener(Events.CharacterDataModified, this.#onCharacterDataModified, this),
domModel.addEventListener(Events.ChildNodeCountUpdated,
recordAndUpdate(node => this.#addUpdateRecord(node).childrenModified())),
domModel.addEventListener(Events.MarkersChanged, this.#onMarkersChanged, this),
domModel.addEventListener(Events.AdoptedStyleSheetsModified,
recordAndUpdate(node => this.#addUpdateRecord(node).childrenModified())),
domModel.addEventListener(Events.TopLayerElementsChanged, this.#onTopLayerElementsChanged, this),
domModel.addEventListener(Events.DistributedNodesChanged,
recordAndUpdate(node => this.#addUpdateRecord(node).childrenModified())),
domModel.addEventListener(Events.DocumentURLChanged,
recordAndUpdate(node => this.#addUpdateRecord(node).charDataModified())),
domModel.addEventListener(Events.AffectedByStartingStylesFlagUpdated, this.requestUpdate, this),
];
const cssModel = domModel.target().model(SDK.CSSModel.CSSModel);
if (cssModel) {
listeners.push(cssModel.addEventListener(SDK.CSSModel.Events.StyleSheetAdded, this.requestUpdate, this));
}
this.#domModelListeners.set(domModel, listeners);
}
#unwireDOMModel(domModel: SDK.DOMModel.DOMModel): void {
Common.EventTarget.removeEventListeners(this.#domModelListeners.get(domModel) ?? []);
this.#domModelListeners.delete(domModel);
}
#onShowHTMLCommentsChange(): void {
this.#lastObservedShowHTMLCommentsSetting = this.#showHTMLCommentsSetting.get();
this.#showComments = this.#lastObservedShowHTMLCommentsSetting;
this.#validateSelectedNode();
const selectedNode = this.selectedDOMNode();
if (selectedNode && selectedNode.nodeType() === Node.COMMENT_NODE && !this.#showComments) {
this.selectDOMNode(selectedNode.parentNode);
} else {
this.performUpdate();
}
}
#highlightNode(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode>): void {
void this.#highlightThrottler.schedule(() => {
const node = this.#resolveInlinedTextNode(event.data);
// The view expands the ancestors of the highlighted node. Raise the
// children limits so that the node is not paginated away.
if (node) {
for (let child = node, ancestor = node.parentNode; ancestor; child = ancestor, ancestor = ancestor.parentNode) {
this.#ensureChildVisible(ancestor, child);
}
}
this.#currentHighlightedNode = node;
this.requestUpdate();
});
}
/** Short text nodes render inline in the row of their parent node. */
#resolveInlinedTextNode(node: SDK.DOMModel.DOMNode|null): SDK.DOMModel.DOMNode|null {
if (node?.nodeType() === Node.TEXT_NODE && node.parentNode &&
(!nodeHasVisibleChildren(node.parentNode, this) || !getVisibleChildren(node.parentNode, this).includes(node))) {
return node.parentNode;
}
return node;
}
#onInspectModeWillBeToggled(): void {
this.#clearHighlightedNode();
this.performUpdate();
}
// Callers render themselves, so that an entry point renders only once.
#clearHighlightedNode(): void {
this.#currentHighlightedNode = null;
}
/** True if the view expands `node` only because it is an ancestor of the highlighted node. */
#isExpandedOnlyForHighlight(node: SDK.DOMModel.DOMNode): boolean {
const highlightedNode = this.#currentHighlightedNode;
return highlightedNode !== null && !this.#expandedNodes.has(node) && isAncestorOf(node, highlightedNode);
}
selectDOMNode(node: SDK.DOMModel.DOMNode|SDK.DOMModel.AdoptedStyleSheet|null, focus?: boolean,
isClosingTag?: boolean): void {
this.#imagePreviewPopover?.hide();
this.#issuePopoverHelper?.hidePopover();
if (node instanceof SDK.DOMModel.AdoptedStyleSheet) {
this.highlightAdoptedStyleSheet(node);
return;
}
this.#selectedAdoptedStyleSheet = null;
node = this.#resolveInlinedTextNode(node);
if (node) {
const ancestors: SDK.DOMModel.DOMNode[] = [];
for (let current: (SDK.DOMModel.DOMNode|null) = node.parentNode; current; current = current.parentNode) {
ancestors.unshift(current);
}
for (let i = 0; i < ancestors.length; ++i) {
const ancestor = ancestors[i];
const child = ancestors[i + 1] || node;
this.#expandedNodes.add(ancestor);
this.#ensureChildVisible(ancestor, child);
if (!ancestor.children() && ancestor.childNodeCount()) {
void ancestor.getChildNodes(() => {
this.#ensureChildVisible(ancestor, child);
this.performUpdate();
});
}
}
if (isClosingTag && nodeHasVisibleChildren(node, this)) {
this.#expandedNodes.add(node);
}
}
const selectClosingTag = Boolean(isClosingTag) && Boolean(node && this.#hasClosingTag(node));
const isSameNode = this.#selectedDOMNode === node && this.#selectedClosingTag === selectClosingTag;
this.#selectedDOMNode = node;
this.#selectedClosingTag = selectClosingTag;
this.#clearHighlightedNode();
if (!isSameNode) {
this.#notifySelectedNodeChanged(node, Boolean(focus));
}
if (focus) {
this.focus();
}
this.performUpdate();
}
/** Raises the children limit of `ancestor` if `child` is paginated away. */
#ensureChildVisible(ancestor: SDK.DOMModel.DOMNode, child: SDK.DOMModel.DOMNode): void {
const childIndex = getVisibleChildren(ancestor, this).indexOf(child);
if (childIndex !== -1 && childIndex >= this.expandedChildrenLimit(ancestor)) {
this.#expandedChildrenLimitByNode.set(ancestor, childIndex + 1);
}
}
#notifySelectedNodeChanged(node: SDK.DOMModel.DOMNode|null, focus: boolean): void {
this.onSelectedNodeChanged(
{data: {node, focus}} as
Common.EventTarget.EventTargetEvent<{node: SDK.DOMModel.DOMNode | null, focus: boolean}>);
}
highlightNodeAttribute(node: SDK.DOMModel.DOMNode, attribute: string): void {
this.#attributeToHighlight = {node, attribute};
this.selectDOMNode(node);
}
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.#selectedDOMNode;
}
setNodeExpanded(node: SDK.DOMModel.DOMNode, expanded: boolean): void {
if (!expanded && this.#multilineEditingNode === node) {
return;
}
if (expanded) {
// The tree outline dispatches an `expand` event for every node that is
// rendered with the `open` attribute. After `expandRecursively()`, this
// happens once per node in the subtree. Each event would otherwise
// trigger a synchronous re-render of the whole tree, which is quadratic
// in the number of expanded nodes. Skip the work when the node is
// already expanded and its children are loaded.
if (this.#expandedNodes.has(node) && node.children()) {
return;
}
this.#expandedNodes.add(node);
// Children that are not collapsible (e.g. the document element) render
// without an expand arrow, so their children must be fetched eagerly.
const expandNotCollapsibleChildren = (): void => {
for (const child of node.children() ?? []) {
// Check the Set directly instead of isNodeExpanded(), because
// isNodeExpanded() always returns true for non-collapsible nodes.
if (isNotCollapsible(child) && !this.#expandedNodes.has(child)) {
this.setNodeExpanded(child, true);
}
}
};
if (!node.children() && (node.childNodeCount() || node.isIframe() || node.nodeType() === Node.DOCUMENT_NODE)) {
void node.getChildNodes(() => {
expandNotCollapsibleChildren();
this.performUpdate();
});
} else {
expandNotCollapsibleChildren();
}
this.onElementExpanded();
} else {
this.#expandedNodes.delete(node);
this.onElementCollapsed();
}
if (!this.#currentHighlightedNode || !isAncestorOf(node, this.#currentHighlightedNode)) {
this.#clearHighlightedNode();
}
this.performUpdate();
}
isNodeExpanded(node: SDK.DOMModel.DOMNode): boolean {
return isNotCollapsible(node) || this.#expandedNodes.has(node);
}
expandedChildrenLimit(node: SDK.DOMModel.DOMNode): number {
return this.#expandedChildrenLimitByNode.get(node) ?? InitialChildrenLimit;
}
setExpandedChildrenLimit(node: SDK.DOMModel.DOMNode, limit: number): void {
if (this.expandedChildrenLimit(node) === limit) {
return;
}
this.#expandedChildrenLimitByNode.set(node, limit);
this.performUpdate();
}
expandAllChildren(node: SDK.DOMModel.DOMNode): void {
const currentLimit = this.expandedChildrenLimit(node);
const visibleChildren = getVisibleChildren(node, this);
this.setExpandedChildrenLimit(node, Math.max(visibleChildren.length, currentLimit + InitialChildrenLimit));
}
async expandRecursively(node: SDK.DOMModel.DOMNode, maxDepth: number = Number.MAX_VALUE): Promise<void> {
const expand = async(n: SDK.DOMModel.DOMNode, depth: number): Promise<void> => {
if (depth > maxDepth) {
return;
}
if (n.childDocumentPromiseForTesting) {
await n.childDocumentPromiseForTesting;
}
if (!n.children() && !n.contentDocument() &&
(n === node || n.childNodeCount() || n.isIframe() || n.nodeType() === Node.DOCUMENT_NODE)) {
await n.getSubtree(100, true);
}
if (nodeHasVisibleChildren(n, this)) {
this.#expandedNodes.add(n);
}
const visibleChildren = getVisibleChildren(n, this);
if (visibleChildren.length) {
if (visibleChildren.length > this.expandedChildrenLimit(n)) {
this.#expandedChildrenLimitByNode.set(n, visibleChildren.length);
}
await Promise.all(visibleChildren.map(child => expand(child, depth + 1)));
}
};
await expand(node, 0);
this.performUpdate();
this.onElementExpanded();
}
collapseChildren(node: SDK.DOMModel.DOMNode): void {
const collapse = (n: SDK.DOMModel.DOMNode): void => {
for (const child of getVisibleChildren(n, this)) {
if (this.#expandedNodes.delete(child)) {
collapse(child);
}
}
};
collapse(node);
this.performUpdate();
}
showContextMenu(node: SDK.DOMModel.DOMNode, event: MouseEvent): Promise<UI.ContextMenu.ContextMenu|undefined> {
if (!this.#enableContextMenu) {
return Promise.resolve(undefined);
}
return showContextMenu(this, node, event);
}
/**
* Re-renders the tree from scratch, for example, when a global setting
* such as `show-ua-shadow-dom` changes.
*
* FIXME: the setting values should be part of the view input instead.
*/
reload(): void {
this.performUpdate();
}
#hoveredDOMNode: SDK.DOMModel.DOMNode|null = null;
#hoveredClosingTag = false;
#selectedClosingTag = false;
#searchMatchNode: SDK.DOMModel.DOMNode|null = null;
#searchMatchQuery: string|null = null;
#nodeToEdit: ({node: SDK.DOMModel.DOMNode}&InitialEditState)|null = null;
#attributeToHighlight: {node: SDK.DOMModel.DOMNode, attribute: string}|null = null;
#nodesWithDirtyAdorners = new Set<SDK.DOMModel.DOMNode>();
#nodeAdornerVersions = new WeakMap<SDK.DOMModel.DOMNode, number>();
#forceOpenPopovers = new WeakSet<SDK.DOMModel.DOMNode>();
#forceOpenInterests = new WeakSet<SDK.DOMModel.DOMNode>();
#draggedNode: SDK.DOMModel.DOMNode|null = null;
#draggedNodeWasExpanded = false;
#dragOverNode: {node: SDK.DOMModel.DOMNode, isClosingTag: boolean}|null = null;
#isFocusing = false;
hoveredDOMNode(): SDK.DOMModel.DOMNode|null {
return this.#hoveredDOMNode;
}
hoveredClosingTag(): boolean {
return this.#hoveredClosingTag;
}
selectedClosingTag(): boolean {
return this.#selectedClosingTag;
}
searchMatchNode(): SDK.DOMModel.DOMNode|null {
return this.#searchMatchNode;
}
searchMatchQuery(): string|null {
return this.#searchMatchQuery;
}
setHoveredNode(node: SDK.DOMModel.DOMNode|null, showInfo = true, isClosingTag = false): void {
if (this.hoveredDOMNode() === node && this.#hoveredClosingTag === Boolean(isClosingTag)) {
return;
}
this.#hoveredDOMNode = node;
this.#hoveredClosingTag = Boolean(isClosingTag);
if (node) {
// TODO(crbug.com/407751692): pass `selectorList: '*'` for `display: contents`
// elements once the view can report that state for the hovered node.
node.domModel().overlayModel().highlightInOverlay({node, selectorList: undefined}, 'all', showInfo);
} else {
SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(SDK.TargetManager.TargetManager.instance());
}
this.performUpdate();
}
#findValidSelectedNode(selectedNode: SDK.DOMModel.DOMNode): SDK.DOMModel.DOMNode|null {
if (!this.#rootDOMNode) {
return null;
}
if (selectedNode === this.#rootDOMNode) {
return selectedNode;
}
let current: SDK.DOMModel.DOMNode|null = selectedNode;
let fallback: SDK.DOMModel.DOMNode|null = null;
while (current && current !== this.#rootDOMNode) {
const parent: SDK.DOMModel.DOMNode|null = current.parentNode;
if (!parent) {
return fallback ?? this.#rootDOMNode;
}
if (parent.children() !== null || parent.childNodeCount() === 0 || parent.isIframe()) {
const visibleChildren = getVisibleChildren(parent, this);
if (!visibleChildren.includes(current)) {
const sibling = findVisibleSibling(current, visibleChildren);
return this.#findValidSelectedNode(sibling ?? parent);
}
}
fallback = parent;
current = parent;
}
return current === this.#rootDOMNode ? selectedNode : (fallback ?? this.#rootDOMNode);
}
#hasClosingTag(node: SDK.DOMModel.DOMNode): boolean {
const editingAsHTML =
isBeingEditedAsHTML({multilineEditingNode: this.#multilineEditingNode, nodeToEdit: this.#nodeToEdit}, node);
if (editingAsHTML || !this.isNodeExpanded(node)) {
return false;
}
const hasChildren = nodeHasVisibleChildren(node, this);
return nodeNeedsClosingTag(node, hasChildren);
}
#validateSelectedNode(): void {
if (!this.#selectedDOMNode) {
this.#selectedClosingTag = false;
return;
}
const validNode = this.#findValidSelectedNode(this.#selectedDOMNode);
if (validNode !== this.#selectedDOMNode) {
this.#selectedDOMNode = validNode;
this.#selectedClosingTag = false;
this.#clearHighlightedNode();
this.#notifySelectedNodeChanged(validNode, false);
} else if (this.#selectedClosingTag && !this.#hasClosingTag(this.#selectedDOMNode)) {
this.#selectedClosingTag = false;
}
}
override performUpdate(): void {
this.#cancelPendingModifiedNodesUpdate();
this.#validateSelectedNode();
const updatedNodes = [...this.#updateRecords.keys()];
this.#view({
rootDOMNode: this.#rootDOMNode,
omitRootDOMNode: this.omitRootDOMNode,
selectEnabled: this.selectEnabled,
hideGutter: this.hideGutter,
maxTreeDepth: this.#maxTreeDepth,
showComments: this.#showComments,
showAIButton: this.#showAIButton,
disableEdits: this.#disableEdits,
wrap: this.#wrap,
maxRowsShown: this.#maxRows,
onClearMaxRows: () => {
this.maxRows = undefined;
},
showSelectionOnKeyboardFocus: this.showSelectionOnKeyboardFocus,
deindentSingleNode: this.deindentSingleNode,
currentHighlightedNode: this.#currentHighlightedNode,
hoveredNode: this.#hoveredDOMNode,
hoveredClosingTag: this.#hoveredClosingTag,
searchMatchNode: this.#searchMatchNode,
searchMatchQuery: this.#searchMatchQuery,
selectedNode: this.selectedDOMNode(),
selectedClosingTag: this.#selectedClosingTag,
onHoverNode: (node: SDK.DOMModel.DOMNode|null, showInfo?: boolean, isClosingTag = false) => {
this.setHoveredNode(node, showInfo, isClosingTag);
},
onLeave: () => {
this.setHoveredNode(null);
},
onSelect: (node: SDK.DOMModel.DOMNode, isClosingTag = false, selectedByUser?: boolean) => {
if (!selectedByUser) {
return;
}
this.selectDOMNode(node, selectedByUser, isClosingTag);
// Mirror the legacy ElementsTreeElement.onselect behavior: a user
// selection (mouse click or keyboard navigation) highlights the node
// in the page overlay.
node.highlight();
Host.userMetrics.actionTaken(Host.UserMetrics.Action.ChangeInspectedNodeInElementsPanel);
},
onExpand: (node: SDK.DOMModel.DOMNode, expanded: boolean, recursively = false) => {
if (expanded && recursively) {
void this.expandRecursively(node);
return;
}
if (expanded && this.#isExpandedOnlyForHighlight(node)) {
// The tree reports the expansion that the view requested for the
// highlighted node. Do not persist it, so that the node collapses
// again when the highlight clears.
return;
}
if (!expanded && !this.#expandedNodes.has(node)) {
// The tree reports a collapse that the view requested itself, e.g.
// for the ancestors of a previous highlight. The collapse must not
// clear the current highlight.
return;
}
this.setNodeExpanded(node, expanded);
},
onContextMenu: (node: SDK.DOMModel.DOMNode, event: MouseEvent) => {
void this.showContextMenu(node, event);
},
onToggleHideElement: (node: SDK.DOMModel.DOMNode) => {
return this.toggleHideElement(node);
},
onKeyDown: (event: KeyboardEvent) => {
this.onKeyDown(event);
},
isToggledToHidden: (node: SDK.DOMModel.DOMNode) => {
return this.isToggledToHidden(node);
},
isNodeExpanded: (node: SDK.DOMModel.DOMNode) => {
return this.isNodeExpanded(node);
},
isNodeInClipboard: (node: SDK.DOMModel.DOMNode) => {
return this.isNodeInClipboard(node);
},
onCopyOrCut: (isCut: boolean, event: Event) => {
this.onCopyOrCut(isCut, event);
},
onPaste: (event: Event) => {
this.onPaste(event);
},
onSelectNodeAfterEdit:
(wasExpanded: boolean, error: string|null, newNode: SDK.DOMModel.DOMNode|null, moveDirection?: string) => {
this.selectNodeAfterEdit(wasExpanded, error, newNode, moveDirection);
},
nodeToEdit: this.#nodeToEdit,
onInitialEditCompleted: () => {
this.#nodeToEdit = null;
},
attributeToHighlight: this.#attributeToHighlight,
onAttributeHighlighted: () => {
this.#attributeToHighlight = null;
},
nodesWithDirtyAdorners: this.#nodesWithDirtyAdorners,
nodeAdornerVersions: this.#nodeAdornerVersions,
onDirtyAdornersUpdated: () => {
this.#nodesWithDirtyAdorners.clear();
},
forceOpenPopovers: this.#forceOpenPopovers,
onPopoverAdornerToggled: (node: SDK.DOMModel.DOMNode, active: boolean) => {
if (active) {
this.#forceOpenPopovers.add(node);
} else {
this.#forceOpenPopovers.delete(node);
}
this.performUpdate();
},
forceOpenInterests: this.#forceOpenInterests,
onInterestAdornerToggled: (node: SDK.DOMModel.DOMNode, active: boolean) => {
if (active) {
this.#forceOpenInterests.add(node);
} else {
this.#forceOpenInterests.delete(node);
}
this.performUpdate();
},
multilineEditingNode: this.#multilineEditingNode,
onSetMultilineEditing: (editing: MultilineEditorController|null, node: SDK.DOMModel.DOMNode) =>
this.setMultilineEditing(editing, node),
onRevealInTopLayer: (node: SDK.DOMModel.DOMNode) => this.revealInTopLayer(node),
visibleWidth: () => this.visibleWidth,
dragOverNode: this.#dragOverNode,
isValidDragSource: (node: SDK.DOMModel.DOMNode) => this.isValidDragSource(node),
onDragStart: (node: SDK.DOMModel.DOMNode, event: DragEvent, textContent?: string) =>
this.onDragStart(node, event, textContent),
onDragOver: (node: SDK.DOMModel.DOMNode, isClosingTag: boolean, event: DragEvent) =>
this.onDragOver(node, isClosingTag, event),
onDragLeave: (event: DragEvent) => this.onDragLeave(event),
onDrop: (node: SDK.DOMModel.DOMNode, isClosingTag: boolean, event: DragEvent) =>
this.onDrop(node, isClosingTag, event),
onDragEnd: (event: DragEvent) => this.onDragEnd(event),
getTopLayerShortcuts: (doc: SDK.DOMModel.DOMDocument) => this.topLayerShortcuts(doc),
isTopLayerExpanded: (doc: SDK.DOMModel.DOMDocument) => this.isTopLayerExpanded(doc),
onToggleTopLayerExpanded: (doc: SDK.DOMModel.DOMDocument, expanded: boolean) =>
this.setTopLayerExpanded(doc, expanded),
onSelectTopLayerContainer: (_doc: SDK.DOMModel.DOMDocument) => {
this.#selectedTopLayerShortcut = null;
this.selectDOMNode(null);
},
isTopLayerShortcutExpanded: (shortcut: SDK.DOMModel.DOMNodeShortcut) => this.isTopLayerShortcutExpanded(shortcut),
onToggleTopLayerShortcutExpanded: (shortcut: SDK.DOMModel.DOMNodeShortcut, expanded: boolean) =>
this.setTopLayerShortcutExpanded(shortcut, expanded),
selectedTopLayerShortcut: this.#selectedTopLayerShortcut,
onSelectTopLayerShortcut: (shortcut: SDK.DOMModel.DOMNodeShortcut) => {
this.#selectedTopLayerShortcut = shortcut;
this.selectDOMNode(null);
shortcut.deferredNode.highlight();
shortcut.deferredNode.resolve(node => {
if (node) {
this.selectDOMNode(node, true);
}
});
},
onRevealTopLayerShortcut: (shortcut: SDK.DOMModel.DOMNodeShortcut) => {
shortcut.deferredNode.resolve(node => {
if (node) {
this.selectDOMNode(node, true);
node.highlight();
}
});
},
isAdoptedStyleSheetsExpanded: (node: SDK.DOMModel.DOMNode) => this.isAdoptedStyleSheetsExpanded(node),
onToggleAdoptedStyleSheetsExpanded: (node: SDK.DOMModel.DOMNode, expanded: boolean) =>
this.setAdoptedStyleSheetsExpanded(node, expanded),
onSelectAdoptedStyleSheets: (_node: SDK.DOMModel.DOMNode) => {
this.#selectedAdoptedStyleSheet = null;
this.selectDOMNode(null);
},
isAdoptedStyleSheetExpanded: (sheet: SDK.DOMModel.AdoptedStyleSheet) => this.isAdoptedStyleSheetExpanded(sheet),
onToggleAdoptedStyleSheetExpanded: (sheet: SDK.DOMModel.AdoptedStyleSheet, expanded: boolean) =>
this.setAdoptedStyleSheetExpanded(sheet, expanded),
selectedAdoptedStyleSheet: this.#selectedAdoptedStyleSheet,
onSelectAdoptedStyleSheet: (sheet: SDK.DOMModel.AdoptedStyleSheet) => this.#selectAdoptedStyleSheet(sheet),
expandedChildrenLimit: (node: SDK.DOMModel.DOMNode) => this.expandedChildrenLimit(node),
onExpandAllChildren: (node: SDK.DOMModel.DOMNode) => this.expandAllChildren(node),
updateRecordForNode: (node: SDK.DOMModel.DOMNode) => this.#updateRecords.get(node) ?? null,
},
undefined, this.contentElement);
this.#updateRecords.clear();
if (updatedNodes.length > 0) {
this.onElementsTreeUpdated({data: updatedNodes} as Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMNode[]>);
}
}
modelAdded(domModel: SDK.DOMModel.DOMModel): void {
if (!this.#domModelListeners.has(domModel)) {
this.#wireDOMModel(domModel);
}
if (this.isShowing() && !domModel.parentModel()) {
this.#adoptDocument(domModel);
}
this.performUpdate();
}
modelRemoved(domModel: SDK.DOMModel.DOMModel): void {
this.#unwireDOMModel(domModel);
if (this.#rootDOMNode?.domModel() === domModel) {
this.#rootDOMNode = null;
this.#resetTreeState();
}
this.performUpdate();
}
/**
* Makes the document of `domModel` the root of the tree, requesting it
* first if it has not been fetched yet.
*/
#adoptDocument(domModel: SDK.DOMModel.DOMModel): void {
if (this.rootDOMNode?.domModel() === domModel) {
return;
}
const existingDocument = domModel.existingDocument();
if (existingDocument) {
this.rootDOMNode = existingDocument;
this.onDocumentUpdated(domModel);
return;
}
void domModel.requestDocument().then(document => {
if (document && this.isShowing() && this.#domModelListeners.has(domModel)) {
this.rootDOMNode = document;
this.onDocumentUpdated(domModel);
}
});
}
expand(): void {
const selectedNode = this.selectedDOMNode();
if (selectedNode) {
this.setNodeExpanded(selectedNode, true);
}
}
/**
* FIXME: which node is selected should be part of the view input.
*/
selectDOMNodeWithoutReveal(node: SDK.DOMModel.DOMNode): void {
const isSameNode = this.#selectedDOMNode === node && !this.#selectedClosingTag;
if (isSameNode) {
return;
}
this.#selectedDOMNode = node;
this.#selectedClosingTag = false;
this.#clearHighlightedNode();
this.#notifySelectedNodeChanged(node, false);
this.performUpdate();
}
/**
* FIXME: adorners should be part of the view input.
*/
updateNodeAdorners(node: SDK.DOMModel.DOMNode): void {
this.#nodesWithDirtyAdorners.add(node);
const version = (this.#nodeAdornerVersions.get(node) ?? 0) + 1;
this.#nodeAdornerVersions.set(node, version);
this.performUpdate();
}
highlightMatch(node: SDK.DOMModel.DOMNode, query?: string): void {
this.#searchMatchNode = node;
this.#searchMatchQuery = query ?? null;
if (this.selectedDOMNode() !== node) {
this.selectDOMNode(node, /* focus= */ false);
} else {
this.performUpdate();
}
}
hideMatchHighlights(node: SDK.DOMModel.DOMNode): void {
if (this.#searchMatchNode === node) {
this.#searchMatchNode = null;
this.#searchMatchQuery = null;
this.performUpdate();
}
}
async toggleHideElement(node: SDK.DOMModel.DOMNode): Promise<void> {
const wasHidden = this.isToggledToHidden(node);
await node.toggleHideElement();
const isHidden = this.isToggledToHidden(node);
if (isHidden !== wasHidden) {
const changeTracker = this.changeTracker;
Elements.DOMChanges.trackVisibilityToggle(changeTracker, node, buildChangeSelector(changeTracker, node),
isHidden);
}
}
async removeNode(node: SDK.DOMModel.DOMNode): Promise<void> {
if (this.isToggledToHidden(node)) {
// Unhide the node before removing. This avoids inconsistent state if the node is restored via undo.
await node.toggleHideElement();
}
if (node.pseudoType()) {
return;
}
if (!node.parentNode || node.parentNode.nodeType() === Node.DOCUMENT_NODE) {
return;
}
// The selector has to be resolved before the node is detached from the tree.
const changeTracker = this.changeTracker;
const selector = buildChangeSelector(changeTracker, node);
await node.removeNode((err: string|null) => {
if (!err) {
Elements.DOMChanges.trackNodeRemoval(changeTracker, node, selector);
}
});
}
isToggledToHidden(node: SDK.DOMModel.DOMNode): boolean {
return node.isToggledToHidden();
}
#multilineEditing: MultilineEditorController|null = null;
#multilineEditingNode: SDK.DOMModel.DOMNode|null = null;
setMultilineEditing(multilineEditing: MultilineEditorController|null, node?: SDK.DOMModel.DOMNode): void {
this.#multilineEditing = multilineEditing;
this.#multilineEditingNode = multilineEditing ? (node ?? this.#multilineEditingNode) : null;
this.performUpdate();
}
multilineEditing(): MultilineEditorController|null {
return this.#multilineEditing;
}
multilineEditingNode(): SDK.DOMModel.DOMNode|null {
return this.#multilineEditingNode;
}
runPendingUpdates(): void {
this.updateModifiedNodes();
}
override onResize(): void {
super.onResize();
if (this.#multilineEditing) {
this.#multilineEditing.resize();
}
}
override willHide(): void {
super.willHide();
this.#unregisterGlobalListeners();
this.#imagePreviewPopover?.hide();
this.#issuePopoverHelper?.hidePopover();
this.#discardPendingUpdates();
if (this.#multilineEditing) {
this.#multilineEditing.cancel();
}
}
toggleEditAsHTML(node: SDK.DOMModel.DOMNode, startEditing?: boolean, callback?: (() => void)): void {
if (node.pseudoType() || node.isShadowRoot() || node.nodeType() === Node.DOCUMENT_NODE) {
return;
}
const parentNode = node.parentNode;
const index = node.index;
const wasExpanded = this.isNodeExpanded(node);
const editingFinished = (success: boolean): void => {
this.#multilineEditingNode = null;
if (callback) {
callback();
}
if (!success) {
this.performUpdate();
return;
}
Badges.UserBadges.instance().recordAction(Badges.BadgeAction.DOM_ELEMENT_OR_ATTRIBUTE_EDITED);
this.runPendingUpdates();
if (index === undefined) {
return;
}
const children = parentNode?.children();
const newNode = children ? children[index] || parentNode : parentNode;
if (!newNode) {
return;
}
this.selectDOMNode(newNode, true);
if (wasExpanded) {
this.setNodeExpanded(newNode, true);
}
};
if (this.#multilineEditing) {
this.#multilineEditing.commit();
return;
}
if (startEditing === false) {
return;
}
this.#multilineEditingNode = node;
this.#nodeToEdit = {
node,
isEditAsHTML: true,
editAsHTMLCallback: editingFinished,
};
this.performUpdate();
}
duplicateNode(node: SDK.DOMModel.DOMNode): void {
void node.duplicate().then(({error, node: newNode}) => {
if (!error && newNode) {
const changeTracker = this.changeTracker;
Elements.DOMChanges.trackNodeDuplication(changeTracker, newNode, buildChangeSelector(changeTracker, newNode));
}
});
}
nodeBeingDragged(): SDK.DOMModel.DOMNode|null {
return this.#draggedNode;
}
dragOverNode(): {node: SDK.DOMModel.DOMNode, isClosingTag: boolean}|null {
return this.#dragOverNode;
}
isValidDragSource(node: SDK.DOMModel.DOMNode): boolean {
if (this.disableEdits) {
return false;
}
if (!node.parentNode || node.parentNode.nodeType() !== Node.ELEMENT_NODE) {
return false;
}
const nodeName = node.nodeName();
if (nodeName === 'BODY' || nodeName === 'HEAD') {
return false;
}
return true;
}
isValidDragTarget(targetNode: SDK.DOMModel.DOMNode): boolean {
if (!this.#draggedNode) {
return false;
}
if (!targetNode.parentNode || targetNode.parentNode.nodeType() !== Node.ELEMENT_NODE) {
return false;
}
let current: SDK.DOMModel.DOMNode|null = targetNode;
while (current) {
if (current === this.#draggedNode) {
return false;
}
current = current.parentNode;
}
return true;
}
onDragStart(node: SDK.DOMModel.DOMNode, event: DragEvent, textContent?: string): boolean {
const target = (event.target as Node | null);
if (!target) {
return false;
}
const selection = target.getComponentSelection();
if (selection && selection.type === 'Range' && !selection.isCollapsed && selection.toString().length > 0 &&
target.hasSelection()) {
return false;
}
if (UI.UIUtils.isEditing() || this.#multilineEditing) {
event.preventDefault();
return false;
}
if (target.nodeName === 'A') {
return false;
}
if (!this.isValidDragSource(node)) {
return false;
}
this.#draggedNode = node;
this.#draggedNodeWasExpanded = this.isNodeExpanded(node);
if (event.dataTransfer) {
const text = textContent ?? node.nodeName().toLowerCase();
event.dataTransfer.setData('text/plain', text.replace(/\u200b/g, ''));
event.dataTransfer.effectAllowed = 'copyMove';
}
SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(SDK.TargetManager.TargetManager.instance());
return true;
}
onDragOver(node: SDK.DOMModel.DOMNode, isClosingTag: boolean, event: DragEvent): boolean {
if (!this.#draggedNode) {
return false;
}
if (!this.isValidDragTarget(node)) {
return false;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
if (this.#dragOverNode?.node !== node || this.#dragOverNode?.isClosingTag !== isClosingTag) {
this.#dragOverNode = {node, isClosingTag};
this.performUpdate();
}
return true;
}
onDragLeave(event: DragEvent): void {
event.preventDefault();
if (this.#dragOverNode) {
this.#dragOverNode = null;
this.performUpdate();
}
}
onDrop(node: SDK.DOMModel.DOMNode, isClosingTag: boolean, event: DragEvent): void {
event.preventDefault();
if (!this.#draggedNode) {
return;
}
this.moveNode(this.#draggedNode, node, isClosingTag);
this.#draggedNode = null;
this.#dragOverNode = null;
this.performUpdate();
}
onDragEnd(event: DragEvent): void {
event.preventDefault();
this.#draggedNode = null;
this.#dragOverNode = null;
this.performUpdate();
}
moveNode(draggedNode: SDK.DOMModel.DOMNode, targetNode: SDK.DOMModel.DOMNode, isClosingTag: boolean): void {
let parentNode: SDK.DOMModel.DOMNode|null;
let anchorNode: SDK.DOMModel.DOMNode|null;
if (isClosingTag) {
// Drop onto closing tag -> insert as last child.
parentNode = targetNode;
anchorNode = null;
} else {
parentNode = targetNode.parentNode;
anchorNode = targetNode;
}
if (!parentNode) {
return;
}
const wasExpanded = this.#draggedNodeWasExpanded;
draggedNode.moveTo(parentNode, anchorNode, (error, newNode) => {
if (!error && newNode) {
const changeTracker = this.changeTracker;
Elements.DOMChanges.trackNodeDrop(changeTracker, newNode, buildChangeSelector(changeTracker, newNode));
}
this.selectNodeAfterEdit(wasExpanded, error, newNode);
});
}
selectNodeAfterEdit(wasExpanded: boolean, error: string|null, newNode: SDK.DOMModel.DOMNode|null,
moveDirection?: string): void {
if (error || !newNode) {
return;
}
this.selectDOMNode(newNode, /* selectedByUser= */ true);
if (wasExpanded) {
this.setNodeExpanded(newNode, true);
}
if (moveDirection) {
if (newNode.nodeType() === Node.PROCESSING_INSTRUCTION_NODE) {
if (!DOMTreeElementWidget.canEditTextNode(newNode)) {
return;
}
this.#nodeToEdit = {node: newNode, isProcessingInstruction: true};
} else if (moveDirection !== 'forward') {
this.#nodeToEdit = {node: newNode, isNewAttribute: true};
} else {
const attributes = newNode.attributes();
if (attributes.length > 0) {
this.#nodeToEdit = {node: newNode, attributeName: attributes[0].name};
} else {
this.#nodeToEdit = {node: newNode, isNewAttribute: true};
}
}
this.performUpdate();
}
}
topLayerShortcuts(document: SDK.DOMModel.DOMDocument): SDK.DOMModel.DOMNodeShortcut[] {
return this.#topLayerShortcutsByDocument.get(document) ?? [];
}
isTopLayerExpanded(document: SDK.DOMModel.DOMDocument): boolean {
return this.#topLayerContainerExpandedByDocument.get(document) ?? false;
}
setTopLayerExpanded(document: SDK.DOMModel.DOMDocument, expanded: boolean): void {
this.#topLayerContainerExpandedByDocument.set(document, expanded);
this.performUpdate();
}
isTopLayerShortcutExpanded(shortcut: SDK.DOMModel.DOMNodeShortcut): boolean {
return this.#topLayerShortcutExpanded.get(shortcut) ?? false;
}
setTopLayerShortcutExpanded(shortcut: SDK.DOMModel.DOMNodeShortcut, expanded: boolean): void {
this.#topLayerShortcutExpanded.set(shortcut, expanded);
this.performUpdate();
}
#onTopLayerElementsChanged(
event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.EventTypes['TopLayerElementsChanged']>): void {
this.#topLayerShortcutsByDocument.set(event.data.document, event.data.documentShortcuts);
this.performUpdate();
}
revealInTopLayer(node: SDK.DOMModel.DOMNode): void {
const document = node.ownerDocument;
if (!document) {
return;
}
const shortcuts = this.topLayerShortcuts(document);
const findShortcut = (list: SDK.DOMModel.DOMNodeShortcut[]): SDK.DOMModel.DOMNodeShortcut|null => {
for (const sc of list) {
if (sc.deferredNode.backendNodeId() === node.backendNodeId()) {
return sc;
}
const found = findShortcut(sc.childShortcuts);
if (found) {
return found;
}
}
return null;
};
const shortcut = findShortcut(shortcuts);
if (shortcut) {
this.#topLayerContainerExpandedByDocument.set(document, true);
this.#selectedTopLayerShortcut = shortcut;
this.performUpdate();
}
}
isAdoptedStyleSheetsExpanded(node: SDK.DOMModel.DOMNode): boolean {
return this.#adoptedStyleSheetsExpandedByNode.get(node) ?? false;
}
setAdoptedStyleSheetsExpanded(node: SDK.DOMModel.DOMNode, expanded: boolean): void {
this.#adoptedStyleSheetsExpandedByNode.set(node, expanded);
this.performUpdate();
}
isAdoptedStyleSheetExpanded(sheet: SDK.DOMModel.AdoptedStyleSheet): boolean {
return this.#adoptedStyleSheetExpanded.get(sheet) ?? false;
}
setAdoptedStyleSheetExpanded(sheet: SDK.DOMModel.AdoptedStyleSheet, expanded: boolean): void {
this.#adoptedStyleSheetExpanded.set(sheet, expanded);
this.performUpdate();
}
highlightAdoptedStyleSheet(adoptedStyleSheet: SDK.DOMModel.AdoptedStyleSheet): void {
const parent = adoptedStyleSheet.parent;
if (!parent) {
return;
}
for (let current: (SDK.DOMModel.DOMNode|null) = parent; current; current = current.parentNode) {
this.#expandedNodes.add(current);
}
this.#adoptedStyleSheetsExpandedByNode.set(parent, true);
this.#selectAdoptedStyleSheet(adoptedStyleSheet);
}
#selectAdoptedStyleSheet(sheet: SDK.DOMModel.AdoptedStyleSheet): void {
// Clear the DOM node selection first: selectDOMNode() also resets the
// selected adopted style sheet.
this.selectDOMNode(null);
this.#selectedAdoptedStyleSheet = sheet;
this.performUpdate();
}
addNewAttribute(node: SDK.DOMModel.DOMNode): void {
if (UI.UIUtils.isEditing()) {
return;
}
if (node.nodeType() !== Node.ELEMENT_NODE || node.isShadowRoot() || node.ancestorUserAgentShadowRoot()) {
return;
}
this.#nodeToEdit = {node, isNewAttribute: true};
this.performUpdate();
}
startEditingTextNode(node: SDK.DOMModel.DOMNode): void {
if (UI.UIUtils.isEditing() || !DOMTreeElementWidget.canEditTextNode(node)) {
return;
}
this.#nodeToEdit = {node, isTextNode: true};
this.performUpdate();
}
startEditing(node: SDK.DOMModel.DOMNode, attributeName?: string): void {
if (UI.UIUtils.isEditing()) {
return;
}
const nodeType = node.nodeType();
if (nodeType === Node.ELEMENT_NODE) {
if (node.isShadowRoot() || node.ancestorUserAgentShadowRoot()) {
return;
}
if (attributeName) {
this.#nodeToEdit = {node, attributeName};
} else {
const attributes = node.attributes();
if (attributes.length > 0) {
this.#nodeToEdit = {node, attributeName: attributes[0].name};
} else {
this.#nodeToEdit = {node, isNewAttribute: true};
}
}
this.performUpdate();
} else if (nodeType === Node.TEXT_NODE) {
if (!DOMTreeElementWidget.canEditTextNode(node)) {
return;
}
this.#nodeToEdit = {node, isTextNode: true};
this.performUpdate();
} else if (nodeType === Node.PROCESSING_INSTRUCTION_NODE) {
if (!DOMTreeElementWidget.canEditTextNode(node)) {
return;
}
this.#nodeToEdit = {node, isProcessingInstruction: true};
this.performUpdate();
}
}
onKeyDown(event: KeyboardEvent): boolean {
if (UI.UIUtils.isEditing()) {
return false;
}
const node = this.selectedDOMNode();
if (!node) {
return false;
}
if (UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event) && node.parentNode) {
if (event.key === 'ArrowUp' && node.previousSibling) {
this.#moveNodeAmongSiblings(node, node.previousSibling, /* directionUp= */ true);
event.consume(true);
return true;
}
if (event.key === 'ArrowDown' && node.nextSibling) {
this.#moveNodeAmongSiblings(node, node.nextSibling.nextSibling, /* directionUp= */ false);
event.consume(true);
return true;
}
}
if (event.key === 'Enter') {
this.startEditing(node);
event.consume(true);
return true;
}
if (event.key === 'F2') {
this.toggleEditAsHTML(node);
event.consume(true);
return true;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
void this.removeNode(node);
event.consume(true);
return true;
}
if (event.key === 'h' || event.key === 'H') {
void this.toggleHideElement(node);
event.consume(true);
return true;
}
return false;
}
/** Moves `node` within its parent so that it ends up right before `anchor` (or last, if `anchor` is null). */
#moveNodeAmongSiblings(node: SDK.DOMModel.DOMNode, anchor: SDK.DOMModel.DOMNode|null, directionUp: boolean): void {
const parentNode = node.parentNode;
if (!parentNode) {
return;
}
const wasExpanded = this.isNodeExpanded(node);
node.moveTo(parentNode, anchor, (error, newNode) => {
if (!error && newNode) {
const changeTracker = this.changeTracker;
Elements.DOMChanges.trackNodeMove(changeTracker, newNode, buildChangeSelector(changeTracker, newNode),
directionUp);
}
this.selectNodeAfterEdit(wasExpanded, error, newNode);
});
}
#clipboardData: ClipboardData|null = null;
clipboardData(): ClipboardData|null {
return this.#clipboardData;
}
setClipboardData(data: ClipboardData|null): void {
this.#clipboardData = data;
this.requestUpdate();
}
resetClipboardIfNeeded(removedNode: SDK.DOMModel.DOMNode): void {
if (this.#clipboardData?.node === removedNode) {
this.setClipboardData(null);
}
}
isNodeInClipboard(node: SDK.DOMModel.DOMNode): boolean {
return Boolean(this.#clipboardData?.isCut && this.#clipboardData?.node === node);
}
async copyOuterHTML(node: SDK.DOMModel.DOMNode, includeShadowRoots = false): Promise<void> {
const outerHTML = await node.getOuterHTML(includeShadowRoots);
if (outerHTML !== null) {
UI.UIUtils.copyTextToClipboard(outerHTML);
}
}
copyCSSPath(node: SDK.DOMModel.DOMNode): void {
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(cssPath(node, true));
}
copyJSPath(node: SDK.DOMModel.DOMNode): void {
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(jsPath(node, true));
}
async copyXPath(node: SDK.DOMModel.DOMNode, optimized = true): Promise<void> {
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(await xPath(node, optimized));
}
async copyFullXPath(node: SDK.DOMModel.DOMNode): Promise<void> {
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(await xPath(node, false));
}
async copyStyles(node: SDK.DOMModel.DOMNode): Promise<void> {
const cssModel = node.domModel().cssModel();
const cascade = await cssModel.cachedMatchedCascadeForNode(node);
if (!cascade) {
return;
}
const indent = Common.Settings.Settings.instance().resolve(SDK.SDKSettings.textEditorIndentSettingDescriptor).get();
const lines: string[] = [];
for (const style of cascade.nodeStyles().reverse()) {
for (const property of style.leadingProperties()) {
if (!property.parsedOk || property.disabled || !property.activeInStyle() || property.implicit) {
continue;
}
if (cascade.isInherited(style) && !SDK.CSSMetadata.cssMetadata().isPropertyInherited(property.name)) {
continue;
}
if (style.parentRule?.isUserAgent()) {
continue;
}
if (cascade.propertyState(property) !== SDK.CSSMatchedStyles.PropertyState.ACTIVE) {
continue;
}
lines.push(`${indent}${property.name}: ${property.value};`);
}
}
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(lines.join('\n'));
}
performCopyOrCut(isCut: boolean, node: SDK.DOMModel.DOMNode|null, includeShadowRoots = false): void {
if (!node) {
return;
}
if (isCut && (node.isShadowRoot() || node.ancestorUserAgentShadowRoot())) {
return;
}
void this.copyOuterHTML(node, includeShadowRoots);
this.setClipboardData({node, isCut});
}
canPaste(targetNode: SDK.DOMModel.DOMNode): boolean {
if (targetNode.isShadowRoot() || targetNode.ancestorUserAgentShadowRoot()) {
return false;
}
if (!this.#clipboardData) {
return false;
}
const node = this.#clipboardData.node;
if (this.#clipboardData.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.#clipboardData) {
return;
}
const wasExpanded = this.isNodeExpanded(this.#clipboardData.node);
const clipboardNode = this.#clipboardData.node;
if (this.#clipboardData.isCut) {
clipboardNode.moveTo(targetNode, null, (error, newNode) => {
if (!error && newNode) {
const changeTracker = this.changeTracker;
Elements.DOMChanges.trackNodePaste(changeTracker, newNode, buildChangeSelector(changeTracker, newNode),
/* isCut= */ true);
}
this.selectNodeAfterEdit(wasExpanded, error, newNode);
});
this.setClipboardData(null);
} else {
clipboardNode.copyTo(targetNode, null, (error, newNode) => {
if (!error && newNode) {
const changeTracker = this.changeTracker;
Elements.DOMChanges.trackNodePaste(changeTracker, newNode, buildChangeSelector(changeTracker, newNode),
/* isCut= */ false);
}
this.selectNodeAfterEdit(wasExpanded, error, newNode);
});
}
}
onCopyOrCut(isCut: boolean, event: Event): void {
this.setClipboardData(null);
// @ts-expect-error this bound in the main entry point
const originalEvent = (event as CustomEvent)['original'] || event;
if (!originalEvent?.target) {
return;
}
// Don't prevent the normal copy if the user has a selection.
if (originalEvent.target instanceof Node && (originalEvent.target as HTMLElement).hasSelection?.()) {
return;
}
// Do not interfere with text editing.
if (UI.UIUtils.isEditing()) {
return;
}
const targetNode = this.selectedDOMNode();
if (!targetNode) {
return;
}
if (originalEvent.clipboardData) {
originalEvent.clipboardData.clearData();
}
(event as {handled?: boolean}).handled = true;
this.performCopyOrCut(isCut, targetNode);
}
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 as {handled?: boolean}).handled = true;
this.pasteNode(targetNode);
}
/**
* FIXME: used to determine focus state, probably we can have a better
* way to do it.
*/
empty(): boolean {
return !this.#rootDOMNode;
}
override focus(): void {
if (this.#isFocusing) {
return;
}
this.#isFocusing = true;
try {
super.focus();
this.contentElement.querySelector('devtools-tree')?.focus();
} finally {
this.#isFocusing = false;
}
}
override wasShown(): void {
super.wasShown();
this.#registerGlobalListeners();
if (this.#lastObservedShowHTMLCommentsSetting !== this.#showHTMLCommentsSetting.get()) {
// The setting changed while the tree was hidden.
this.#onShowHTMLCommentsChange();
return;
}
this.performUpdate();
}
override wasHidden(): void {
super.wasHidden();
this.#discardPendingUpdates();
}
override detach(overrideHideOnDetach?: boolean): void {
super.detach(overrideHideOnDetach);
this.#discardPendingUpdates();
}
hideImagePreview(): void {
this.#imagePreviewPopover?.hide();
}
imagePreviewPopoverForTest(): ImagePreviewPopover|undefined {
return this.#imagePreviewPopover;
}
issuePopoverHelperForTest(): UI.PopoverHelper.PopoverHelper|undefined {
return this.#issuePopoverHelper;
}
override show(parentElement: Element, insertBefore?: Node|null, suppressOrphanWidgetError = false): void {
this.performUpdate();
// Only follow the inspected page's document when wired to DOM models via
// modelAdded() (the Elements panel). Embedders that set an explicit
// rootDOMNode (e.g. the Console) keep it, even if that node lives in a
// different (e.g. OOPIF) DOM model than the scoped main one.
const domModels = SDK.TargetManager.TargetManager.instance().models(SDK.DOMModel.DOMModel, {scoped: true});
for (const domModel of domModels) {
if (!domModel.parentModel() && this.#domModelListeners.has(domModel)) {
this.#adoptDocument(domModel);
}
}
super.show(parentElement, insertBefore, suppressOrphanWidgetError);
}
}
export interface MultilineEditorController {
cancel: () => void;
commit: () => void;
resize: () => void;
}
export interface ClipboardData {
node: SDK.DOMModel.DOMNode;
isCut: boolean;
}