Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/services/contextmenu/electron-browser/contextmenuService.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { IAction, WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification, Separator, SubmenuAction } from '../../../../base/common/actions.js';
7
import * as dom from '../../../../base/browser/dom.js';
8
import { IContextMenuMenuDelegate, IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js';
9
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
10
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
11
import { getZoomFactor } from '../../../../base/browser/browser.js';
12
import { unmnemonicLabel } from '../../../../base/common/labels.js';
13
import { INotificationService } from '../../../../platform/notification/common/notification.js';
14
import { IContextMenuDelegate, IContextMenuEvent } from '../../../../base/browser/contextmenu.js';
15
import { createSingleCallFunction } from '../../../../base/common/functional.js';
16
import { IContextMenuItem } from '../../../../base/parts/contextmenu/common/contextmenu.js';
17
import { popup } from '../../../../base/parts/contextmenu/electron-browser/contextmenu.js';
18
import { hasNativeContextMenu, MenuSettings } from '../../../../platform/window/common/window.js';
19
import { isMacintosh, isWindows } from '../../../../base/common/platform.js';
20
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
21
import { ContextMenuMenuDelegate, ContextMenuService as HTMLContextMenuService } from '../../../../platform/contextview/browser/contextMenuService.js';
22
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
23
import { stripIcons } from '../../../../base/common/iconLabels.js';
24
import { coalesce } from '../../../../base/common/arrays.js';
25
import { Event, Emitter } from '../../../../base/common/event.js';
26
import { AnchorAlignment, AnchorAxisAlignment, isAnchor } from '../../../../base/browser/ui/contextview/contextview.js';
27
import { IMenuService } from '../../../../platform/actions/common/actions.js';
28
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
29
import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
30
31
export class ContextMenuService implements IContextMenuService {
32
33
declare readonly _serviceBrand: undefined;
34
35
private impl: HTMLContextMenuService | NativeContextMenuService;
36
private listener?: IDisposable;
37
38
get onDidShowContextMenu(): Event<void> { return this.impl.onDidShowContextMenu; }
39
get onDidHideContextMenu(): Event<void> { return this.impl.onDidHideContextMenu; }
40
41
constructor(
42
@INotificationService notificationService: INotificationService,
43
@ITelemetryService telemetryService: ITelemetryService,
44
@IKeybindingService keybindingService: IKeybindingService,
45
@IConfigurationService configurationService: IConfigurationService,
46
@IContextViewService contextViewService: IContextViewService,
47
@IMenuService menuService: IMenuService,
48
@IContextKeyService contextKeyService: IContextKeyService,
49
) {
50
function createContextMenuService(native: boolean) {
51
return native ?
52
new NativeContextMenuService(notificationService, telemetryService, keybindingService, menuService, contextKeyService)
53
: new HTMLContextMenuService(telemetryService, notificationService, contextViewService, keybindingService, menuService, contextKeyService);
54
}
55
56
// set initial context menu service
57
let isNativeContextMenu = hasNativeContextMenu(configurationService);
58
this.impl = createContextMenuService(isNativeContextMenu);
59
60
// MacOS does not need a restart when the menu style changes
61
// It should update the context menu style on menu style configuration change
62
if (isMacintosh) {
63
this.listener = configurationService.onDidChangeConfiguration(e => {
64
if (!e.affectsConfiguration(MenuSettings.MenuStyle)) {
65
return;
66
}
67
68
const newIsNativeContextMenu = hasNativeContextMenu(configurationService);
69
if (newIsNativeContextMenu === isNativeContextMenu) {
70
return;
71
}
72
73
this.impl.dispose();
74
this.impl = createContextMenuService(newIsNativeContextMenu);
75
isNativeContextMenu = newIsNativeContextMenu;
76
});
77
}
78
}
79
80
dispose(): void {
81
this.listener?.dispose();
82
this.impl.dispose();
83
}
84
85
showContextMenu(delegate: IContextMenuDelegate | IContextMenuMenuDelegate): void {
86
this.impl.showContextMenu(delegate);
87
}
88
}
89
90
class NativeContextMenuService extends Disposable implements IContextMenuService {
91
92
declare readonly _serviceBrand: undefined;
93
94
private readonly _onDidShowContextMenu = this._store.add(new Emitter<void>());
95
readonly onDidShowContextMenu = this._onDidShowContextMenu.event;
96
97
private readonly _onDidHideContextMenu = this._store.add(new Emitter<void>());
98
readonly onDidHideContextMenu = this._onDidHideContextMenu.event;
99
100
constructor(
101
@INotificationService private readonly notificationService: INotificationService,
102
@ITelemetryService private readonly telemetryService: ITelemetryService,
103
@IKeybindingService private readonly keybindingService: IKeybindingService,
104
@IMenuService private readonly menuService: IMenuService,
105
@IContextKeyService private readonly contextKeyService: IContextKeyService
106
) {
107
super();
108
}
109
110
showContextMenu(delegate: IContextMenuDelegate | IContextMenuMenuDelegate): void {
111
112
delegate = ContextMenuMenuDelegate.transform(delegate, this.menuService, this.contextKeyService);
113
114
const actions = delegate.getActions();
115
if (actions.length) {
116
const onHide = createSingleCallFunction(() => {
117
delegate.onHide?.(false);
118
119
dom.ModifierKeyEmitter.getInstance().resetKeyStatus();
120
this._onDidHideContextMenu.fire();
121
});
122
123
const menu = this.createMenu(delegate, actions, onHide);
124
const anchor = delegate.getAnchor();
125
126
let x: number | undefined;
127
let y: number | undefined;
128
129
let zoom = getZoomFactor(dom.isHTMLElement(anchor) ? dom.getWindow(anchor) : dom.getActiveWindow());
130
if (dom.isHTMLElement(anchor)) {
131
const clientRect = anchor.getBoundingClientRect();
132
const elementPosition = { left: clientRect.left, top: clientRect.top, width: clientRect.width, height: clientRect.height };
133
134
// Determine if element is clipped by viewport; if so we'll use the bottom-right of the visible portion
135
const win = dom.getWindow(anchor);
136
const vw = win.innerWidth;
137
const vh = win.innerHeight;
138
const isClipped = clientRect.left < 0 || clientRect.top < 0 || clientRect.right > vw || clientRect.bottom > vh;
139
140
// When drawing context menus, we adjust the pixel position for native menus using zoom level
141
// In areas where zoom is applied to the element or its ancestors, we need to adjust accordingly
142
// e.g. The title bar has counter zoom behavior meaning it applies the inverse of zoom level.
143
// Window Zoom Level: 1.5, Title Bar Zoom: 1/1.5, Coordinate Multiplier: 1.5 * 1.0 / 1.5 = 1.0
144
zoom *= dom.getDomNodeZoomLevel(anchor);
145
146
if (isClipped) {
147
// Element is partially out of viewport: always place at bottom-right visible corner
148
x = Math.min(Math.max(clientRect.right, 0), vw);
149
y = Math.min(Math.max(clientRect.bottom, 0), vh);
150
} else {
151
// Position according to the axis alignment and the anchor alignment:
152
// `HORIZONTAL` aligns at the top left or right of the anchor and
153
// `VERTICAL` aligns at the bottom left of the anchor.
154
if (delegate.anchorAxisAlignment === AnchorAxisAlignment.HORIZONTAL) {
155
if (delegate.anchorAlignment === AnchorAlignment.LEFT) {
156
x = elementPosition.left;
157
y = elementPosition.top;
158
} else {
159
x = elementPosition.left + elementPosition.width;
160
y = elementPosition.top;
161
}
162
163
if (!isMacintosh) {
164
const window = dom.getWindow(anchor);
165
const availableHeightForMenu = window.screen.height - y;
166
if (availableHeightForMenu < actions.length * (isWindows ? 45 : 32) /* guess of 1 menu item height */) {
167
// this is a guess to detect whether the context menu would
168
// open to the bottom from this point or to the top. If the
169
// menu opens to the top, make sure to align it to the bottom
170
// of the anchor and not to the top.
171
// this seems to be only necessary for Windows and Linux.
172
y += elementPosition.height;
173
}
174
}
175
} else {
176
if (delegate.anchorAlignment === AnchorAlignment.LEFT) {
177
x = elementPosition.left;
178
y = elementPosition.top + elementPosition.height;
179
} else {
180
x = elementPosition.left + elementPosition.width;
181
y = elementPosition.top + elementPosition.height;
182
}
183
}
184
}
185
186
// Shift macOS menus by a few pixels below elements
187
// to account for extra padding on top of native menu
188
// https://github.com/microsoft/vscode/issues/84231
189
if (isMacintosh) {
190
y += 4 / zoom;
191
}
192
} else if (isAnchor(anchor)) {
193
x = anchor.x;
194
y = anchor.y;
195
} else {
196
// We leave x/y undefined in this case which will result in
197
// Electron taking care of opening the menu at the cursor position.
198
}
199
200
if (typeof x === 'number') {
201
x = Math.floor(x * zoom);
202
}
203
204
if (typeof y === 'number') {
205
y = Math.floor(y * zoom);
206
}
207
208
popup(menu, { x, y, positioningItem: delegate.autoSelectFirstItem ? 0 : undefined, }, () => onHide());
209
210
this._onDidShowContextMenu.fire();
211
}
212
}
213
214
private createMenu(delegate: IContextMenuDelegate, entries: readonly IAction[], onHide: () => void, submenuIds = new Set<string>()): IContextMenuItem[] {
215
return coalesce(entries.map(entry => this.createMenuItem(delegate, entry, onHide, submenuIds)));
216
}
217
218
private createMenuItem(delegate: IContextMenuDelegate, entry: IAction, onHide: () => void, submenuIds: Set<string>): IContextMenuItem | undefined {
219
// Separator
220
if (entry instanceof Separator) {
221
return { type: 'separator' };
222
}
223
224
// Submenu
225
if (entry instanceof SubmenuAction) {
226
if (submenuIds.has(entry.id)) {
227
console.warn(`Found submenu cycle: ${entry.id}`);
228
return undefined;
229
}
230
231
return {
232
label: unmnemonicLabel(stripIcons(entry.label)).trim(),
233
submenu: this.createMenu(delegate, entry.actions, onHide, new Set([...submenuIds, entry.id]))
234
};
235
}
236
237
// Normal Menu Item
238
else {
239
let type: 'radio' | 'checkbox' | undefined = undefined;
240
if (!!entry.checked) {
241
if (typeof delegate.getCheckedActionsRepresentation === 'function') {
242
type = delegate.getCheckedActionsRepresentation(entry);
243
} else {
244
type = 'checkbox';
245
}
246
}
247
248
const item: IContextMenuItem = {
249
label: unmnemonicLabel(stripIcons(entry.label)).trim(),
250
checked: !!entry.checked,
251
type,
252
enabled: !!entry.enabled,
253
click: event => {
254
255
// To preserve pre-electron-2.x behaviour, we first trigger
256
// the onHide callback and then the action.
257
// Fixes https://github.com/microsoft/vscode/issues/45601
258
onHide();
259
260
// Run action which will close the menu
261
this.runAction(entry, delegate, event);
262
}
263
};
264
265
const keybinding = !!delegate.getKeyBinding ? delegate.getKeyBinding(entry) : this.keybindingService.lookupKeybinding(entry.id);
266
if (keybinding) {
267
const electronAccelerator = keybinding.getElectronAccelerator();
268
if (electronAccelerator) {
269
item.accelerator = electronAccelerator;
270
} else {
271
const label = keybinding.getLabel();
272
if (label) {
273
item.label = `${item.label} [${label}]`;
274
}
275
}
276
}
277
278
return item;
279
}
280
}
281
282
private async runAction(actionToRun: IAction, delegate: IContextMenuDelegate, event: IContextMenuEvent): Promise<void> {
283
if (!delegate.skipTelemetry) {
284
this.telemetryService.publicLog2<WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification>('workbenchActionExecuted', { id: actionToRun.id, from: 'contextMenu' });
285
}
286
287
const context = delegate.getActionsContext ? delegate.getActionsContext(event) : undefined;
288
289
try {
290
if (delegate.actionRunner) {
291
await delegate.actionRunner.run(actionToRun, context);
292
} else if (actionToRun.enabled) {
293
await actionToRun.run(context);
294
}
295
} catch (error) {
296
this.notificationService.error(error);
297
}
298
}
299
}
300
301
registerSingleton(IContextMenuService, ContextMenuService, InstantiationType.Delayed);
302
303