UNPKG

chrome-devtools-frontend

Version:
1,185 lines (1,108 loc) 123 kB
// Copyright 2021 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable @devtools/no-imperative-dom-api */ /* eslint-disable @devtools/no-lit-render-outside-of-view */ /* * Copyright (C) 2007, 2008 Apple Inc. All rights reserved. * Copyright (C) 2008 Matt Lilek <webkit@mattlilek.com> * Copyright (C) 2009 Joseph Pecoraro * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of * its contributors may be used to endorse or promote products derived * from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ import '../../ui/components/adorners/adorners.js'; import '../../ui/components/buttons/buttons.js'; import * as Common from '../../core/common/common.js'; import * as Host from '../../core/host/host.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as Platform from '../../core/platform/platform.js'; import * as Root from '../../core/root/root.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as Protocol from '../../generated/protocol.js'; import * as AIAssistance from '../../models/ai_assistance/ai_assistance.js'; import * as Badges from '../../models/badges/badges.js'; import type * as Elements from '../../models/elements/elements.js'; import type * as IssuesManager from '../../models/issues_manager/issues_manager.js'; import * as TextUtils from '../../models/text_utils/text_utils.js'; import * as Workspace from '../../models/workspace/workspace.js'; import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js'; import type * as Adorners from '../../ui/components/adorners/adorners.js'; import * as CodeHighlighter from '../../ui/components/code_highlighter/code_highlighter.js'; import * as Highlighting from '../../ui/components/highlighting/highlighting.js'; import * as TextEditor from '../../ui/components/text_editor/text_editor.js'; import {Icon} from '../../ui/kit/kit.js'; import * as Components from '../../ui/legacy/components/utils/utils.js'; import * as UI from '../../ui/legacy/legacy.js'; import * as Lit from '../../ui/lit/lit.js'; import type {DirectiveResult} from '../../ui/lit/lit.js'; import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import * as PanelsCommon from '../common/common.js'; import * as Emulation from '../emulation/emulation.js'; import * as Media from '../media/media.js'; import * as ElementsComponents from './components/components.js'; import {canGetJSPath, cssPath, jsPath, xPath} from './DOMPath.js'; import {getElementIssueDetails} from './ElementIssueUtils.js'; import {ElementsPanel} from './ElementsPanel.js'; import {type ElementsTreeOutline, MappedCharToEntity} from './ElementsTreeOutline.js'; import {ImagePreviewPopover} from './ImagePreviewPopover.js'; import {getRegisteredDecorators, type MarkerDecorator, type MarkerDecoratorRegistration} from './MarkerDecorator.js'; const {html, nothing, render, Directives: {ref, repeat}} = Lit; const {animateOn} = UI.UIUtils; const UIStrings = { /** * @description Title for Ad adorner. This element is marked as advertisement element. */ thisElementWasIdentifiedAsAnAd: 'This element was identified as an ad', /** * @description Title of a section in the Ad adorner tooltip. Lists the ad script(s) responsible for generating this element. */ creatorAdScriptAncestry: 'Creator ad script ancestry', /** * @description Title of a section in the Ad adorner tooltip. The filter list rule that flagged the root script in 'Creator ad script ancestry' as an ad. */ rootScriptFilterListRule: 'Root script filter list rule', /** * @description Title of a section in the Ad adorner tooltip. The filter list rule that flagged the element's current resource. */ filterListRule: 'Filter list rule', /** * @description Title of a section in the Ad adorner tooltip. This element was identified as an ad, but no provenance data is available. */ noProvenanceAvailable: 'No provenance data is available', /** * @description A context menu item in the Elements panel. Force is used as a verb, indicating intention to make the state change. */ forceState: 'Force state', /** * @description Hint element title in Elements Tree Element of the Elements panel * @example {0} PH1 */ useSInTheConsoleToReferToThis: 'Use {PH1} in the console to refer to this element.', /** * @description A context menu item in the Elements Tree Element of the Elements panel */ addAttribute: 'Add attribute', /** * @description Text to modify the attribute of an item */ editAttribute: 'Edit attribute', /** * @description Text to focus on something */ focus: 'Focus', /** * @description Text to scroll the displayed content into view */ scrollIntoView: 'Scroll into view', /** * @description A context menu item in the Elements Tree Element of the Elements panel */ editText: 'Edit text', /** * @description A context menu item in the Elements Tree Element of the Elements panel */ editAsHtml: 'Edit as HTML', /** * @description A context menu item in the Elements Tree Element of the Elements panel */ editData: 'Edit data', /** * @description Text to cut an element, cut should be used as a verb */ cut: 'Cut', /** * @description Text for copying, copy should be used as a verb */ copy: 'Copy', /** * @description Text to paste an element, paste should be used as a verb */ paste: 'Paste', /** * @description Text in Elements Tree Element of the Elements panel, copy should be used as a verb */ copyOuterhtml: 'Copy outerHTML', /** * @description Text in Elements Tree Element of the Elements panel, copy should be used as a verb */ copySelector: 'Copy `selector`', /** * @description Text in Elements Tree Element of the Elements panel */ copyJsPath: 'Copy JS path', /** * @description Text in Elements Tree Element of the Elements panel, copy should be used as a verb */ copyStyles: 'Copy styles', /** * @description Text in Elements Tree Element of the Elements panel, copy should be used as a verb */ copyXpath: 'Copy XPath', /** * @description Text in Elements Tree Element of the Elements panel, copy should be used as a verb */ copyFullXpath: 'Copy full XPath', /** * @description Text in Elements Tree Element of the Elements panel, copy should be used as a verb */ copyElement: 'Copy element', /** * @description A context menu item in the Elements Tree Element of the Elements panel */ duplicateElement: 'Duplicate element', /** * @description Text to hide an element */ hideElement: 'Hide element', /** * @description A context menu item in the Elements Tree Element of the Elements panel */ deleteElement: 'Delete element', /** * @description Text to expand something recursively */ expandRecursively: 'Expand recursively', /** * @description Text to collapse children of a parent group */ collapseChildren: 'Collapse children', /** * @description Title of an action in the emulation tool to capture node screenshot */ captureNodeScreenshot: 'Capture node screenshot', /** * @description Title of a context menu item. When clicked DevTools goes to the Application panel and shows this specific iframe's details */ showFrameDetails: 'Show `iframe` details', /** * @description Text in Elements Tree Element of the Elements panel */ valueIsTooLargeToEdit: '<value is too large to edit>', /** * @description Element text content in Elements Tree Element of the Elements panel */ children: 'Children:', /** * @description ARIA label for Elements Tree adorners */ enableGridMode: 'Enable grid mode', /** * @description ARIA label for Elements Tree adorners */ disableGridMode: 'Disable grid mode', /** * @description ARIA label for Elements Tree adorners */ /** * @description ARIA label for Elements Tree adorners */ enableGridLanesMode: 'Enable grid-lanes mode', /** * @description ARIA label for Elements Tree adorners */ disableGridLanesMode: 'Disable grid-lanes mode', /** * @description ARIA label for an elements tree adorner */ forceOpenPopover: 'Keep this popover open', /** * @description ARIA label for an elements tree adorner */ stopForceOpenPopover: 'Stop keeping this popover open', /** * @description Label of the adorner for flex elements in the Elements panel */ enableFlexMode: 'Enable flex mode', /** * @description Label of the adorner for flex elements in the Elements panel */ disableFlexMode: 'Disable flex mode', /** * @description Label of an adorner in the Elements panel. When clicked, it enables * the overlay showing CSS scroll snapping for the current element. */ enableScrollSnap: 'Enable scroll-snap overlay', /** * @description Label of an adorner in the Elements panel. When clicked, it disables * the overlay showing CSS scroll snapping for the current element. */ disableScrollSnap: 'Disable scroll-snap overlay', /** * @description Label of an adorner in the Elements panel. When clicked, it enables * the overlay showing the container overlay for the current element. */ enableContainer: 'Enable container overlay', /** * @description Label of an adorner in the Elements panel. When clicked, it disables * the overlay showing container for the current element. */ disableContainer: 'Disable container overlay', /** * @description Label of an adorner in the Elements panel. When clicked, it forces * the element into applying its starting-style rules. */ enableStartingStyle: 'Enable @starting-style mode', /** * @description Label of an adorner in the Elements panel. When clicked, it no longer * forces the element into applying its starting-style rules. */ disableStartingStyle: 'Disable @starting-style mode', /** * @description Label of an adorner in the Elements panel. When clicked, it redirects * to the Media Panel. */ openMediaPanel: 'Jump to Media panel', /** * @description Text of a tooltip to redirect to another element in the Elements panel */ showPopoverTarget: 'Show element associated with the `popovertarget` attribute', /** * @description Text of a tooltip to redirect to another element in the Elements panel, associated with the `interesttarget` attribute */ showInterestTarget: 'Show element associated with the `interesttarget` attribute', /** * @description Text of a tooltip to redirect to another element in the Elements panel, associated with the `commandfor` attribute */ showCommandForTarget: 'Show element associated with the `commandfor` attribute', /** * @description Text of the tooltip for scroll adorner. */ elementHasScrollableOverflow: 'This element has a scrollable overflow', /** * @description Text of a context menu item to redirect to the AI assistance panel and to start a chat. */ startAChat: 'Start a chat', /** * @description Label of an adorner next to the html node in the Elements panel. */ viewSourceCode: 'View source code', /** * @description Context menu item in Elements panel to assess visibility of an element via AI. */ assessVisibility: 'Assess visibility', /** * @description Context menu item in Elements panel to center an element via AI. */ centerElement: 'Center element', /** * @description Context menu item in Elements panel to wrap flex items via AI. */ wrapTheseItems: 'Wrap these items', /** * @description Context menu item in Elements panel to distribute flex items evenly via AI. */ distributeItemsEvenly: 'Distribute items evenly', /** * @description Context menu item in Elements panel to explain flexbox via AI. */ explainFlexbox: 'Explain flexbox', /** * @description Context menu item in Elements panel to align grid items via AI. */ alignItems: 'Align items', /** * @description Context menu item in Elements panel to add padding/gap to grid via AI. */ addPadding: 'Add padding', /** * @description Context menu item in Elements panel to explain grid layout via AI. */ explainGridLayout: 'Explain grid layout', /** * @description Context menu item in Elements panel to find grid definition for a subgrid item via AI. */ findGridDefinition: 'Find grid definition', /** * @description Context menu item in Elements panel to change parent grid properties for a subgrid item via AI. */ changeParentProperties: 'Change parent properties', /** * @description Context menu item in Elements panel to explain subgrids via AI. */ explainSubgrids: 'Explain subgrids', /** * @description Context menu item in Elements panel to remove scrollbars via AI. */ removeScrollbars: 'Remove scrollbars', /** * @description Context menu item in Elements panel to style scrollbars via AI. */ styleScrollbars: 'Style scrollbars', /** * @description Context menu item in Elements panel to explain scrollbars via AI. */ explainScrollbars: 'Explain scrollbars', /** * @description Context menu item in Elements panel to explain container queries via AI. */ explainContainerQueries: 'Explain container queries', /** * @description Context menu item in Elements panel to explain container types via AI. */ explainContainerTypes: 'Explain container types', /** * @description Context menu item in Elements panel to explain container context via AI. */ explainContainerContext: 'Explain container context', /** * @description Link text content in Elements Tree Outline of the Elements panel. When clicked, it "reveals" the true location of an element. */ reveal: 'reveal', } as const; const str_ = i18n.i18n.registerUIStrings('panels/elements/ElementsTreeElement.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const enum TagType { OPENING = 'OPENING_TAG', CLOSING = 'CLOSING_TAG', } interface OpeningTagContext { tagType: TagType.OPENING; canAddAttributes: boolean; } interface ClosingTagContext { tagType: TagType.CLOSING; } export type TagTypeContext = OpeningTagContext|ClosingTagContext; export function isOpeningTag(context: TagTypeContext): context is OpeningTagContext { return context.tagType === TagType.OPENING; } export interface ViewInput { node: SDK.DOMModel.DOMNode|null; isClosingTag: boolean; expanded: boolean; isExpandable: boolean; isXMLMimeType: boolean; updateRecord: Elements.ElementUpdateRecord.ElementUpdateRecord|null; onHighlightSearchResults: () => void; onExpand: () => void; containerAdornerActive: boolean; flexAdornerActive: boolean; gridAdornerActive: boolean; popoverAdornerActive: boolean; adProvenance?: Protocol.Network.AdProvenance; target?: SDK.Target.Target; adTooltipId: string; showContainerAdorner: boolean; containerType?: string; showFlexAdorner: boolean; showGridAdorner: boolean; showGridLanesAdorner: boolean; showMediaAdorner: boolean; showPopoverAdorner: boolean; showTopLayerAdorner: boolean; isSubgrid: boolean; showViewSourceAdorner: boolean; showScrollAdorner: boolean; showScrollSnapAdorner: boolean; topLayerIndex: number; scrollSnapAdornerActive: boolean; onGutterClick: (e: Event) => void; onContainerAdornerClick: (e: Event) => void; onFlexAdornerClick: (e: Event) => void; onGridAdornerClick: (e: Event) => void; onMediaAdornerClick: (e: Event) => void; onPopoverAdornerClick: (e: Event) => void; onScrollSnapAdornerClick: (e: Event) => void; onTopLayerAdornerClick: (e: Event) => void; onViewSourceAdornerClick: () => void; onSlotAdornerClick: (e: Event) => void; showSlotAdorner: boolean; slotName?: string; showStartingStyleAdorner: boolean; startingStyleAdornerActive: boolean; onStartingStyleAdornerClick: (e: Event) => void; isHovered: boolean; isSelected: boolean; showAiButton: boolean; aiButtonTitle?: string; onAiButtonClick: (e: Event) => void; decorations: Decoration[]; descendantDecorations: Decoration[]; decorationsTooltip: string; indent: number; } export interface ViewOutput { contentElement?: HTMLElement; } export function adornerRef(): DirectiveResult<typeof Lit.Directives.RefDirective> { let adorner: Adorners.Adorner.Adorner|undefined; return ref(el => { if (adorner) { ElementsPanel.instance().deregisterAdorner(adorner); } adorner = el as Adorners.Adorner.Adorner; if (adorner) { if (ElementsPanel.instance().isAdornerEnabled(adorner.name)) { adorner.show(); } else { adorner.hide(); } ElementsPanel.instance().registerAdorner(adorner); } }); } export interface Decoration { title: string; color: string; } const DOM_UPDATE_ANIMATION_CLASS_NAME = 'dom-update-highlight'; function handleAdornerKeydown(cb: (event: Event) => void): (event: KeyboardEvent) => void { return (event: KeyboardEvent) => { if (event.code === 'Enter' || event.code === 'Space') { cb(event); event.preventDefault(); event.stopPropagation(); } }; } function renderTitle( node: SDK.DOMModel.DOMNode, isClosingTag: boolean, expanded: boolean, isExpandable: boolean, isXMLMimeType: boolean, updateRecord: Elements.ElementUpdateRecord.ElementUpdateRecord|null, onUpdateSearchHighlight: () => void, onExpand: () => void, ): Lit.LitTemplate { switch (node.nodeType()) { case Node.ATTRIBUTE_NODE: return renderAttribute({name: node.name as string, value: node.value as string}, updateRecord, true, node); case Node.ELEMENT_NODE: { if (node.pseudoType()) { let pseudoElementName = node.nodeName(); const pseudoIdentifier = node.pseudoIdentifier(); if (pseudoIdentifier) { pseudoElementName += `(${pseudoIdentifier})`; } return html`<span class="webkit-html-pseudo-element">${pseudoElementName}</span>\u200B`; } const tagName = node.nodeNameInCorrectCase(); if (isClosingTag) { return renderTag(node, tagName, true, expanded, true, updateRecord); } const openingTag = renderTag(node, tagName, false, expanded, false, updateRecord); if (isExpandable) { if (!expanded) { return html`${openingTag}<devtools-elements-tree-expand-button .data=${ {clickHandler: onExpand} as ElementsComponents.ElementsTreeExpandButton .ElementsTreeExpandButtonData}></devtools-elements-tree-expand-button><span style="font-size: 0;" >…</span>\u200B${renderTag(node, tagName, true, expanded, false, updateRecord)}`; } return openingTag; } if (ElementsTreeElement.canShowInlineText(node)) { const firstChild = node.firstChild; if (!firstChild) { throw new Error('ElementsTreeElement._nodeTitleInfo expects node.firstChild to be defined.'); } const result = convertUnicodeCharsToHTMLEntities(firstChild.nodeValue()); const textContent = Platform.StringUtilities.collapseWhitespace(result.text); const renderTextNode = ref(el => { if (el) { el.textContent = textContent; Highlighting.highlightRangesWithStyleClass(el, result.entityRanges, 'webkit-html-entity-value'); } }); return html`${openingTag}<span class="webkit-html-text-node" jslog=${ VisualLogging.value('text-node').track({change: true, dblclick: true})} ${ animateOn( Boolean((updateRecord?.hasChangedChildren() || updateRecord?.isCharDataModified())), DOM_UPDATE_ANIMATION_CLASS_NAME)} ${renderTextNode}></span>\u200B${ renderTag(node, tagName, true, expanded, false, updateRecord)}`; } if (isXMLMimeType || !ForbiddenClosingTagElements.has(tagName)) { return html`${openingTag}${renderTag(node, tagName, true, expanded, false, updateRecord)}`; } return openingTag; } case Node.TEXT_NODE: { if (node.parentNode && node.parentNode.nodeName().toLowerCase() === 'script') { const text = node.nodeValue(); const highlightNode = ref(el => { if (el) { el.textContent = text.replace(/^[\n\r]+|\s+$/g, ''); void CodeHighlighter.CodeHighlighter.highlightNode(el, 'text/javascript').then(onUpdateSearchHighlight); } }); return html`<span class="webkit-html-text-node webkit-html-js-node" jslog=${ VisualLogging.value('script-text-node').track({change: true, dblclick: true})} ${highlightNode}></span>`; } if (node.parentNode && node.parentNode.nodeName().toLowerCase() === 'style') { const text = node.nodeValue(); const highlightNode = ref(el => { if (el) { el.textContent = text.replace(/^[\n\r]+|\s+$/g, ''); void CodeHighlighter.CodeHighlighter.highlightNode(el, 'text/css').then(onUpdateSearchHighlight); } }); return html`<span class="webkit-html-text-node webkit-html-css-node" jslog=${ VisualLogging.value('css-text-node').track({change: true, dblclick: true})} ${highlightNode}></span>`; } const result = convertUnicodeCharsToHTMLEntities(node.nodeValue()); const textContent = Platform.StringUtilities.collapseWhitespace(result.text); const renderTextNode = ref(el => { if (el) { el.textContent = textContent; Highlighting.highlightRangesWithStyleClass(el, result.entityRanges, 'webkit-html-entity-value'); } }); return html`"<span class="webkit-html-text-node" jslog=${VisualLogging.value('text-node').track({ change: true, dblclick: true })} ${animateOn(Boolean(updateRecord?.isCharDataModified()), DOM_UPDATE_ANIMATION_CLASS_NAME)} ${ renderTextNode}></span>"`; } case Node.COMMENT_NODE: { return html`<span class="webkit-html-comment">&lt;!--${node.nodeValue()}--&gt;</span>`; } case Node.DOCUMENT_TYPE_NODE: { let doctype = '<!DOCTYPE ' + node.nodeName(); if (node.publicId) { doctype += ' PUBLIC "' + node.publicId + '"'; if (node.systemId) { doctype += ' "' + node.systemId + '"'; } } else if (node.systemId) { doctype += ' SYSTEM "' + node.systemId + '"'; } if (node.internalSubset) { doctype += ' [' + node.internalSubset + ']'; } doctype += '>'; return html`<span class="webkit-html-doctype">${doctype}</span>`; } case Node.CDATA_SECTION_NODE: { return html`<span class="webkit-html-text-node">&lt;![CDATA[${node.nodeValue()}]]&gt;</span>`; } case Node.DOCUMENT_NODE: { const text = (node as SDK.DOMModel.DOMDocument).documentURL; return html`<span>#document (<span>${Components.Linkifier.Linkifier.renderLinkifiedUrl(text, { text, preventClick: true, showColumnNumber: false, inlineFrameIndex: 0, })}</span>)</span>`; } case Node.DOCUMENT_FRAGMENT_NODE: { return html`<span class="webkit-html-fragment">${ Platform.StringUtilities.collapseWhitespace(node.nodeNameInCorrectCase())}</span>`; } case Node.PROCESSING_INSTRUCTION_NODE: { const nodeValue = node.nodeValue(); const maybeSpace = nodeValue ? ' ' : ''; return html`<span class="webkit-html-processing-instruction">&lt;?<span class="webkit-html-tag-name" jslog=${VisualLogging.value('tag-name').track({change: true, dblclick: true})}>${ node.nodeName()}</span>${maybeSpace}<span class="webkit-html-processing-instruction-value" jslog=${ VisualLogging.value('processing-instruction-value').track({ change: true, dblclick: true, })}>${nodeValue}</span>?&gt;</span>`; } default: { return html`${Platform.StringUtilities.collapseWhitespace(node.nodeNameInCorrectCase())}`; } } } function renderLinkifiedSrcset(tokens: Common.Srcset.Token[], node: SDK.DOMModel.DOMNode): Lit.TemplateResult { return html`${repeat(tokens, token => { switch (token.type) { case Common.Srcset.TokenType.URL: return renderLinkifiedValue(token.value, node); case Common.Srcset.TokenType.LITERAL: return token.value; } })}`; } const closingPunctuationRegex = /[\/;:\)\]\}]/g; // FIXME: this should be made declarative next. function setValueWithEntities(element: Element, value: string): void { let highlightIndex = 0; let highlightCount = 0; let additionalHighlightOffset = 0; const result = convertUnicodeCharsToHTMLEntities(value); highlightCount = result.entityRanges.length; const newValue = result.text.replace(closingPunctuationRegex, (match, replaceOffset) => { while (highlightIndex < highlightCount && result.entityRanges[highlightIndex].offset < replaceOffset) { result.entityRanges[highlightIndex].offset += additionalHighlightOffset; ++highlightIndex; } additionalHighlightOffset += 1; return match + '\u200B'; }); while (highlightIndex < highlightCount) { result.entityRanges[highlightIndex].offset += additionalHighlightOffset; ++highlightIndex; } element.setTextContentTruncatedIfNeeded(newValue); Highlighting.highlightRangesWithStyleClass(element, result.entityRanges, 'webkit-html-entity-value'); } function renderLinkifiedValue(value: string, node: SDK.DOMModel.DOMNode): Lit.TemplateResult { const rewrittenHref = node ? node.resolveURL(value) : null; if (rewrittenHref === null) { return html`<span ${ref(el => { if (el) { setValueWithEntities(el, value); } })}}></span>`; } value = value.replace(closingPunctuationRegex, '$&\u200B'); if (value.startsWith('data:')) { value = Platform.StringUtilities.trimMiddle(value, 60); } const isAnchor = node && node.nodeName().toLowerCase() === 'a'; if (isAnchor) { return html`<devtools-link class="devtools-link image-url" href=${rewrittenHref} ${ref(el => { if (el) { ImagePreviewPopover.setImageUrl(el, rewrittenHref); } })}>${Platform.StringUtilities.trimMiddle(value, 150)}</devtools-link>`; } return Components.Linkifier.Linkifier.renderLinkifiedUrl(rewrittenHref, { text: value, preventClick: true, showColumnNumber: false, inlineFrameIndex: 0, onRef: link => { ImagePreviewPopover.setImageUrl(link, rewrittenHref); } }); } function renderAttribute( attr: {name: string, value?: string}, updateRecord: Elements.ElementUpdateRecord.ElementUpdateRecord|null, isDiff: boolean, node: SDK.DOMModel.DOMNode): Lit.LitTemplate { const name = attr.name; const value = attr.value || ''; const forceValue = isDiff; const hasText = (forceValue || value.length > 0); const jslog = VisualLogging.value(name === 'style' ? 'style-attribute' : 'attribute').track({ change: true, dblclick: true, }); const relationRef = (relation: Protocol.DOM.GetElementByRelationRequestRelation, tooltip: string): ReturnType<typeof ref> => ref((el): void => { if (!el) { return; } void (async(): Promise<void> => { const relatedElementId = await node.domModel().getElementByRelation(node.id, relation); const relatedElement = node.domModel().nodeForId(relatedElementId); if (!relatedElement) { return; } const link = PanelsCommon.DOMLinkifier.Linkifier.instance().linkify(relatedElement, { preventKeyboardFocus: true, tooltip, textContent: el.textContent || undefined, isDynamicLink: true, }); render(link, el as HTMLElement); })(); }); let relationRefDirective: ReturnType<typeof relationRef> = ref(() => {}); if (!value) { if (name === 'popovertarget') { relationRefDirective = relationRef( Protocol.DOM.GetElementByRelationRequestRelation.PopoverTarget, i18nString(UIStrings.showPopoverTarget)); } else if (name === 'interesttarget') { relationRefDirective = relationRef( Protocol.DOM.GetElementByRelationRequestRelation.InterestTarget, i18nString(UIStrings.showInterestTarget)); } else if (name === 'commandfor') { relationRefDirective = relationRef( Protocol.DOM.GetElementByRelationRequestRelation.CommandFor, i18nString(UIStrings.showCommandForTarget)); } } let valueRelationRefDirective: ReturnType<typeof relationRef> = ref(() => {}); if (value) { if (name === 'popovertarget') { valueRelationRefDirective = relationRef( Protocol.DOM.GetElementByRelationRequestRelation.PopoverTarget, i18nString(UIStrings.showPopoverTarget)); } else if (name === 'interesttarget') { valueRelationRefDirective = relationRef( Protocol.DOM.GetElementByRelationRequestRelation.InterestTarget, i18nString(UIStrings.showInterestTarget)); } else if (name === 'commandfor') { valueRelationRefDirective = relationRef( Protocol.DOM.GetElementByRelationRequestRelation.CommandFor, i18nString(UIStrings.showCommandForTarget)); } } const nodeName = node ? node.nodeName().toLowerCase() : ''; const enum ValueType { UNKNOWN = 0, SRC = 1, SRCSET = 2, } let valueType = ValueType.UNKNOWN; if (nodeName && (name === 'src' || name === 'href') && value) { valueType = ValueType.SRC; } else if ((nodeName === 'img' || nodeName === 'source') && name === 'srcset') { valueType = ValueType.SRCSET; } else if (nodeName === 'image' && (name === 'xlink:href' || name === 'href')) { valueType = ValueType.SRCSET; } const withEntitiesRef = valueType === ValueType.UNKNOWN ? ref(el => { if (el) { setValueWithEntities(el, value); } }) : nothing; // clang-format off return html`<span class="webkit-html-attribute" jslog=${jslog}><span class="webkit-html-attribute-name" ${animateOn(Boolean(updateRecord?.isAttributeModified(name) && !hasText), DOM_UPDATE_ANIMATION_CLASS_NAME)} ${relationRefDirective}>${name}</span>${hasText ? html`=\u200B"<span class="webkit-html-attribute-value" ${animateOn( Boolean(updateRecord?.isAttributeModified(name) && hasText), DOM_UPDATE_ANIMATION_CLASS_NAME)} ${valueRelationRefDirective} ${withEntitiesRef}> ${valueType === ValueType.SRC ? renderLinkifiedValue(value, node) : nothing} ${valueType === ValueType.SRCSET ? renderLinkifiedSrcset(Common.Srcset.parseSrcset(value), node) : nothing} </span>"` : nothing}</span>`; // clang-format on } function renderTag( node: SDK.DOMModel.DOMNode, tagName: string, isClosingTag: boolean, expanded: boolean, isDistinctTreeElement: boolean, updateRecord: Elements.ElementUpdateRecord.ElementUpdateRecord|null): Lit.LitTemplate { const classMap = { 'webkit-html-tag': true, close: isClosingTag && isDistinctTreeElement, }; let hasUpdates = false; const attributes = !isClosingTag && node.hasAttributes() ? node.attributes() : []; if (!isClosingTag && updateRecord) { hasUpdates = updateRecord.hasRemovedAttributes() || updateRecord.hasRemovedChildren(); hasUpdates = hasUpdates || (!expanded && updateRecord.hasChangedChildren()); } // We are taking full text content of the tag, including attributes and children, to set the aria label. // FIXME: we should compute the aria label ourselves if it is event needed. const setAriaLabel = ref(el => { if (el?.textContent) { UI.ARIAUtils.setLabel(el, el.textContent); } }); const tagNameClass = isClosingTag ? 'webkit-html-close-tag-name' : 'webkit-html-tag-name'; const tagString = (isClosingTag ? '/' : '') + tagName; const jslog = !isClosingTag ? VisualLogging.value('tag-name').track({change: true, dblclick: true}) : ''; return html`<span class=${Lit.Directives.classMap(classMap)} ${setAriaLabel} >&lt;<span class=${tagNameClass} jslog=${jslog || nothing} ${ animateOn(hasUpdates, DOM_UPDATE_ANIMATION_CLASS_NAME)}>${tagString}</span>${ attributes.map(attr => html` ${renderAttribute(attr, updateRecord, false, node)}`)}&gt;</span>\u200B`; } function maybeRenderAdAdorner(input: ViewInput): Lit.TemplateResult|typeof nothing { if (!input.adProvenance) { return nothing; } // clang-format off return html` <devtools-adorner aria-details=${input.adTooltipId} aria-label=${i18nString(UIStrings.thisElementWasIdentifiedAsAnAd)} .name=${ElementsComponents.AdornerManager.RegisteredAdorners.AD} jslog=${VisualLogging.adorner(ElementsComponents.AdornerManager.RegisteredAdorners.AD)} ${adornerRef()}> <span>${ElementsComponents.AdornerManager.RegisteredAdorners.AD}</span> </devtools-adorner> <!-- Prevent the copy event from bubbling up to the Elements tree outline. Otherwise, DevTools copies the underlying DOM node's HTML instead of the user's highlighted text. --> <devtools-tooltip id=${input.adTooltipId} variant=rich @copy=${(e: Event) => e.stopPropagation()}> <div class="ad-provenance-tooltip"> ${input.adProvenance.filterlistRule ? html` <div class="ad-provenance-tooltip-title">${i18nString(UIStrings.filterListRule)}</div> <div class="ad-provenance-tooltip-content">${input.adProvenance.filterlistRule}</div> ` : nothing} ${input.adProvenance.adScriptAncestry && input.target ? html` <div class="ad-provenance-tooltip-title">${i18nString(UIStrings.creatorAdScriptAncestry)}</div> <div class="ad-provenance-tooltip-content"> ${input.adProvenance.adScriptAncestry.ancestryChain.map(script => html` <div> ${UI.Widget.widget(Components.Linkifier.ScriptLocationLink, { target: input.target, scriptId: script.scriptId, options: { jslogContext: 'ad-script' }, })} </div> `)} </div> ${input.adProvenance.adScriptAncestry.rootScriptFilterlistRule ? html` <div class="ad-provenance-tooltip-title">${i18nString(UIStrings.rootScriptFilterListRule)}</div> <div class="ad-provenance-tooltip-content"> ${input.adProvenance.adScriptAncestry.rootScriptFilterlistRule} </div> ` : nothing} ` : nothing} ${!input.adProvenance.adScriptAncestry && !input.adProvenance.filterlistRule ? html` <div class="ad-provenance-tooltip-title">${i18nString(UIStrings.noProvenanceAvailable)}</div> ` : nothing} </div> </devtools-tooltip> `; // clang-format on } export const DEFAULT_VIEW = (input: ViewInput, output: ViewOutput, target: HTMLElement): void => { const hasAdorners = !!input.adProvenance || input.showContainerAdorner || input.showFlexAdorner || input.showGridAdorner || input.showGridLanesAdorner || input.showMediaAdorner || input.showPopoverAdorner || input.showTopLayerAdorner || input.showViewSourceAdorner || input.showScrollAdorner || input.showScrollSnapAdorner || input.showSlotAdorner || input.showStartingStyleAdorner; const gutterContainerClasses = { 'has-decorations': input.decorations.length || input.descendantDecorations.length, 'gutter-container': true, }; // clang-format off render(html` <div ${ref(el => { output.contentElement = el as HTMLElement; })}> ${input.node ? html`<span class="highlight">${renderTitle( input.node, input.isClosingTag, input.expanded, input.isExpandable, input.isXMLMimeType, input.updateRecord, input.onHighlightSearchResults, input.onExpand, )}</span>` : nothing} ${input.isHovered || input.isSelected ? html` <div class="selection fill" style=${`margin-left: ${-input.indent}px`}></div> ` : nothing} <div class=${Lit.Directives.classMap(gutterContainerClasses)} style="left: ${-input.indent}px" @click=${input.onGutterClick}> <devtools-icon name="dots-horizontal"></devtools-icon> ${input.decorations.length || input.descendantDecorations.length ? html` <div class="elements-gutter-decoration-container" title=${input.decorationsTooltip}> ${input.decorations.map(d => html`<div class="elements-gutter-decoration" style="--decoration-color: ${d.color}"></div>`)} ${input.descendantDecorations.map(d => html`<div class="elements-gutter-decoration elements-has-decorated-children" style="--decoration-color: ${d.color}"></div>`)} </div>` : nothing} </div> ${hasAdorners ? html`<div class="adorner-container ${!hasAdorners ? 'hidden' : ''}"> ${maybeRenderAdAdorner(input)} ${input.showViewSourceAdorner ? html`<devtools-adorner .name=${ElementsComponents.AdornerManager.RegisteredAdorners.VIEW_SOURCE} jslog=${VisualLogging.adorner(ElementsComponents.AdornerManager.RegisteredAdorners.VIEW_SOURCE)} aria-label=${i18nString(UIStrings.viewSourceCode)} @click=${input.onViewSourceAdornerClick} ${adornerRef()}> <span>${ElementsComponents.AdornerManager.RegisteredAdorners.VIEW_SOURCE}</span> </devtools-adorner>` : nothing} ${input.showContainerAdorner ? html`<devtools-adorner class=clickable role=button toggleable=true tabindex=0 .name=${ElementsComponents.AdornerManager.RegisteredAdorners.CONTAINER} jslog=${VisualLogging.adorner(ElementsComponents.AdornerManager.RegisteredAdorners.CONTAINER).track({ click: true })} active=${input.containerAdornerActive} aria-label=${input.containerAdornerActive ? i18nString(UIStrings.enableContainer) : i18nString(UIStrings.disableContainer)} @click=${input.onContainerAdornerClick} @keydown=${handleAdornerKeydown(input.onContainerAdornerClick)} ${adornerRef()}> <span class="adorner-with-icon"> <devtools-icon name="container"></devtools-icon> <span>${input.containerType}</span> </span> </devtools-adorner>`: nothing} ${input.showFlexAdorner ? html`<devtools-adorner class=clickable role=button toggleable=true tabindex=0 .name=${ElementsComponents.AdornerManager.RegisteredAdorners.FLEX} jslog=${VisualLogging.adorner(ElementsComponents.AdornerManager.RegisteredAdorners.FLEX).track({ click: true })} active=${input.flexAdornerActive} aria-label=${input.flexAdornerActive ? i18nString(UIStrings.disableFlexMode) : i18nString(UIStrings.enableFlexMode)} @click=${input.onFlexAdornerClick} @keydown=${handleAdornerKeydown(input.onFlexAdornerClick)} ${adornerRef()}> <span>${ElementsComponents.AdornerManager.RegisteredAdorners.FLEX}</span> </devtools-adorner>`: nothing} ${input.showGridAdorner ? html`<devtools-adorner class=clickable role=button toggleable=true tabindex=0 .name=${input.isSubgrid ? ElementsComponents.AdornerManager.RegisteredAdorners.SUBGRID : ElementsComponents.AdornerManager.RegisteredAdorners.GRID} jslog=${VisualLogging.adorner(input.isSubgrid ? ElementsComponents.AdornerManager.RegisteredAdorners.SUBGRID : ElementsComponents.AdornerManager.RegisteredAdorners.GRID).track({ click: true })} active=${input.gridAdornerActive} aria-label=${input.gridAdornerActive ? i18nString(UIStrings.disableGridMode) : i18nString(UIStrings.enableGridMode)} @click=${input.onGridAdornerClick} @keydown=${handleAdornerKeydown(input.onGridAdornerClick)} ${adornerRef()}> <span>${input.isSubgrid ? ElementsComponents.AdornerManager.RegisteredAdorners.SUBGRID : ElementsComponents.AdornerManager.RegisteredAdorners.GRID}</span> </devtools-adorner>`: nothing} ${input.showGridLanesAdorner ? html`<devtools-adorner class=clickable role=button toggleable=true tabindex=0 .name=${ElementsComponents.AdornerManager.RegisteredAdorners.GRID_LANES} jslog=${VisualLogging.adorner(ElementsComponents.AdornerManager.RegisteredAdorners.GRID_LANES).track({ click: true })} active=${input.gridAdornerActive} aria-label=${input.gridAdornerActive ? i18nString(UIStrings.disableGridLanesMode) : i18nString(UIStrings.enableGridLanesMode)} @click=${input.onGridAdornerClick} @keydown=${handleAdornerKeydown(input.onGridAdornerClick)} ${adornerRef()}> <span>${ElementsComponents.AdornerManager.RegisteredAdorners.GRID_LANES}</span> </devtools-adorner>`: nothing} ${input.showMediaAdorner ? html`<devtools-adorner class=clickable role=button tabindex=0 .name=${ElementsComponents.AdornerManager.RegisteredAdorners.MEDIA} jslog=${VisualLogging.adorner(ElementsComponents.AdornerManager.RegisteredAdorners.MEDIA).track({ click: true })} aria-label=${i18nString(UIStrings.openMediaPanel)} @click=${input.onMediaAdornerClick} @keydown=${handleAdornerKeydown(input.onMediaAdornerClick)} ${adornerRef()}> <span class="adorner-with-icon"> ${ElementsComponents.AdornerManager.RegisteredAdorners.MEDIA}<devtools-icon name="select-element"></devtools-icon> </span> </devtools-adorner>`: nothing} ${input.showPopoverAdorner ? html`<devtools-adorner class=clickable role=button toggleable=true tabindex=0 .name=${ElementsComponents.AdornerManager.RegisteredAdorners.POPOVER} jslog=${VisualLogging.adorner(ElementsComponents.AdornerManager.RegisteredAdorners.POPOVER).track({ click: true })} active=${input.popoverAdornerActive} aria-label=${input.popoverAdornerActive ? i18nString(UIStrings.stopForceOpenPopover) : i18nString(UIStrings.forceOpenPopover)} @click=${input.onPopoverAdornerClick} @keydown=${handleAdornerKeydown(input.onPopoverAdornerClick)} ${adornerRef()}> <span>${ElementsComponents.AdornerManager.RegisteredAdorners.POPOVER}</span> </devtools-adorner>`: nothing} ${input.showTopLayerAdorner ? html`<devtools-adorner class=clickable role=button tabindex=0 .name=${ElementsComponents.AdornerManager.RegisteredAdorners.TOP_LAYER} jslog=${VisualLogging.adorner(ElementsComponents.AdornerManager.RegisteredAdorners.TOP_LAYER).track({ click: true })} aria-label=${i18nString(UIStrings.reveal)} @click=${input.onTopLayerAdornerClick} @keydown=${handleAdornerKeydown(input.onTopLayerAdornerClick)} ${adornerRef()}> <span class="adorner-with-icon"> ${`top-layer (${input.topLayerIndex})`}<devtools-icon name="select-element"></devtools-icon> </span> </devtools-adorner>`: nothing} ${input.showStartingStyleAdorner ? html`<devtools-adorner class="starting-style" .name=${ElementsComponents.AdornerManager.RegisteredAdorners.STARTING_STYLE} jslog=${VisualLogging.adorner(ElementsComponents.AdornerManager.RegisteredAdorners.STARTING_STYLE).track({ click: true })} active=${input.startingStyleAdornerActive} toggleable=true aria-label=${input.startingStyleAdornerActive ? i18nString(UIStrings.disableStartingStyle) : i18nString(UIStrings.enableStartingStyle)} @click=${input.onStartingStyleAdornerClick} @keydown=${handleAdornerKeydown(input.onStartingStyleAdornerClick)} ${adornerRef()}> <span>${ElementsComponents.AdornerManager.RegisteredAdorners.STARTING_STYLE}</span> </devtools-adorner>` : nothing} ${input.showScrollAdorner ? html`<devtools-adorner class="scroll" .name=${ElementsComponents.AdornerManager.RegisteredAdorners.SCROLL} jslog=${VisualLogging.adorner(ElementsComponents.AdornerManager.RegisteredAdorners.SCROLL).track({ click: true })} aria-label=${i18nString(UIStrings.elementHasScrollableOverflow)} ${adornerRef()}> <span>${ElementsComponents.AdornerManager.RegisteredAdorners.SCROLL}</span> </devtools-adorner>` : nothing} ${input.showSlotAdorner ? html`<devtools-adorner class=clickable role=button tabindex=0 .name=${ElementsComponents.AdornerManager.RegisteredAdorners.SLOT} jslog=${VisualLogging.adorner(ElementsComponents.AdornerManager.RegisteredAdorners.SLOT).track({ click: true })} @click=${input.onSlotAdornerClick} @mousedown=${(e: Event) => e.stopPropagation()} ${adornerRef()}> <span class="adorner-with-icon"> <devtools-icon name="select-element"></devtools-icon> <span>${ElementsComponents.AdornerManager.RegisteredAdorners.SLOT}</span> </span> </devtools-adorner>`: nothing} ${input.showScrollSnapAdorner ? html`<devtools-adorner class="scroll-snap" .name=${ElementsComponents.AdornerManager.RegisteredAdorners.SCROLL_SNAP} jslog=${VisualLogging.adorner(ElementsComponents.AdornerManager.RegisteredAdorners.SCROLL_SNAP).track({ click: true })} active=${input.scrollSnapAdornerActive} toggleable=true aria-label=${input.scrollSnapAdornerActive ? i18nString(UIStrings.disableScrollSnap) : i18nString(UIStrings.enableScrollSnap)} @click=${input.onScrollSnapAdornerClick} @keydown=${handleAdornerKeydown(input.onScrollSnapAdornerClick)} ${adornerRef()}> <span>${ElementsComponents.AdornerManager.RegisteredAdorners.SCROLL_SNAP}</span> </devtools-adorner>` : nothing} </div>`: nothing} ${input.isSelected ? html` <span class="selected-hint" title=${i18nString(UIStrings.useSInTheConsoleToReferToThis, { PH1: '$0' })} aria-hidden="true"></span> ` : nothing} ${input.showAiButton ? html` <span class="ai-button-container"> <devtools-floating-button icon-name=${AIAssistance.AiUtils.getIconName()} title=${input.aiButtonTitle || ''} jslogcontext="ask-ai" @click=${input.onAiButtonClick} @mousedown=${(e: Event) => e.stopPropagation()}> </devtools-floating-button> </span> ` : nothing} </div> `, target); // clang-format on }; export class ElementsTreeElement extends UI.TreeOutline.TreeElement { nodeInternal: SDK.DOMModel.DOMNode; override treeOutline: ElementsTreeOutline|null; private searchQuery: string|null; #expandedChildrenLimit: number; private readonly decorationsThrottler: Common.Throttler.Throttler; private inClipboard: boolean; #hovered: boolean; private editing: EditorHandles|null; private htmlEditElement?: HTMLElement; expandAllButtonElement: UI.TreeOutline.TreeElement|null; #elementIssues = new Map<string, IssuesManager.Issue.Issue>(); #nodeElementToIssue = new Map<Element, IssuesManager.Issue.Issue[]>(); #highlights: Range[] = []; readonly tagTypeContext: TagTypeContext; #adornersThrottler = new Common.Throttler.Throttler(100); #containerAdornerActive = false; #flexAdornerActive = false; #gridAdornerActive = false; #popoverAdornerActive = false; #scrollSnapAdornerActive = false; #startingStyleAdornerActive = false; #layout: SDK.CSSModel.LayoutProperties|null = null; #decorations: Decoration[] = []; #descendantDecorations: Decoration[] = []; #decorationsTooltip = ''; static #adTooltipIdCounter = 0; #adTooltipId = `ad-tooltip-${++ElementsTreeElement.#adTooltipIdCounter}`; #updateRecord: Elements.ElementUpdateRecord.ElementUpdateRecord|null = null; // Used to add the content to TreeElement's title element. // Relied on by web tests. #contentElement?: HTMLElement;