UNPKG

chrome-devtools-frontend

Version:
542 lines (515 loc) • 23.5 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} from './ElementsTreeElement.js'; import type {ElementsTreeOutline} 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, treeElement: ElementsTreeElement): Promise<void> { const domNode = treeElement.node(); const isEditable = treeElement.hasEditableNode(); if (isEditable && !treeElement.isEditing) { contextMenu.editSection().appendItem(i18nString(UIStrings.editAsHtml), () => treeElement.editAsHTML(), {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); } } } const outline = treeElement.treeOutline as ElementsTreeOutline | null; menuItem = contextMenu.clipboardSection().appendItem(i18nString(UIStrings.cut), () => outline?.performCopyOrCut(true, domNode), {disabled: !treeElement.hasEditableNode(), 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), () => outline?.performCopyOrCut(false, domNode), {jslogContext: 'copy-outer-html'}); menuItem.setShortcut(createShortcut('V', modifier)); } if (domNode.nodeType() === Node.ELEMENT_NODE) { section.appendItem(i18nString(UIStrings.copySelector), () => treeElement.copyCSSPath(), {jslogContext: 'copy-selector'}); section.appendItem(i18nString(UIStrings.copyJsPath), () => treeElement.copyJSPath(), {disabled: !canGetJSPath(domNode), jslogContext: 'copy-js-path'}); section.appendItem(i18nString(UIStrings.copyStyles), () => void treeElement.copyStyles(), {jslogContext: 'elements.copy-styles'}); } if (!isShadowRoot) { section.appendItem(i18nString(UIStrings.copyXpath), () => treeElement.copyXPath(), {jslogContext: 'copy-xpath'}); section.appendItem(i18nString(UIStrings.copyFullXpath), () => treeElement.copyFullXPath(), {jslogContext: 'copy-full-xpath'}); } menuItem = copyMenu.clipboardSection().appendItem(i18nString(UIStrings.copyElement), () => outline?.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), () => outline?.duplicateNode(domNode), { disabled: (domNode.isInShadowTree() || isRootElement), jslogContext: 'elements.duplicate-element', }); } menuItem = contextMenu.clipboardSection().appendItem(i18nString(UIStrings.paste), () => outline?.pasteNode(domNode), {disabled: !outline?.canPaste(domNode), jslogContext: 'paste'}); menuItem.setShortcut(createShortcut('V', modifier)); menuItem = contextMenu.debugSection().appendCheckboxItem(i18nString(UIStrings.hideElement), () => outline?.toggleHideElement(domNode), { checked: Boolean(outline?.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 treeElement.remove(), {jslogContext: 'delete-element'}); } contextMenu.viewSection().appendItem(i18nString(UIStrings.expandRecursively), () => void treeElement.expandRecursively(), {jslogContext: 'expand-recursively'}); contextMenu.viewSection().appendItem(i18nString(UIStrings.collapseChildren), () => treeElement.collapseChildren(), {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( treeElement: ElementsTreeElement, event: Event, onSaveNodeToTempVariable?: (node: SDK.DOMModel.DOMNode) => void, ): Promise<UI.ContextMenu.ContextMenu|undefined> { if (UI.UIUtils.isEditing()) { return; } const outline = treeElement.treeOutline as ElementsTreeOutline | null; if (outline && !outline.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 domNode = treeElement.node(); 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'); if (onSaveNodeToTempVariable) { contextMenu.saveSection().appendItem(i18nString(UIStrings.storeAsGlobalVariable), () => onSaveNodeToTempVariable(domNode), {jslogContext: 'store-as-global-variable'}); } if (textNode) { if (!treeElement.isEditing) { contextMenu.editSection().appendItem(i18nString(UIStrings.editText), () => treeElement.startEditingTextNode(textNode as Element), {jslogContext: 'edit-text'}); } await populateNodeContextMenu(contextMenu, treeElement); } else if (isTag) { const targetWidget = treeElement.isClosingTag() ? ((treeElement.treeOutline as ElementsTreeOutline | null)?.findTreeElement(domNode) as ElementsTreeElement | null) : treeElement; if (targetWidget) { contextMenu.editSection().appendItem(i18nString(UIStrings.addAttribute), () => targetWidget.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), () => treeElement.startEditingAttribute(attribute, target), {jslogContext: 'edit-attribute'}); } await populateNodeContextMenu(contextMenu, treeElement); 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, treeElement); } else if (isPseudoElement) { if (treeElement.childCount() !== 0) { contextMenu.viewSection().appendItem(i18nString(UIStrings.expandRecursively), () => void treeElement.expandRecursively(), {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), () => treeElement.startEditingProcessingInstructionValue(), {jslogContext: 'elements.edit-data'}); contextMenu.editSection().appendItem( i18nString(UIStrings.duplicateElement), () => (treeElement.treeOutline as ElementsTreeOutline | null)?.duplicateNode(domNode), { disabled: domNode.isInShadowTree(), jslogContext: 'elements.duplicate-element', }); contextMenu.editSection().appendItem(i18nString(UIStrings.deleteElement), () => void treeElement.remove(), {jslogContext: 'delete-element'}); } ElementsPanel.instance().populateAdornerSettingsContextMenu(contextMenu); contextMenu.appendApplicableItems(domNode); void contextMenu.show(); return contextMenu; }