UNPKG

chrome-devtools-frontend

Version:
1,213 lines (1,132 loc) 118 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 */ /* * 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 iframe is marked as advertisement frame. */ thisFrameWasIdentifiedAsAnAd: 'This frame was identified as an ad frame', /** * @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 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; showAdAdorner: boolean; 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>`; } default: { return html`${Platform.StringUtilities.collapseWhitespace(node.nodeNameInCorrectCase())}`; } } } const enum SrcsetTokenType { LITERAL = 0, LINK = 1 } interface SrcsetToken { type: SrcsetTokenType; value: string; } // FIXME: find a home for this in SDK. function parseSrcset(value: string): SrcsetToken[] { const result: SrcsetToken[] = []; let i = 0; while (value.length) { if (i++ > 0) { result.push({value: ' ', type: SrcsetTokenType.LITERAL}); } value = value.trim(); let url = ''; let descriptor = ''; const indexOfSpace = value.search(/\s/); if (indexOfSpace === -1) { url = value; } else if (indexOfSpace > 0 && value[indexOfSpace - 1] === ',') { url = value.substring(0, indexOfSpace); } else { url = value.substring(0, indexOfSpace); const indexOfComma = value.indexOf(',', indexOfSpace); if (indexOfComma !== -1) { descriptor = value.substring(indexOfSpace, indexOfComma + 1); } else { descriptor = value.substring(indexOfSpace); } } if (url) { if (url.endsWith(',')) { result.push({value: url.substring(0, url.length - 1), type: SrcsetTokenType.LINK}); result.push({type: SrcsetTokenType.LITERAL, value: ','}); } else { result.push({value: url, type: SrcsetTokenType.LINK}); } } if (descriptor) { result.push({type: SrcsetTokenType.LITERAL, value: descriptor}); } value = value.substring(url.length + descriptor.length); } return result; } function renderLinkifiedSrcset(tokens: SrcsetToken[], node: SDK.DOMModel.DOMNode): Lit.TemplateResult { return html`${repeat(tokens, token => { switch (token.type) { case SrcsetTokenType.LINK: return renderLinkifiedValue(token.value, node); case SrcsetTokenType.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, }); el.removeChildren(); el.append(link); })(); }); 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(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`; } export const DEFAULT_VIEW = (input: ViewInput, output: ViewOutput, target: HTMLElement): void => { const hasAdorners = input.showAdAdorner || 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' : ''}"> ${input.showAdAdorner ? html`<devtools-adorner aria-label=${i18nString(UIStrings.thisFrameWasIdentifiedAsAnAd)} .name=${ElementsComponents.AdornerManager.RegisteredAdorners.AD} jslog=${VisualLogging.adorner(ElementsComponents.AdornerManager.RegisteredAdorners.AD)} ${adornerRef()}> <span>${ElementsComponents.AdornerManager.RegisteredAdorners.AD}</span> </devtools-adorner>` : nothing} ${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 = ''; #updateRecord: Elements.ElementUpdateRecord.ElementUpdateRecord|null = null; // Used to add the content to TreeElement's title element. // Relied on by web tests. #contentElement?: HTMLElement; constructor(node: SDK.DOMModel.DOMNode, isClosingTag?: boolean) { // The title will be updated in onattach. super(); this.nodeInternal = node; this.treeOutline = null; this.listItemElement.setAttribute( 'jslog', `${VisualLogging.treeItem().parent('elementsTreeOutline').track({ keydown: 'ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Backspace|Delete|Enter|Space|Home|End', drag: true, click: true, })}`); this.searchQuery = null; this.#expandedChildrenLimit = InitialChildrenLimit; this.decorationsThrottler = new Common.Throttler.Throttler(100); this.inClipboard = false; this.#hovered = false; this.editing = null; if (isClosingTag) { this.tagTypeContext = {tagType: TagType.CLOSING}; } else { this.tagTypeContext = { tagType: TagType.OPENING, canAddAttributes: this.nodeInternal.nodeType() === Node.ELEMENT_NODE, }; void this.#updateAdorners(); } this.expandAllButtonElement = null; this.performUpdate(); if (this.nodeInternal.retained && !this.isClosingTag()) { const icon = new Icon(); icon.name = 'small-status-dot'; icon.style.color = 'var(--icon-error)'; icon.classList.add('extra-small'); icon.style.setProperty('vertical-align', 'middle'); this.setLeadingIcons([icon]); this.listItemNode.classList.add('detached-elements-detached-node'); this.listItemNode.style.setProperty('display', '-webkit-box'); this.listItemNode.setAttribute('title', 'Retained Node'); } if (this.nodeInternal.detached && !this.isClosingTag()) { this.listItemNode.setAttribute('title', 'Detached Tree Node'); } } static animateOnDOMUpdate(treeElement: ElementsTreeElement): void { const tagName = treeElement.listItemElement.querySelector('.webkit-html-tag-name'); UI.UIUtils.runCSSAnimationOnce(tagName || treeElement.listItemElement, DOM_UPDATE_ANIMATION_CLASS_NAME); } static visibleShadowRoots(node: SDK.DOMModel.DOMNode): SDK.DOMModel.DOMNode[] { let roots = node.shadowRoots(); if (roots.length && !Common.Settings.Settings.instance().moduleSetting('show-ua-shadow-dom').get()) { roots = roots.filter(filter); } function filter(root: SDK.DOMModel.DOMNode): boolean { return root.shadowRootType() !== SDK.DOMModel