UNPKG

chrome-devtools-frontend

Version:
544 lines (518 loc) • 23.8 kB
// Copyright 2026 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Common from '../../core/common/common.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as UI from '../../ui/legacy/legacy.js'; import * as Emulation from '../emulation/emulation.js'; import {canGetJSPath} from './DOMPath.js'; import {ElementsPanel} from './ElementsPanel.js'; import {ElementsTreeElement, type ElementsTreeWidget} from './ElementsTreeElement.js'; import type {DOMTreeWidget} from './ElementsTreeOutline.js'; const UIStrings = { /** * @description A context menu item to store a value as a global variable the Elements Panel */ storeAsGlobalVariable: 'Store as global variable', /** * @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 panel to switch to Accessibility tree */ switchToAccessibilityTree: 'Switch to accessibility tree', /** * @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 of a context menu item to redirect to the AI assistance panel and to start a chat. */ startAChat: 'Start a chat', /** * @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', } as const; const str_ = i18n.i18n.registerUIStrings('panels/elements/DOMTreeContextMenu.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export async function populateNodeContextMenu( contextMenu: UI.ContextMenu.ContextMenu, domTreeWidget: DOMTreeWidget, domNode: SDK.DOMModel.DOMNode, targetWidget?: ElementsTreeWidget, ): Promise<void> { const isEditable = !domNode.isShadowRoot() && !domNode.ancestorUserAgentShadowRoot(); if (isEditable && !targetWidget?.isEditing) { contextMenu.editSection().appendItem(i18nString(UIStrings.editAsHtml), () => domTreeWidget.toggleEditAsHTML(domNode), {jslogContext: 'elements.edit-as-html'}); } const isShadowRoot = domNode.isShadowRoot(); const createShortcut = UI.KeyboardShortcut.KeyboardShortcut.shortcutToString.bind(null); const modifier = UI.KeyboardShortcut.Modifiers.CtrlOrMeta.value; let menuItem; const openAiAssistanceId = 'freestyler.element-panel-context'; if (UI.ActionRegistry.ActionRegistry.instance().hasAction(openAiAssistanceId)) { function appendSubmenuPromptAction(submenu: UI.ContextMenu.SubMenu, action: UI.ActionRegistration.Action, label: Common.UIString.LocalizedString, prompt: string, jslogContext: string): void { submenu.defaultSection().appendItem(label, () => { void action.execute({prompt}); UI.UIUtils.PromotionManager.instance().recordFeatureInteraction(openAiAssistanceId); }, {disabled: !action.enabled(), jslogContext}); } UI.Context.Context.instance().setFlavor(SDK.DOMModel.DOMNode, domNode); const action = UI.ActionRegistry.ActionRegistry.instance().getAction(openAiAssistanceId); const submenu = contextMenu.footerSection().appendSubMenuItem(action.title(), false, openAiAssistanceId); submenu.defaultSection().appendAction(openAiAssistanceId, i18nString(UIStrings.startAChat)); const submenuConfigs = [ { condition: (props: SDK.CSSModel.LayoutProperties|null): boolean => Boolean(props?.isFlex), items: [ { label: i18nString(UIStrings.wrapTheseItems), prompt: 'How can I make flex items wrap?', jslogContextSuffix: '.flex-wrap', }, { label: i18nString(UIStrings.distributeItemsEvenly), prompt: 'How do I distribute flex items evenly?', jslogContextSuffix: '.flex-distribute', }, { label: i18nString(UIStrings.explainFlexbox), prompt: 'What is flexbox?', jslogContextSuffix: '.flex-what', }, ], }, { condition: (props: SDK.CSSModel.LayoutProperties|null): boolean => Boolean(props?.isGrid && !props?.isSubgrid), items: [ { label: i18nString(UIStrings.alignItems), prompt: 'How do I align items in a grid?', jslogContextSuffix: '.grid-align', }, { label: i18nString(UIStrings.addPadding), prompt: 'How to add spacing between grid items?', jslogContextSuffix: '.grid-gap', }, { label: i18nString(UIStrings.explainGridLayout), prompt: 'How does grid layout work?', jslogContextSuffix: '.grid-how', }, ], }, { condition: (props: SDK.CSSModel.LayoutProperties|null): boolean => Boolean(props?.isSubgrid), items: [ { label: i18nString(UIStrings.findGridDefinition), prompt: 'Where is this grid defined?', jslogContextSuffix: '.subgrid-where', }, { label: i18nString(UIStrings.changeParentProperties), prompt: 'How to overwrite parent grid properties?', jslogContextSuffix: '.subgrid-override', }, { label: i18nString(UIStrings.explainSubgrids), prompt: 'How do subgrids work?', jslogContextSuffix: '.subgrid-how', }, ], }, { condition: (props: SDK.CSSModel.LayoutProperties|null): boolean => Boolean(props?.hasScroll), items: [ { label: i18nString(UIStrings.removeScrollbars), prompt: 'How do I remove scrollbars for this element?', jslogContextSuffix: '.scroll-remove', }, { label: i18nString(UIStrings.styleScrollbars), prompt: 'How can I style a scrollbar?', jslogContextSuffix: '.scroll-style', }, { label: i18nString(UIStrings.explainScrollbars), prompt: 'Why does this element scroll?', jslogContextSuffix: '.scroll-why', }, ], }, { condition: (props: SDK.CSSModel.LayoutProperties|null): boolean => Boolean(props?.containerType), items: [ { label: i18nString(UIStrings.explainContainerQueries), prompt: 'What are container queries?', jslogContextSuffix: '.container-what', }, { label: i18nString(UIStrings.explainContainerTypes), prompt: 'How do I use container-type?', jslogContextSuffix: '.container-how', }, { label: i18nString(UIStrings.explainContainerContext), prompt: 'What\'s the container context for this element?', jslogContextSuffix: '.container-context', }, ], }, { // Default items condition: (): boolean => true, items: [ { label: i18nString(UIStrings.assessVisibility), prompt: 'Why isn’t this element visible?', jslogContextSuffix: '.visibility', }, { label: i18nString(UIStrings.centerElement), prompt: 'How do I center this element?', jslogContextSuffix: '.center', }, ], }, ]; const layoutProps = await domNode.domModel().cssModel().getLayoutPropertiesFromComputedStyle(domNode.id); const config = submenuConfigs.find(c => c.condition(layoutProps)); if (config) { for (const item of config.items) { appendSubmenuPromptAction(submenu, action, item.label, item.prompt, openAiAssistanceId + item.jslogContextSuffix); } } } menuItem = contextMenu.clipboardSection().appendItem(i18nString(UIStrings.cut), () => domTreeWidget.performCopyOrCut(true, domNode), {disabled: !isEditable, jslogContext: 'cut'}); menuItem.setShortcut(createShortcut('X', modifier)); // Place it here so that all "Copy"-ing items stick together. const copyMenu = contextMenu.clipboardSection().appendSubMenuItem(i18nString(UIStrings.copy), false, 'copy'); const section = copyMenu.section(); if (!isShadowRoot) { menuItem = section.appendItem(i18nString(UIStrings.copyOuterhtml), () => domTreeWidget.performCopyOrCut(false, domNode), {jslogContext: 'copy-outer-html'}); menuItem.setShortcut(createShortcut('V', modifier)); } if (domNode.nodeType() === Node.ELEMENT_NODE) { section.appendItem(i18nString(UIStrings.copySelector), () => domTreeWidget.copyCSSPath(domNode), {jslogContext: 'copy-selector'}); section.appendItem(i18nString(UIStrings.copyJsPath), () => domTreeWidget.copyJSPath(domNode), {disabled: !canGetJSPath(domNode), jslogContext: 'copy-js-path'}); section.appendItem(i18nString(UIStrings.copyStyles), () => void domTreeWidget.copyStyles(domNode), {jslogContext: 'elements.copy-styles'}); } if (!isShadowRoot) { section.appendItem(i18nString(UIStrings.copyXpath), () => domTreeWidget.copyXPath(domNode), {jslogContext: 'copy-xpath'}); section.appendItem(i18nString(UIStrings.copyFullXpath), () => domTreeWidget.copyFullXPath(domNode), {jslogContext: 'copy-full-xpath'}); } menuItem = copyMenu.clipboardSection().appendItem(i18nString(UIStrings.copyElement), () => domTreeWidget.performCopyOrCut(false, domNode, true), {jslogContext: 'copy-element'}); menuItem.setShortcut(createShortcut('C', modifier)); if (!isShadowRoot) { // Duplicate element, disabled on root element and ShadowDOM. const isRootElement = !domNode.parentNode || domNode.parentNode.nodeName() === '#document'; menuItem = contextMenu.editSection().appendItem(i18nString(UIStrings.duplicateElement), () => domTreeWidget.duplicateNode(domNode), { disabled: (domNode.isInShadowTree() || isRootElement), jslogContext: 'elements.duplicate-element', }); } menuItem = contextMenu.clipboardSection().appendItem(i18nString(UIStrings.paste), () => domTreeWidget.pasteNode(domNode), {disabled: !domTreeWidget.canPaste(domNode), jslogContext: 'paste'}); menuItem.setShortcut(createShortcut('V', modifier)); menuItem = contextMenu.debugSection().appendCheckboxItem(i18nString(UIStrings.hideElement), () => domTreeWidget.toggleHideElement(domNode), { checked: Boolean(domTreeWidget.isToggledToHidden(domNode)), jslogContext: 'elements.hide-element', }); menuItem.setShortcut( UI.ShortcutRegistry.ShortcutRegistry.instance().shortcutTitleForAction('elements.hide-element') || ''); if (isEditable) { contextMenu.editSection().appendItem(i18nString(UIStrings.deleteElement), () => void domTreeWidget.removeNode(domNode), {jslogContext: 'delete-element'}); } contextMenu.viewSection().appendItem(i18nString(UIStrings.expandRecursively), () => void domTreeWidget.expandRecursively(domNode), {jslogContext: 'expand-recursively'}); contextMenu.viewSection().appendItem(i18nString(UIStrings.collapseChildren), () => domTreeWidget.collapseChildren(domNode), {jslogContext: 'collapse-children'}); contextMenu.viewSection().appendItem(i18nString(UIStrings.switchToAccessibilityTree), () => ElementsPanel.instance().toggleAccessibilityTree(), {jslogContext: 'switch-to-accessibility-tree'}); const deviceModeWrapperAction = new Emulation.DeviceModeView.ActionDelegate(); contextMenu.viewSection().appendItem(i18nString(UIStrings.captureNodeScreenshot), deviceModeWrapperAction.handleAction.bind(null, UI.Context.Context.instance(), 'emulation.capture-node-screenshot'), {jslogContext: 'emulation.capture-node-screenshot'}); if (domNode.frameOwnerFrameId()) { contextMenu.viewSection().appendItem(i18nString(UIStrings.showFrameDetails), () => { const frameOwnerFrameId = domNode.frameOwnerFrameId(); if (frameOwnerFrameId) { const frame = SDK.FrameManager.FrameManager.instance().getFrame(frameOwnerFrameId); void Common.Revealer.reveal(frame); } }, {jslogContext: 'show-frame-details'}); } } export async function showContextMenu( domTreeWidget: DOMTreeWidget, domNode: SDK.DOMModel.DOMNode, event: Event, targetWidget?: ElementsTreeWidget, ): Promise<UI.ContextMenu.ContextMenu|undefined> { if (UI.UIUtils.isEditing()) { return; } if (!domTreeWidget.enableContextMenu) { return; } const node = (event.target as Node | null); if (!node) { return; } // The context menu construction may be async. In order to // make sure that no other (default) context menu shows up, we need // to stop propagating and prevent the default action. event.stopPropagation(); event.preventDefault(); const contextMenu = new UI.ContextMenu.ContextMenu(event); const isPseudoElement = Boolean(domNode.pseudoType()); const isTag = domNode.nodeType() === Node.ELEMENT_NODE && !isPseudoElement; let textNode: Element|null = (node as Element).enclosingNodeOrSelfWithClass?.('webkit-html-text-node'); if (textNode?.classList.contains('bogus')) { textNode = null; } const commentNode = (node as Element).enclosingNodeOrSelfWithClass?.('webkit-html-comment'); contextMenu.saveSection().appendItem(i18nString(UIStrings.storeAsGlobalVariable), () => void domNode.saveNodeToTempVariable(), {jslogContext: 'store-as-global-variable'}); if (textNode) { if (!targetWidget?.isEditing) { contextMenu.editSection().appendItem(i18nString(UIStrings.editText), () => targetWidget?.startEditingTextNode(textNode as Element), {jslogContext: 'edit-text'}); } await populateNodeContextMenu(contextMenu, domTreeWidget, domNode, targetWidget); } else if (isTag) { const startTagWidget = targetWidget?.isClosingTag ? (targetWidget.findStartTagWidget?.() ?? targetWidget) : targetWidget; if (startTagWidget) { contextMenu.editSection().appendItem(i18nString(UIStrings.addAttribute), () => startTagWidget.addNewAttribute(), {jslogContext: 'add-attribute'}); } const target = (event.composedPath()[0] || event.target) as Element; const attribute = target.enclosingNodeOrSelfWithClass?.('webkit-html-attribute'); const newAttribute = target.enclosingNodeOrSelfWithClass?.('add-attribute'); if (attribute && !newAttribute) { contextMenu.editSection().appendItem(i18nString(UIStrings.editAttribute), () => startTagWidget?.startEditingAttribute(attribute, target), {jslogContext: 'edit-attribute'}); } await populateNodeContextMenu(contextMenu, domTreeWidget, domNode, startTagWidget); ElementsTreeElement.populateForcedPseudoStateItems(contextMenu, domNode); contextMenu.viewSection().appendItem(i18nString(UIStrings.scrollIntoView), () => domNode.scrollIntoView(), {jslogContext: 'scroll-into-view'}); contextMenu.viewSection().appendItem(i18nString(UIStrings.focus), async () => { await domNode.focus(); }, {jslogContext: 'focus'}); } else if (commentNode) { await populateNodeContextMenu(contextMenu, domTreeWidget, domNode, targetWidget); } else if (isPseudoElement) { if (domNode.childNodeCount() !== 0 || domNode.hasPseudoElements()) { contextMenu.viewSection().appendItem(i18nString(UIStrings.expandRecursively), () => void domTreeWidget.expandRecursively(domNode), {jslogContext: 'expand-recursively'}); } contextMenu.viewSection().appendItem(i18nString(UIStrings.scrollIntoView), () => domNode.scrollIntoView(), {jslogContext: 'scroll-into-view'}); } else if (domNode.nodeType() === Node.PROCESSING_INSTRUCTION_NODE) { contextMenu.editSection().appendItem(i18nString(UIStrings.editData), () => targetWidget?.startEditingProcessingInstructionValue(), {jslogContext: 'elements.edit-data'}); contextMenu.editSection().appendItem(i18nString(UIStrings.duplicateElement), () => domTreeWidget.duplicateNode(domNode), { disabled: domNode.isInShadowTree(), jslogContext: 'elements.duplicate-element', }); contextMenu.editSection().appendItem(i18nString(UIStrings.deleteElement), () => void domTreeWidget.removeNode(domNode), {jslogContext: 'delete-element'}); } ElementsPanel.instance().populateAdornerSettingsContextMenu(contextMenu); contextMenu.appendApplicableItems(domNode); void contextMenu.show(); return contextMenu; }