Skip to content

Commit 5673ce3

Browse files
committed
Add a desktop API to allow for opening custom context menus
1 parent 3f42c5d commit 5673ce3

File tree

4 files changed

+116
-18
lines changed

4 files changed

+116
-18
lines changed

src/context-menu.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import * as Electron from 'electron';
2+
import { UnreachableCheck } from './util';
3+
4+
export interface ContextMenuDefinition {
5+
position: { x: number; y: number };
6+
items: ContextMenuItem[];
7+
}
8+
9+
type ContextMenuItem =
10+
| ContextMenuOption
11+
| ContextMenuSubmenu
12+
| { type: 'separator' };
13+
14+
interface ContextMenuOption {
15+
type: 'option';
16+
id: string;
17+
label: string;
18+
enabled?: boolean;
19+
}
20+
21+
interface ContextMenuSubmenu {
22+
type: 'submenu';
23+
label: string;
24+
enabled?: boolean;
25+
items: ContextMenuItem[];
26+
}
27+
28+
type ContextMenuCallback = (item: Electron.MenuItem) => void;
29+
30+
31+
// This resolves either with an id of a selected option, or undefined if the menu is closes any other way.
32+
export function openContextMenu({ position, items }: ContextMenuDefinition) {
33+
return new Promise((resolve) => {
34+
const callback = (menuItem: Electron.MenuItem) => resolve(menuItem.id);
35+
36+
const menu = buildContextMenu(items, callback);
37+
menu.addListener('menu-will-close', () => {
38+
// Resolve has to be deferred briefly, because the click callback fires *after* menu-will-close.
39+
setTimeout(resolve, 0);
40+
});
41+
42+
menu.popup({ x: position.x, y: position.y });
43+
});
44+
}
45+
46+
function buildContextMenu(items: ContextMenuItem[], callback: ContextMenuCallback) {
47+
const menu = new Electron.Menu();
48+
49+
items
50+
.map((item) => buildContextMenuItem(item, callback))
51+
.forEach(menuItem => menu.append(menuItem));
52+
53+
return menu;
54+
}
55+
56+
function buildContextMenuItem(item: ContextMenuItem, callback: ContextMenuCallback): Electron.MenuItem {
57+
if (item.type === 'option') return buildContextMenuOption(item, callback);
58+
else if (item.type === 'submenu') return buildContextSubmenu(item, callback);
59+
else if (item.type === 'separator') return new Electron.MenuItem(item);
60+
else throw new UnreachableCheck(item, (i) => i.type);
61+
}
62+
63+
function buildContextMenuOption(option: ContextMenuOption, callback: ContextMenuCallback) {
64+
return new Electron.MenuItem({
65+
type: 'normal',
66+
id: option.id,
67+
label: option.label,
68+
enabled: option.enabled,
69+
click: callback
70+
});
71+
}
72+
73+
function buildContextSubmenu(option: ContextMenuSubmenu, callback: ContextMenuCallback) {
74+
const submenu = buildContextMenu(option.items, callback);
75+
return new Electron.MenuItem({
76+
label: option.label,
77+
enabled: option.enabled,
78+
submenu
79+
});
80+
}

src/index.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,14 @@ import * as yargs from 'yargs';
1717
import * as semver from 'semver';
1818
import * as rimraf from 'rimraf';
1919
const rmRF = promisify(rimraf);
20+
2021
import * as windowStateKeeper from 'electron-window-state';
2122
import { getSystemProxy } from 'os-proxy-config';
22-
2323
import registerContextMenu = require('electron-context-menu');
24-
registerContextMenu({
25-
showSaveImageAs: true
26-
});
2724

28-
import { getMenu, shouldAutoHideMenu } from './menu';
2925
import { getDeferred, delay } from './util';
26+
import { getMenu, shouldAutoHideMenu } from './menu';
27+
import { ContextMenuDefinition, openContextMenu } from './context-menu';
3028
import { stopServer } from './stop-server';
3129

3230
const packageJson = require('../package.json');
@@ -588,13 +586,17 @@ if (!amMainInstance) {
588586
});
589587
}
590588

591-
ipcMain.handle(
592-
'select-application',
593-
() =>
594-
dialog.showOpenDialogSync({
595-
properties:
589+
ipcMain.handle('select-application', () => {
590+
return dialog.showOpenDialogSync({
591+
properties:
596592
process.platform === 'darwin'
597-
? ['openFile', 'openDirectory', 'treatPackageAsDirectory']
598-
: ['openFile'],
599-
})?.[0]
600-
);
593+
? ['openFile', 'openDirectory', 'treatPackageAsDirectory']
594+
: ['openFile'],
595+
})?.[0];
596+
});
597+
598+
// Enable the default context menu
599+
registerContextMenu({ showSaveImageAs: true });
600+
601+
// Enable custom context menus, for special cases where the UI wants to define the options available
602+
ipcMain.handle('open-context-menu', (_event: {}, options: ContextMenuDefinition) => openContextMenu(options));

src/preload.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import {contextBridge, ipcRenderer} from 'electron';
1+
import { contextBridge, ipcRenderer } from 'electron';
2+
3+
import type { ContextMenuDefinition } from './context-menu';
24

35
contextBridge.exposeInMainWorld('desktopApi', {
4-
selectApplication: () => ipcRenderer.invoke('select-application'),
5-
});
6+
7+
selectApplication: () => ipcRenderer.invoke('select-application'),
8+
9+
openContextMenu: (options: ContextMenuDefinition) => ipcRenderer.invoke('open-context-menu', options)
10+
11+
});

src/util.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,14 @@ export function getDeferred<T = void>(): Deferred<T> {
2020
return { resolve, reject, promise } as any;
2121
}
2222

23-
export const delay = (delayMs: number) => new Promise<void>((resolve) => setTimeout(resolve, delayMs));
23+
export const delay = (delayMs: number) => new Promise<void>((resolve) => setTimeout(resolve, delayMs));
24+
25+
export class UnreachableCheck extends Error {
26+
27+
// getValue is used to allow logging properties (e.g. v.type) on expected-unreachable
28+
// values, instead of just logging [object Object].
29+
constructor(value: never, getValue: (v: any) => any = (x => x)) {
30+
super(`Unhandled case value: ${getValue(value)}`);
31+
}
32+
33+
}

0 commit comments

Comments
 (0)