Path: blob/main/src/vs/workbench/services/contextmenu/electron-browser/contextmenuService.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { IAction, WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification, Separator, SubmenuAction } from '../../../../base/common/actions.js';6import * as dom from '../../../../base/browser/dom.js';7import { IContextMenuMenuDelegate, IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js';8import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';9import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';10import { getZoomFactor } from '../../../../base/browser/browser.js';11import { unmnemonicLabel } from '../../../../base/common/labels.js';12import { INotificationService } from '../../../../platform/notification/common/notification.js';13import { IContextMenuDelegate, IContextMenuEvent } from '../../../../base/browser/contextmenu.js';14import { createSingleCallFunction } from '../../../../base/common/functional.js';15import { IContextMenuItem } from '../../../../base/parts/contextmenu/common/contextmenu.js';16import { popup } from '../../../../base/parts/contextmenu/electron-browser/contextmenu.js';17import { hasNativeContextMenu, MenuSettings } from '../../../../platform/window/common/window.js';18import { isMacintosh, isWindows } from '../../../../base/common/platform.js';19import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';20import { ContextMenuMenuDelegate, ContextMenuService as HTMLContextMenuService } from '../../../../platform/contextview/browser/contextMenuService.js';21import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';22import { stripIcons } from '../../../../base/common/iconLabels.js';23import { coalesce } from '../../../../base/common/arrays.js';24import { Event, Emitter } from '../../../../base/common/event.js';25import { AnchorAlignment, AnchorAxisAlignment, isAnchor } from '../../../../base/browser/ui/contextview/contextview.js';26import { IMenuService } from '../../../../platform/actions/common/actions.js';27import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';28import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';2930export class ContextMenuService implements IContextMenuService {3132declare readonly _serviceBrand: undefined;3334private impl: HTMLContextMenuService | NativeContextMenuService;35private listener?: IDisposable;3637get onDidShowContextMenu(): Event<void> { return this.impl.onDidShowContextMenu; }38get onDidHideContextMenu(): Event<void> { return this.impl.onDidHideContextMenu; }3940constructor(41@INotificationService notificationService: INotificationService,42@ITelemetryService telemetryService: ITelemetryService,43@IKeybindingService keybindingService: IKeybindingService,44@IConfigurationService configurationService: IConfigurationService,45@IContextViewService contextViewService: IContextViewService,46@IMenuService menuService: IMenuService,47@IContextKeyService contextKeyService: IContextKeyService,48) {49function createContextMenuService(native: boolean) {50return native ?51new NativeContextMenuService(notificationService, telemetryService, keybindingService, menuService, contextKeyService)52: new HTMLContextMenuService(telemetryService, notificationService, contextViewService, keybindingService, menuService, contextKeyService);53}5455// set initial context menu service56let isNativeContextMenu = hasNativeContextMenu(configurationService);57this.impl = createContextMenuService(isNativeContextMenu);5859// MacOS does not need a restart when the menu style changes60// It should update the context menu style on menu style configuration change61if (isMacintosh) {62this.listener = configurationService.onDidChangeConfiguration(e => {63if (!e.affectsConfiguration(MenuSettings.MenuStyle)) {64return;65}6667const newIsNativeContextMenu = hasNativeContextMenu(configurationService);68if (newIsNativeContextMenu === isNativeContextMenu) {69return;70}7172this.impl.dispose();73this.impl = createContextMenuService(newIsNativeContextMenu);74isNativeContextMenu = newIsNativeContextMenu;75});76}77}7879dispose(): void {80this.listener?.dispose();81this.impl.dispose();82}8384showContextMenu(delegate: IContextMenuDelegate | IContextMenuMenuDelegate): void {85this.impl.showContextMenu(delegate);86}87}8889class NativeContextMenuService extends Disposable implements IContextMenuService {9091declare readonly _serviceBrand: undefined;9293private readonly _onDidShowContextMenu = this._store.add(new Emitter<void>());94readonly onDidShowContextMenu = this._onDidShowContextMenu.event;9596private readonly _onDidHideContextMenu = this._store.add(new Emitter<void>());97readonly onDidHideContextMenu = this._onDidHideContextMenu.event;9899constructor(100@INotificationService private readonly notificationService: INotificationService,101@ITelemetryService private readonly telemetryService: ITelemetryService,102@IKeybindingService private readonly keybindingService: IKeybindingService,103@IMenuService private readonly menuService: IMenuService,104@IContextKeyService private readonly contextKeyService: IContextKeyService105) {106super();107}108109showContextMenu(delegate: IContextMenuDelegate | IContextMenuMenuDelegate): void {110111delegate = ContextMenuMenuDelegate.transform(delegate, this.menuService, this.contextKeyService);112113const actions = delegate.getActions();114if (actions.length) {115const onHide = createSingleCallFunction(() => {116delegate.onHide?.(false);117118dom.ModifierKeyEmitter.getInstance().resetKeyStatus();119this._onDidHideContextMenu.fire();120});121122const menu = this.createMenu(delegate, actions, onHide);123const anchor = delegate.getAnchor();124125let x: number | undefined;126let y: number | undefined;127128let zoom = getZoomFactor(dom.isHTMLElement(anchor) ? dom.getWindow(anchor) : dom.getActiveWindow());129if (dom.isHTMLElement(anchor)) {130const clientRect = anchor.getBoundingClientRect();131const elementPosition = { left: clientRect.left, top: clientRect.top, width: clientRect.width, height: clientRect.height };132133// Determine if element is clipped by viewport; if so we'll use the bottom-right of the visible portion134const win = dom.getWindow(anchor);135const vw = win.innerWidth;136const vh = win.innerHeight;137const isClipped = clientRect.left < 0 || clientRect.top < 0 || clientRect.right > vw || clientRect.bottom > vh;138139// When drawing context menus, we adjust the pixel position for native menus using zoom level140// In areas where zoom is applied to the element or its ancestors, we need to adjust accordingly141// e.g. The title bar has counter zoom behavior meaning it applies the inverse of zoom level.142// Window Zoom Level: 1.5, Title Bar Zoom: 1/1.5, Coordinate Multiplier: 1.5 * 1.0 / 1.5 = 1.0143zoom *= dom.getDomNodeZoomLevel(anchor);144145if (isClipped) {146// Element is partially out of viewport: always place at bottom-right visible corner147x = Math.min(Math.max(clientRect.right, 0), vw);148y = Math.min(Math.max(clientRect.bottom, 0), vh);149} else {150// Position according to the axis alignment and the anchor alignment:151// `HORIZONTAL` aligns at the top left or right of the anchor and152// `VERTICAL` aligns at the bottom left of the anchor.153if (delegate.anchorAxisAlignment === AnchorAxisAlignment.HORIZONTAL) {154if (delegate.anchorAlignment === AnchorAlignment.LEFT) {155x = elementPosition.left;156y = elementPosition.top;157} else {158x = elementPosition.left + elementPosition.width;159y = elementPosition.top;160}161162if (!isMacintosh) {163const window = dom.getWindow(anchor);164const availableHeightForMenu = window.screen.height - y;165if (availableHeightForMenu < actions.length * (isWindows ? 45 : 32) /* guess of 1 menu item height */) {166// this is a guess to detect whether the context menu would167// open to the bottom from this point or to the top. If the168// menu opens to the top, make sure to align it to the bottom169// of the anchor and not to the top.170// this seems to be only necessary for Windows and Linux.171y += elementPosition.height;172}173}174} else {175if (delegate.anchorAlignment === AnchorAlignment.LEFT) {176x = elementPosition.left;177y = elementPosition.top + elementPosition.height;178} else {179x = elementPosition.left + elementPosition.width;180y = elementPosition.top + elementPosition.height;181}182}183}184185// Shift macOS menus by a few pixels below elements186// to account for extra padding on top of native menu187// https://github.com/microsoft/vscode/issues/84231188if (isMacintosh) {189y += 4 / zoom;190}191} else if (isAnchor(anchor)) {192x = anchor.x;193y = anchor.y;194} else {195// We leave x/y undefined in this case which will result in196// Electron taking care of opening the menu at the cursor position.197}198199if (typeof x === 'number') {200x = Math.floor(x * zoom);201}202203if (typeof y === 'number') {204y = Math.floor(y * zoom);205}206207popup(menu, { x, y, positioningItem: delegate.autoSelectFirstItem ? 0 : undefined, }, () => onHide());208209this._onDidShowContextMenu.fire();210}211}212213private createMenu(delegate: IContextMenuDelegate, entries: readonly IAction[], onHide: () => void, submenuIds = new Set<string>()): IContextMenuItem[] {214return coalesce(entries.map(entry => this.createMenuItem(delegate, entry, onHide, submenuIds)));215}216217private createMenuItem(delegate: IContextMenuDelegate, entry: IAction, onHide: () => void, submenuIds: Set<string>): IContextMenuItem | undefined {218// Separator219if (entry instanceof Separator) {220return { type: 'separator' };221}222223// Submenu224if (entry instanceof SubmenuAction) {225if (submenuIds.has(entry.id)) {226console.warn(`Found submenu cycle: ${entry.id}`);227return undefined;228}229230return {231label: unmnemonicLabel(stripIcons(entry.label)).trim(),232submenu: this.createMenu(delegate, entry.actions, onHide, new Set([...submenuIds, entry.id]))233};234}235236// Normal Menu Item237else {238let type: 'radio' | 'checkbox' | undefined = undefined;239if (!!entry.checked) {240if (typeof delegate.getCheckedActionsRepresentation === 'function') {241type = delegate.getCheckedActionsRepresentation(entry);242} else {243type = 'checkbox';244}245}246247const item: IContextMenuItem = {248label: unmnemonicLabel(stripIcons(entry.label)).trim(),249checked: !!entry.checked,250type,251enabled: !!entry.enabled,252click: event => {253254// To preserve pre-electron-2.x behaviour, we first trigger255// the onHide callback and then the action.256// Fixes https://github.com/microsoft/vscode/issues/45601257onHide();258259// Run action which will close the menu260this.runAction(entry, delegate, event);261}262};263264const keybinding = !!delegate.getKeyBinding ? delegate.getKeyBinding(entry) : this.keybindingService.lookupKeybinding(entry.id);265if (keybinding) {266const electronAccelerator = keybinding.getElectronAccelerator();267if (electronAccelerator) {268item.accelerator = electronAccelerator;269} else {270const label = keybinding.getLabel();271if (label) {272item.label = `${item.label} [${label}]`;273}274}275}276277return item;278}279}280281private async runAction(actionToRun: IAction, delegate: IContextMenuDelegate, event: IContextMenuEvent): Promise<void> {282if (!delegate.skipTelemetry) {283this.telemetryService.publicLog2<WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification>('workbenchActionExecuted', { id: actionToRun.id, from: 'contextMenu' });284}285286const context = delegate.getActionsContext ? delegate.getActionsContext(event) : undefined;287288try {289if (delegate.actionRunner) {290await delegate.actionRunner.run(actionToRun, context);291} else if (actionToRun.enabled) {292await actionToRun.run(context);293}294} catch (error) {295this.notificationService.error(error);296}297}298}299300registerSingleton(IContextMenuService, ContextMenuService, InstantiationType.Delayed);301302303