UNPKG

chrome-devtools-frontend

Version:
1,232 lines (1,123 loc) • 43.9 kB
// Copyright 2009 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import type * as Common from '../../core/common/common.js'; import * as Host from '../../core/host/host.js'; import type * as Platform from '../../core/platform/platform.js'; import * as Root from '../../core/root/root.js'; import * as Buttons from '../components/buttons/buttons.js'; import {html, render} from '../lit/lit.js'; import * as VisualLogging from '../visual_logging/visual_logging.js'; import {ActionRegistry} from './ActionRegistry.js'; import type {Key, Modifier} from './KeyboardShortcut.js'; import {ShortcutRegistry} from './ShortcutRegistry.js'; import {SoftContextMenu, type SoftContextMenuDescriptor} from './SoftContextMenu.js'; import {deepElementFromEvent, maybeCreateNewBadge} from './UIUtils.js'; /** * Represents a single item in a context menu. * @property jslogContext - An optional string identifying the element for visual logging. * @property customElement - A custom HTML element to be rendered for this item. * @property idInternal - The unique ID of this item. Undefined for separators. * @property contextMenu - The parent `ContextMenu` or `null` if this item is not part of a menu (e.g. a SubMenu). * @property isDevToolsPerformanceMenuItem - Controls whether the shortcuts will be shown in Mac native menus. Use only in exceptional cases where shortcuts are heavily context dependent and critical for a smooth user interaction. * @property disabled - Whether the item should be disabled. * @property previewFeature - Whether this item represents an experimental feature. Adds an experiment icon next to the menu item. * @property accelerator - Describes a keyboard shortcut for the item. Shortcut will not show in native Menus on Mac. * @property label - The text to display for the item. Not used for 'separator' type. */ export class Item { private readonly typeInternal: string; protected readonly label: string|undefined; protected accelerator?: Host.InspectorFrontendHostAPI.AcceleratorDescriptor; protected featureName?: string; protected readonly previewFeature: boolean; protected disabled: boolean|undefined; private readonly checked: boolean|undefined; protected isDevToolsPerformanceMenuItem: boolean; protected contextMenu: ContextMenu|null; protected idInternal: number|undefined; customElement?: Element; private shortcut?: string; #tooltip: Common.UIString.LocalizedString|undefined; protected jslogContext: string|undefined; constructor( contextMenu: ContextMenu|null, type: 'checkbox'|'item'|'separator'|'subMenu', label?: string, isPreviewFeature?: boolean, disabled?: boolean, checked?: boolean, accelerator?: Host.InspectorFrontendHostAPI.AcceleratorDescriptor, tooltip?: Platform.UIString.LocalizedString, jslogContext?: string, featureName?: string) { this.typeInternal = type; this.label = label; this.previewFeature = Boolean(isPreviewFeature); this.accelerator = accelerator; this.disabled = disabled; this.checked = checked; this.isDevToolsPerformanceMenuItem = false; this.contextMenu = contextMenu; this.idInternal = undefined; this.#tooltip = tooltip; if (type === 'item' || type === 'checkbox') { this.idInternal = contextMenu ? contextMenu.nextId() : 0; } this.jslogContext = jslogContext; this.featureName = featureName; } /** * Returns the unique ID of this item. * @throws If the item ID was not set (e.g. for a separator). */ id(): number { if (this.idInternal === undefined) { throw new Error('Tried to access a ContextMenu Item ID but none was set.'); } return this.idInternal; } /** * Returns the type of this item (e.g. 'item', 'checkbox'). */ type(): string { return this.typeInternal; } /** * Returns whether this item is marked as a preview feature (experimental). */ isPreviewFeature(): boolean { return this.previewFeature; } /** * Returns whether this item is enabled. */ isEnabled(): boolean { return !this.disabled; } /** * Sets the enabled state of this item. * @param enabled True to enable the item, false to disable it. */ setEnabled(enabled: boolean): void { this.disabled = !enabled; } /** * Builds a descriptor object for this item. * This descriptor is used to create the actual menu item in either * a soft-rendered menu or a native menu. * @returns The descriptor for the item. * @throws If the item type is invalid. */ buildDescriptor(): SoftContextMenuDescriptor|Host.InspectorFrontendHostAPI.ContextMenuDescriptor { switch (this.typeInternal) { case 'item': { const result: SoftContextMenuDescriptor = { type: 'item', id: this.idInternal, label: this.label, isExperimentalFeature: this.previewFeature, enabled: !this.disabled, checked: undefined, subItems: undefined, tooltip: this.#tooltip, jslogContext: this.jslogContext, featureName: this.featureName, }; if (this.customElement) { result.element = this.customElement; } if (this.shortcut) { result.shortcut = this.shortcut; } if (this.accelerator) { result.accelerator = this.accelerator; if (this.isDevToolsPerformanceMenuItem) { result.isDevToolsPerformanceMenuItem = true; } } return result; } case 'separator': { return { type: 'separator', id: undefined, label: undefined, enabled: undefined, checked: undefined, subItems: undefined, }; } case 'checkbox': { const result: SoftContextMenuDescriptor = { type: 'checkbox', id: this.idInternal, label: this.label, checked: Boolean(this.checked), isExperimentalFeature: this.previewFeature, enabled: !this.disabled, subItems: undefined, tooltip: this.#tooltip, jslogContext: this.jslogContext, }; if (this.customElement) { result.element = this.customElement; } return result; } } throw new Error('Invalid item type:' + this.typeInternal); } /** * Sets a keyboard accelerator for this item. * @param key The key code for the accelerator. * @param modifiers An array of modifiers (e.g. Ctrl, Shift). */ setAccelerator(key: Key, modifiers: Modifier[]): void { const modifierSum = modifiers.reduce( (result, modifier) => result + ShortcutRegistry.instance().devToolsToChromeModifier(modifier), 0); this.accelerator = {keyCode: key.code, modifiers: modifierSum}; } /** * This influences whether accelerators will be shown for native menus on Mac. * Use this ONLY for performance menus and ONLY where accelerators are critical * for a smooth user journey and heavily context dependent. * @param isDevToolsPerformanceMenuItem True if this is a DevTools performance menu item. */ setIsDevToolsPerformanceMenuItem(isDevToolsPerformanceMenuItem: boolean): void { this.isDevToolsPerformanceMenuItem = isDevToolsPerformanceMenuItem; } /** * Sets a display string for the shortcut associated with this item. * This is typically used when the shortcut is managed by `ActionRegistry`. * @param shortcut The shortcut string to display. */ setShortcut(shortcut: string): void { this.shortcut = shortcut; } } /** * Represents a section within a `ContextMenu` or `SubMenu`. * Sections are used to group related items and are often visually separated. * @property items - The list of items in this section. * @property contextMenu - The parent `ContextMenu` or `null`. */ export class Section { readonly contextMenu: ContextMenu|null; readonly items: Item[]; constructor(contextMenu: ContextMenu|null) { this.contextMenu = contextMenu; this.items = []; } /** * Appends a standard clickable item to this section. * @param label The text to display for the item. * @param handler The function to execute when the item is clicked. * @param options Optional settings for the item. * @returns The newly created `Item`. */ appendItem(label: string, handler: () => void, options?: { accelerator?: Host.InspectorFrontendHostAPI.AcceleratorDescriptor, isPreviewFeature?: boolean, disabled?: boolean, additionalElement?: Element, tooltip?: Platform.UIString.LocalizedString, jslogContext?: string, featureName?: string, }): Item { const item = new Item( this.contextMenu, 'item', label, options?.isPreviewFeature, options?.disabled, undefined, options?.accelerator, options?.tooltip, options?.jslogContext, options?.featureName); if (options?.additionalElement) { item.customElement = options?.additionalElement; } this.items.push(item); if (this.contextMenu) { this.contextMenu.setHandler(item.id(), handler); } return item; } /** * Appends an item that contains a custom HTML element (for non-native menus only). * @param element The custom `Element` to display in the menu item. * @param jslogContext An optional string identifying the element for visual logging. * @returns The newly created `Item`. */ appendCustomItem(element: Element, jslogContext?: string): Item { const item = new Item( this.contextMenu, 'item', undefined, undefined, undefined, undefined, undefined, undefined, jslogContext); item.customElement = element; this.items.push(item); return item; } /** * Appends a visual separator to this section. * @returns The newly created separator `Item`. */ appendSeparator(): Item { const item = new Item(this.contextMenu, 'separator'); this.items.push(item); return item; } /** * Appends an item that triggers a registered `Action`. * The item's label, handler, enabled state, and shortcut are derived from the action. * @param actionId The ID of the action to append. * @param label Optional label to override the action's title. * @param optional If true and the action is not registered, this method does nothing. */ appendAction(actionId: string, label?: string, optional?: boolean, jslogContext?: string, feature?: string): void { if (optional && !ActionRegistry.instance().hasAction(actionId)) { return; } const action = ActionRegistry.instance().getAction(actionId); if (!label) { label = action.title(); } const promotionId = action.featurePromotionId(); let additionalElement = undefined; if (promotionId) { additionalElement = maybeCreateNewBadge(promotionId); } const result = this.appendItem( label, action.execute.bind(action), {disabled: !action.enabled(), jslogContext: jslogContext ?? actionId, featureName: feature, additionalElement}); const shortcut = ShortcutRegistry.instance().shortcutTitleForAction(actionId); const keyAndModifier = ShortcutRegistry.instance().keyAndModifiersForAction(actionId); if (keyAndModifier) { result.setAccelerator(keyAndModifier.key, [keyAndModifier.modifier]); } if (shortcut) { result.setShortcut(shortcut); } } /** * Appends an item that, when clicked, opens a sub-menu. * @param label The text to display for the sub-menu item. * @param disabled Whether the sub-menu item should be disabled. * @param jslogContext An optional string identifying the element for visual logging. * @returns The newly created `SubMenu` instance. */ appendSubMenuItem(label: string, disabled?: boolean, jslogContext?: string, featureName?: string): SubMenu { const item = new SubMenu(this.contextMenu, label, disabled, jslogContext, featureName); item.init(); this.items.push(item); return item; } /** * Appends a checkbox item to this section. * @param label The text to display for the checkbox item. * @param handler The function to execute when the checkbox state changes. * @param options Optional settings for the checkbox item. * @returns The newly created checkbox `Item`. */ appendCheckboxItem(label: string, handler: () => void, options?: { checked?: boolean, disabled?: boolean, experimental?: boolean, additionalElement?: Element, tooltip?: Platform.UIString.LocalizedString, jslogContext?: string, featureName?: string, }): Item { const item = new Item( this.contextMenu, 'checkbox', label, options?.experimental, options?.disabled, options?.checked, undefined, options?.tooltip, options?.jslogContext, options?.featureName); this.items.push(item); if (this.contextMenu) { this.contextMenu.setHandler(item.id(), handler); } if (options?.additionalElement) { item.customElement = options.additionalElement; } return item; } } /** * Represents an `Item` that opens a nested menu (a sub-menu). * It extends `Item` and manages its own set of `Section`s. * @property sections - A map of section names to `Section` objects. */ export class SubMenu extends Item { readonly sections: Map<string, Section>; private readonly sectionList: Section[]; constructor( contextMenu: ContextMenu|null, label?: string, disabled?: boolean, jslogContext?: string, featureName?: string) { super( contextMenu, 'subMenu', label, undefined, disabled, undefined, undefined, undefined, jslogContext, featureName); this.sections = new Map(); this.sectionList = []; } /** * Initializes the standard sections for this sub-menu based on `ContextMenu.groupWeights`. */ init(): void { ContextMenu.groupWeights.forEach(name => this.section(name)); } /** * Retrieves an existing section by its name or creates a new one if it doesn't exist. * * If a section with the given `name` (or 'default' if `name` is unspecified) is not found, * a new `Section` instance is created, stored internally for future lookups by that name, * and added to the ordered list of sections for this submenu. * * @param name The optional name of the section. Defaults to 'default' if not provided. * @returns The `Section` object, either pre-existing or newly created. */ section(name?: string): Section { if (!name) { name = 'default'; } let section: Section|(Section | null | undefined) = name ? this.sections.get(name) : null; if (!section) { section = new Section(this.contextMenu); if (name) { this.sections.set(name, section); this.sectionList.push(section); } else { this.sectionList.splice(ContextMenu.groupWeights.indexOf('default'), 0, section); } } return section; } /** * Retrieves or creates the 'header' section. * @returns The 'header' `Section` object. */ headerSection(): Section { return this.section('header'); } /** * Retrieves or creates the 'new' section. * @returns The 'new' `Section` object. */ newSection(): Section { return this.section('new'); } /** * Retrieves or creates the 'reveal' section. * @returns The 'reveal' `Section` object. */ revealSection(): Section { return this.section('reveal'); } /** * Retrieves or creates the 'clipboard' section. * @returns The 'clipboard' `Section` object. */ clipboardSection(): Section { return this.section('clipboard'); } /** * Retrieves or creates the 'edit' section. * @returns The 'edit' `Section` object. */ editSection(): Section { return this.section('edit'); } /** * Retrieves or creates the 'debug' section. * @returns The 'debug' `Section` object. */ debugSection(): Section { return this.section('debug'); } /** * Retrieves or creates the 'view' section. * @returns The 'view' `Section` object. */ viewSection(): Section { return this.section('view'); } /** * Retrieves or creates the 'default' section. * This is often used for general-purpose menu items. * @returns The 'default' `Section` object. */ defaultSection(): Section { return this.section('default'); } /** * Retrieves or creates the 'override' section. * @returns The 'override' `Section` object. */ overrideSection(): Section { return this.section('override'); } /** * Retrieves or creates the 'save' section. * @returns The 'save' `Section` object. */ saveSection(): Section { return this.section('save'); } /** * Retrieves or creates the 'annotation' section. * @returns The 'annotation' `Section` object. */ annotationSection(): Section { return this.section('annotation'); } /** * Retrieves or creates the 'footer' section. * @returns The 'footer' `Section` object. */ footerSection(): Section { return this.section('footer'); } override buildDescriptor(): SoftContextMenuDescriptor|Host.InspectorFrontendHostAPI.ContextMenuDescriptor { const result: Host.InspectorFrontendHostAPI.ContextMenuDescriptor|SoftContextMenuDescriptor = { type: 'subMenu', label: this.label, accelerator: this.accelerator, isDevToolsPerformanceMenuItem: this.accelerator ? this.isDevToolsPerformanceMenuItem : undefined, isExperimentalFeature: this.previewFeature, enabled: !this.disabled, subItems: [], id: undefined, checked: undefined, jslogContext: this.jslogContext, featureName: this.featureName, }; const nonEmptySections = this.sectionList.filter(section => Boolean(section.items.length)); for (const section of nonEmptySections) { for (const item of section.items) { if (!result.subItems) { result.subItems = []; } result.subItems.push(item.buildDescriptor()); } if (section !== nonEmptySections[nonEmptySections.length - 1]) { if (!result.subItems) { result.subItems = []; } result.subItems.push({ type: 'separator', id: undefined, subItems: undefined, checked: undefined, enabled: undefined, label: undefined, }); } } return result; } /** * Appends registered context menu items that are configured to appear under a specific `location` path. * Items are sorted by their `order` property. * Experimental items are only added if their corresponding experiment is enabled. * @param location The base location path (e.g. 'mainMenu'). Items with locations like 'mainMenu/default' will be appended. */ appendItemsAtLocation(location: string): void { const items: ContextMenuItemRegistration[] = getRegisteredItems(); items.sort((firstItem, secondItem) => { const order1 = firstItem.order || 0; const order2 = secondItem.order || 0; return order1 - order2; }); for (const item of items) { if (item.experiment && !Root.Runtime.experiments.isEnabled(item.experiment)) { continue; } const itemLocation = item.location; const actionId = item.actionId; if (!itemLocation?.startsWith(location + '/')) { continue; } const section = itemLocation.substr(location.length + 1); if (!section || section.includes('/')) { continue; } if (actionId) { this.section(section).appendAction(actionId); } } } } /** * Options for configuring a `ContextMenu`. */ export interface ContextMenuOptions { /** * Whether to use the soft (HTML-based) menu implementation. * Defaults to `false` unless globally overridden or in hosted mode. * Set this to true ONLY in exceptional cases where native context menu lacks desired functionality (e.g. keeping menu open after clicking items etc.) */ useSoftMenu?: boolean; /** Whether the menu should remain open after an item is clicked. Defaults to `false`. */ keepOpen?: boolean; /** A callback function that is invoked when a soft menu is closed. */ onSoftMenuClosed?: () => void; /** The x-coordinate for the menu's position. Defaults to the mouse event's x-coordinate. */ x?: number; /** The y-coordinate for the menu's position. Defaults to the mouse event's y-coordinate. */ y?: number; } const MENU_ITEM_HEIGHT_FOR_LOGGING = 20; const MENU_ITEM_WIDTH_FOR_LOGGING = 200; /** * Represents the main context menu. It extends `SubMenu` because a `ContextMenu` * is essentially a top-level menu that can contain sections and items, similar to a sub-menu. * It handles the display of the menu (either soft or native), event handling, and * integration with registered context menu providers. */ export class ContextMenu extends SubMenu { protected override contextMenu: this; private pendingTargets: unknown[]; private readonly event: MouseEvent; private readonly useSoftMenu: boolean; private readonly keepOpen: boolean; private x: number; private y: number; private onSoftMenuClosed?: () => void; private readonly handlers: Map<number, () => void>; override idInternal: number; private softMenu?: SoftContextMenu; private contextMenuLabel?: string; private openHostedMenu: Host.InspectorFrontendHostAPI.ContextMenuDescriptor[]|null; private eventTarget: EventTarget|null; private loggableParent: Element|null = null; /** * Creates an instance of `ContextMenu`. * @param event The mouse event that triggered the menu. * @param options Optional configuration for the context menu. */ constructor(event: Event, options: ContextMenuOptions = {}) { super(null); const mouseEvent = (event as MouseEvent); this.contextMenu = this; super.init(); this.pendingTargets = []; this.event = mouseEvent; this.eventTarget = this.event.target; this.useSoftMenu = Boolean(options.useSoftMenu); this.keepOpen = Boolean(options.keepOpen); this.x = options.x === undefined ? mouseEvent.x : options.x; this.y = options.y === undefined ? mouseEvent.y : options.y; this.onSoftMenuClosed = options.onSoftMenuClosed; this.handlers = new Map(); this.idInternal = 0; this.openHostedMenu = null; let target = (deepElementFromEvent(event) || event.target) as Element | null; if (target) { this.appendApplicableItems((target as Object)); while (target instanceof Element && !target.hasAttribute('jslog')) { target = target.parentElementOrShadowHost() ?? null; } if (target instanceof Element) { this.loggableParent = target; } } } /** * Initializes global settings for context menus, such as listening for * commands from the host to toggle soft menu usage. */ static initialize(): void { Host.InspectorFrontendHost.InspectorFrontendHostInstance.events.addEventListener( Host.InspectorFrontendHostAPI.Events.SetUseSoftMenu, setUseSoftMenu); /** * Sets the global preference for using soft menus. * @param event The event containing the new preference. */ function setUseSoftMenu(event: Common.EventTarget.EventTargetEvent<boolean>): void { ContextMenu.useSoftMenu = event.data; } } /** * Installs a global context menu handler on the provided document's body. * This handler will create and show a `ContextMenu` when a contextmenu event is detected. * @param doc The `Document` to install the handler on. */ static installHandler(doc: Document): void { doc.body.addEventListener('contextmenu', handler, false); function handler(event: Event): void { const contextMenu = new ContextMenu(event); void contextMenu.show(); } } /** * Generates the next unique ID for a menu item within this `ContextMenu`. * @returns A unique number for the item ID. */ nextId(): number { return this.idInternal++; } /** * Checks if a native (hosted) context menu is currently open. * @returns `true` if a native menu is open, `false` otherwise. */ isHostedMenuOpen(): boolean { return Boolean(this.openHostedMenu); } /** * Retrieves the item descriptors if a soft menu is currently active. * @returns An array of `SoftContextMenuDescriptor`s or an empty array if no soft menu is active. */ getItems(): SoftContextMenuDescriptor[] { return this.softMenu?.getItems() || []; } /** * Sets the checked state of an item in an active soft menu. * @param item The descriptor of the item to update. * @param checked `true` to check the item, `false` to uncheck it. */ setChecked(item: SoftContextMenuDescriptor, checked: boolean): void { this.softMenu?.setChecked(item, checked); } /** * Shows the context menu. This involves loading items from registered providers * and then displaying either a soft or native menu. */ async show(): Promise<void> { ContextMenu.pendingMenu = this; this.event.consume(true); const loadedProviders = await Promise.all(this.pendingTargets.map(async target => { const providers = await loadApplicableRegisteredProviders(target); return {target, providers}; })); // After loading all providers, the contextmenu might be hidden again, so bail out. if (ContextMenu.pendingMenu !== this) { return; } ContextMenu.pendingMenu = null; for (const {target, providers} of loadedProviders) { for (const provider of providers) { provider.appendApplicableItems(this.event, this, target); } } this.pendingTargets = []; this.#show(); } /** * Discards (closes) the soft context menu if it's currently shown. */ discard(): void { if (this.softMenu) { this.softMenu.discard(); } } private registerLoggablesWithin( descriptors: Host.InspectorFrontendHostAPI.ContextMenuDescriptor[], parent?: Host.InspectorFrontendHostAPI.ContextMenuDescriptor): void { for (const descriptor of descriptors) { if (descriptor.jslogContext) { if (descriptor.type === 'checkbox') { VisualLogging.registerLoggable( descriptor, `${VisualLogging.toggle().track({click: true}).context(descriptor.jslogContext)}`, parent || descriptors, new DOMRect(0, 0, MENU_ITEM_WIDTH_FOR_LOGGING, MENU_ITEM_HEIGHT_FOR_LOGGING)); } else if (descriptor.type === 'item') { VisualLogging.registerLoggable( descriptor, `${VisualLogging.action().track({click: true}).context(descriptor.jslogContext)}`, parent || descriptors, new DOMRect(0, 0, MENU_ITEM_WIDTH_FOR_LOGGING, MENU_ITEM_HEIGHT_FOR_LOGGING)); } else if (descriptor.type === 'subMenu') { VisualLogging.registerLoggable( descriptor, `${VisualLogging.item().context(descriptor.jslogContext)}`, parent || descriptors, new DOMRect(0, 0, MENU_ITEM_WIDTH_FOR_LOGGING, MENU_ITEM_HEIGHT_FOR_LOGGING)); } if (descriptor.subItems) { this.registerLoggablesWithin(descriptor.subItems, descriptor); } } } } #show(): void { if (!this.eventTarget) { return; } const menuObject = this.buildMenuDescriptors(); const ownerDocument = (this.eventTarget as HTMLElement).ownerDocument; if (this.useSoftMenu || ContextMenu.useSoftMenu || Host.InspectorFrontendHost.InspectorFrontendHostInstance.isHostedMode()) { this.softMenu = new SoftContextMenu( (menuObject as SoftContextMenuDescriptor[]), this.itemSelected.bind(this), this.keepOpen, undefined, this.onSoftMenuClosed, this.loggableParent); // let soft context menu focus on the first item when the event is triggered by a non-mouse event // add another check of button value to differentiate mouse event with 'shift + f10' keyboard event const isMouseEvent = (this.event as PointerEvent).pointerType === 'mouse' && (this.event as PointerEvent).button >= 0; this.softMenu.setFocusOnTheFirstItem(!isMouseEvent); this.softMenu.show((ownerDocument), new AnchorBox(this.x, this.y, 0, 0)); if (this.contextMenuLabel) { this.softMenu.setContextMenuElementLabel(this.contextMenuLabel); } } else { Host.InspectorFrontendHost.InspectorFrontendHostInstance.showContextMenuAtPoint( this.x, this.y, menuObject, (ownerDocument)); function listenToEvents(this: ContextMenu): void { Host.InspectorFrontendHost.InspectorFrontendHostInstance.events.addEventListener( Host.InspectorFrontendHostAPI.Events.ContextMenuCleared, this.menuCleared, this); Host.InspectorFrontendHost.InspectorFrontendHostInstance.events.addEventListener( Host.InspectorFrontendHostAPI.Events.ContextMenuItemSelected, this.onItemSelected, this); } VisualLogging.registerLoggable( menuObject, `${VisualLogging.menu()}`, this.loggableParent, new DOMRect(0, 0, MENU_ITEM_WIDTH_FOR_LOGGING, MENU_ITEM_HEIGHT_FOR_LOGGING * menuObject.length)); this.registerLoggablesWithin(menuObject); this.openHostedMenu = menuObject; // showContextMenuAtPoint call above synchronously issues a clear event for previous context menu (if any), // so we skip it before subscribing to the clear event. queueMicrotask(listenToEvents.bind(this)); } } /** * Sets the x-coordinate for the menu's position. * @param x The new x-coordinate. */ setX(x: number): void { this.x = x; } /** * Sets the y-coordinate for the menu's position. * @param y The new y-coordinate. */ setY(y: number): void { this.y = y; } /** * Associates a handler function with a menu item ID. * @param id The ID of the menu item. * @param handler The function to execute when the item is selected. */ setHandler(id: number, handler: () => void): void { if (handler) { this.handlers.set(id, handler); } } /** * Invokes the handler associated with the given menu item ID. * @param id The ID of the selected menu item. */ invokeHandler(id: number): void { const handler = this.handlers.get(id); if (handler) { handler.call(this); } } private buildMenuDescriptors(): Array<SoftContextMenuDescriptor|Host.InspectorFrontendHostAPI.ContextMenuDescriptor> { return super.buildDescriptor().subItems as Array<SoftContextMenuDescriptor|Host.InspectorFrontendHostAPI.ContextMenuDescriptor>; } private onItemSelected(event: Common.EventTarget.EventTargetEvent<number>): void { this.itemSelected(event.data); } private itemSelected(id: number): void { this.invokeHandler(id); // Collect all features used along the way when searching for the clicked item. // I.e. a 'feature' on a submenu should be counted as 'used' if its submenu items are clicked. const featuresUsed: string[] = []; if (this.openHostedMenu) { const itemWithId = (items: Host.InspectorFrontendHostAPI.ContextMenuDescriptor[], id: number): Host.InspectorFrontendHostAPI.ContextMenuDescriptor|null => { for (const item of items) { if (item.id === id) { if (item.featureName) { featuresUsed.push(item.featureName); } return item; } const subitem = item.subItems && itemWithId(item.subItems, id); if (subitem) { // Record submenu feature. if (item.featureName) { featuresUsed.push(item.featureName); } return subitem; } } return null; }; const item = itemWithId(this.openHostedMenu, id); if (item?.jslogContext) { void VisualLogging.logClick(item, new MouseEvent('click')); } if (item && featuresUsed.length > 0) { featuresUsed.map( feature => Host.InspectorFrontendHost.InspectorFrontendHostInstance.recordNewBadgeUsage(feature)); } } this.menuCleared(); } private menuCleared(): void { Host.InspectorFrontendHost.InspectorFrontendHostInstance.events.removeEventListener( Host.InspectorFrontendHostAPI.Events.ContextMenuCleared, this.menuCleared, this); Host.InspectorFrontendHost.InspectorFrontendHostInstance.events.removeEventListener( Host.InspectorFrontendHostAPI.Events.ContextMenuItemSelected, this.onItemSelected, this); if (this.openHostedMenu) { void VisualLogging.logResize(this.openHostedMenu, new DOMRect(0, 0, 0, 0)); } this.openHostedMenu = null; if (!this.keepOpen) { this.onSoftMenuClosed?.(); } } /** * Appends the `target` to the list of pending targets for which context menu providers * will be loaded when showing the context menu. * * @param target an object for which we can have registered menu item providers. */ appendApplicableItems(target: unknown): void { if (this.pendingTargets.includes(target)) { return; } this.pendingTargets.push(target); } /** * Marks the soft context menu (if one exists) to visually indicate that its items behave like checkboxes. */ markAsMenuItemCheckBox(): void { if (this.softMenu) { this.softMenu.markAsMenuItemCheckBox(); } } private static pendingMenu: ContextMenu|null = null; private static useSoftMenu = false; static readonly groupWeights = [ 'header', 'new', 'reveal', 'edit', 'clipboard', 'debug', 'view', 'default', 'override', 'save', 'annotation', 'footer' ]; } /* eslint-disable @devtools/no-lit-render-outside-of-view */ /** * @property jslogContext - Reflects the `"jslogContext"` attribute. * @property populateMenuCall - Callback function to populate the menu. * @property softMenu - Reflects the `"soft-menu"` attribute. * @property keepOpen -Reflects the `"keep-open"` attribute. * @property iconName - Reflects the `"icon-name"` attribute. * @property disabled - Reflects the `"disabled"` attribute. * @attribute soft-menu - Whether to use the soft menu implementation. * @attribute keep-open - Whether the menu should stay open after an item is clicked. * @attribute icon-name - Name of the icon to display on the button. * @attribute disabled - Whether the menu button is disabled * @attribute jslogContext - The jslog context for the button. * */ export class MenuButton extends HTMLElement { static readonly observedAttributes = ['icon-name', 'disabled']; readonly #shadow = this.attachShadow({mode: 'open'}); #triggerTimeoutId?: number; #populateMenuCall?: (arg0: ContextMenu) => void; /** * Sets the callback function used to populate the context menu when the button is clicked. * @param populateCall A function that takes a `ContextMenu` instance and adds items to it. */ set populateMenuCall(populateCall: (arg0: ContextMenu) => void) { this.#populateMenuCall = populateCall; } /** * Reflects the `soft-menu` attribute. If true, uses the `SoftContextMenu` implementation. * @default false */ get softMenu(): boolean { return Boolean(this.getAttribute('soft-menu')); } set softMenu(softMenu: boolean) { this.toggleAttribute('soft-menu', softMenu); } /** * Reflects the `keep-open` attribute. If true, the menu stays open after an item click. * @default false */ get keepOpen(): boolean { return Boolean(this.getAttribute('keep-open')); } set keepOpen(keepOpen: boolean) { this.toggleAttribute('keep-open', keepOpen); } /** * Reflects the `icon-name` attribute. Sets the icon to display on the button. */ set iconName(iconName: string) { this.setAttribute('icon-name', iconName); } get iconName(): string|null { return this.getAttribute('icon-name'); } /** * Reflects the `jslogContext` attribute. Sets the visual logging context for the button. */ set jslogContext(jslogContext: string) { this.setAttribute('jslog', VisualLogging.dropDown(jslogContext).track({click: true}).toString()); } get jslogContext(): string|null { return this.getAttribute('jslogContext'); } /** * Reflects the `disabled` attribute. If true, the button cannot be clicked. * @default false */ get disabled(): boolean { return this.hasAttribute('disabled'); } set disabled(disabled: boolean) { this.toggleAttribute('disabled', disabled); } /** * Creates and shows the `ContextMenu`. It calls the `populateMenuCall` * callback to fill the menu with items before displaying it relative to the button. * Manages the `aria-expanded` state. * @param event The event that triggered the menu */ #openMenu(event: Event): void { this.#triggerTimeoutId = undefined; if (!this.#populateMenuCall) { return; } const button = this.#shadow.querySelector('devtools-button'); const contextMenu = new ContextMenu(event, { useSoftMenu: this.softMenu, keepOpen: this.keepOpen, x: this.getBoundingClientRect().right, y: this.getBoundingClientRect().top + this.offsetHeight, // Without adding a delay, pointer events will be un-ignored too early, and a single click causes // the context menu to be closed and immediately re-opened on Windows (https://crbug.com/339560549). onSoftMenuClosed: () => setTimeout(() => button?.removeAttribute('aria-expanded'), 50), }); this.#populateMenuCall(contextMenu); button?.setAttribute('aria-expanded', 'true'); void contextMenu.show(); } /** * Handles the click event on the button. It clears any pending trigger timeout * and immediately calls the `openMenu` method to show the context menu. * @param event The click event. */ #triggerContextMenu(event: MouseEvent): void { const triggerTimeout = 50; if (!this.#triggerTimeoutId) { this.#triggerTimeoutId = window.setTimeout(this.#openMenu.bind(this, event), triggerTimeout); } } attributeChangedCallback(_: string, oldValue: string, newValue: string): void { if (oldValue !== newValue) { this.#render(); } } connectedCallback(): void { this.#render(); } #render(): void { if (!this.iconName) { throw new Error('<devtools-menu-button> expects an icon.'); } // clang-format off render(html` <devtools-button .disabled=${this.disabled} .iconName=${this.iconName} .variant=${Buttons.Button.Variant.ICON} .title=${this.title} aria-haspopup='menu' @click=${this.#triggerContextMenu}> </devtools-button>`, this.#shadow, { host: this }); // clang-format on } } customElements.define('devtools-menu-button', MenuButton); /* eslint-enable @devtools/no-lit-render-outside-of-view */ export interface Provider<T> { /** * Appends menu items to the `contextMenu` for the given `target` object. * @param event The event that triggered the context menu. * @param contextMenu The `ContextMenu` to which items should be appended. * @param target The object for which context menu items are being provided. */ appendApplicableItems(event: Event, contextMenu: ContextMenu, target: T): void; } /** * Stores all registered context menu provider registrations. */ const registeredProviders: Array<ProviderRegistration<unknown>> = []; /** * Registers a new context menu provider. * @template T The type of the object for which the provider supplies context menu items. * @param registration The provider registration object, specifying context types and how to load the provider. */ export function registerProvider<T>(registration: ProviderRegistration<T>): void { registeredProviders.push(registration); } /** * Asynchronously loads all registered providers that are applicable to the given `target` object. * A provider is applicable if the `target` is an instance of one of its specified `contextTypes` * and if its associated experiment (if any) is enabled. * @param target The object for which to load applicable providers. * @returns A promise that resolves to an array of loaded `Provider` instances. */ async function loadApplicableRegisteredProviders(target: unknown): Promise<Array<Provider<unknown>>> { const providers: Array<Provider<unknown>> = []; for (const providerRegistration of registeredProviders) { if (!Root.Runtime.Runtime.isDescriptorEnabled( {experiment: providerRegistration.experiment, condition: undefined})) { continue; } if (providerRegistration.contextTypes) { for (const contextType of providerRegistration.contextTypes()) { if (target instanceof contextType) { providers.push(await providerRegistration.loadProvider()); } } } } return providers; } /** * Stores all registered context menu item registrations. */ const registeredItemsProviders: ContextMenuItemRegistration[] = []; /** * Registers a new context menu item. * These items are typically actions that appear in predefined locations in the menu. * @param registration The item registration object, specifying its location, action ID, and optional order/experiment. */ export function registerItem(registration: ContextMenuItemRegistration): void { registeredItemsProviders.push(registration); } /** * Attempts to remove a registered context menu item. * The item is identified by its `actionId` and `location`. * @param registration The registration details of the item to remove. * @returns `true` if the item was found and removed, `false` otherwise. */ export function maybeRemoveItem(registration: ContextMenuItemRegistration): boolean { const itemIndex = registeredItemsProviders.findIndex( item => item.actionId === registration.actionId && item.location === registration.location); if (itemIndex < 0) { return false; } registeredItemsProviders.splice(itemIndex, 1); return true; } /** * Retrieves all currently registered context menu items. * @returns An array of `ContextMenuItemRegistration` objects. */ function getRegisteredItems(): ContextMenuItemRegistration[] { return registeredItemsProviders; } /** * Defines specific locations within the DevTools UI where context menu items can be registered. */ export const enum ItemLocation { DEVICE_MODE_MENU_SAVE = 'deviceModeMenu/save', MAIN_MENU = 'mainMenu', MAIN_MENU_DEFAULT = 'mainMenu/default', MAIN_MENU_FOOTER = 'mainMenu/footer', MAIN_MENU_HELP_DEFAULT = 'mainMenuHelp/default', NAVIGATOR_MENU_DEFAULT = 'navigatorMenu/default', PROFILER_MENU_DEFAULT = 'profilerMenu/default', TIMELINE_MENU_OPEN = 'timelineMenu/open', } export interface ProviderRegistration<T> { /** A function that returns an array of constructor functions that this provider applies to. */ contextTypes: () => Array<abstract new(...any: any[]) => T>; /** A function that asynchronously loads the provider instance. */ loadProvider: () => Promise<Provider<T>>; /** Optional. The experiment that enables this provider. */ experiment?: Root.Runtime.ExperimentName; } export interface ContextMenuItemRegistration { /** The location in the menu where this item should appear. */ location: ItemLocation; /** The ID of the action to be triggered by this item. */ actionId: string; /** Optional. A number used for sorting items within the same location. Lower numbers appear first. */ order?: number; /** Optional. The experiment that enables this item. */ experiment?: Root.Runtime.ExperimentName; }