menu-item-roles.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import { app, BrowserWindow, session, webContents, WebContents, MenuItemConstructorOptions } from 'electron/main';
  2. const isMac = process.platform === 'darwin';
  3. const isWindows = process.platform === 'win32';
  4. const isLinux = process.platform === 'linux';
  5. type RoleId = 'about' | 'close' | 'copy' | 'cut' | 'delete' | 'forcereload' | 'front' | 'help' | 'hide' | 'hideothers' | 'minimize' |
  6. 'paste' | 'pasteandmatchstyle' | 'quit' | 'redo' | 'reload' | 'resetzoom' | 'selectall' | 'services' | 'recentdocuments' | 'clearrecentdocuments' |
  7. 'showsubstitutions' | 'togglesmartquotes' | 'togglesmartdashes' | 'toggletextreplacement' | 'startspeaking' | 'stopspeaking' |
  8. 'toggledevtools' | 'togglefullscreen' | 'undo' | 'unhide' | 'window' | 'zoom' | 'zoomin' | 'zoomout' | 'togglespellchecker' |
  9. 'appmenu' | 'filemenu' | 'editmenu' | 'viewmenu' | 'windowmenu' | 'sharemenu'
  10. interface Role {
  11. label: string;
  12. accelerator?: string;
  13. checked?: boolean;
  14. windowMethod?: ((window: BrowserWindow) => void);
  15. webContentsMethod?: ((webContents: WebContents) => void);
  16. appMethod?: () => void;
  17. registerAccelerator?: boolean;
  18. nonNativeMacOSRole?: boolean;
  19. submenu?: MenuItemConstructorOptions[];
  20. }
  21. export const roleList: Record<RoleId, Role> = {
  22. about: {
  23. get label () {
  24. return isLinux ? 'About' : `About ${app.name}`;
  25. },
  26. ...((isWindows || isLinux) && { appMethod: () => app.showAboutPanel() })
  27. },
  28. close: {
  29. label: isMac ? 'Close Window' : 'Close',
  30. accelerator: 'CommandOrControl+W',
  31. windowMethod: w => w.close()
  32. },
  33. copy: {
  34. label: 'Copy',
  35. accelerator: 'CommandOrControl+C',
  36. webContentsMethod: wc => wc.copy(),
  37. registerAccelerator: false
  38. },
  39. cut: {
  40. label: 'Cut',
  41. accelerator: 'CommandOrControl+X',
  42. webContentsMethod: wc => wc.cut(),
  43. registerAccelerator: false
  44. },
  45. delete: {
  46. label: 'Delete',
  47. webContentsMethod: wc => wc.delete()
  48. },
  49. forcereload: {
  50. label: 'Force Reload',
  51. accelerator: 'Shift+CmdOrCtrl+R',
  52. nonNativeMacOSRole: true,
  53. windowMethod: (window: BrowserWindow) => {
  54. window.webContents.reloadIgnoringCache();
  55. }
  56. },
  57. front: {
  58. label: 'Bring All to Front'
  59. },
  60. help: {
  61. label: 'Help'
  62. },
  63. hide: {
  64. get label () {
  65. return `Hide ${app.name}`;
  66. },
  67. accelerator: 'Command+H'
  68. },
  69. hideothers: {
  70. label: 'Hide Others',
  71. accelerator: 'Command+Alt+H'
  72. },
  73. minimize: {
  74. label: 'Minimize',
  75. accelerator: 'CommandOrControl+M',
  76. windowMethod: w => w.minimize()
  77. },
  78. paste: {
  79. label: 'Paste',
  80. accelerator: 'CommandOrControl+V',
  81. webContentsMethod: wc => wc.paste(),
  82. registerAccelerator: false
  83. },
  84. pasteandmatchstyle: {
  85. label: 'Paste and Match Style',
  86. accelerator: isMac ? 'Cmd+Option+Shift+V' : 'Shift+CommandOrControl+V',
  87. webContentsMethod: wc => wc.pasteAndMatchStyle(),
  88. registerAccelerator: false
  89. },
  90. quit: {
  91. get label () {
  92. switch (process.platform) {
  93. case 'darwin': return `Quit ${app.name}`;
  94. case 'win32': return 'Exit';
  95. default: return 'Quit';
  96. }
  97. },
  98. accelerator: isWindows ? undefined : 'CommandOrControl+Q',
  99. appMethod: () => app.quit()
  100. },
  101. redo: {
  102. label: 'Redo',
  103. accelerator: isWindows ? 'Control+Y' : 'Shift+CommandOrControl+Z',
  104. webContentsMethod: wc => wc.redo()
  105. },
  106. reload: {
  107. label: 'Reload',
  108. accelerator: 'CmdOrCtrl+R',
  109. nonNativeMacOSRole: true,
  110. windowMethod: w => w.reload()
  111. },
  112. resetzoom: {
  113. label: 'Actual Size',
  114. accelerator: 'CommandOrControl+0',
  115. nonNativeMacOSRole: true,
  116. webContentsMethod: (webContents: WebContents) => {
  117. webContents.zoomLevel = 0;
  118. }
  119. },
  120. selectall: {
  121. label: 'Select All',
  122. accelerator: 'CommandOrControl+A',
  123. webContentsMethod: wc => wc.selectAll()
  124. },
  125. services: {
  126. label: 'Services'
  127. },
  128. recentdocuments: {
  129. label: 'Open Recent'
  130. },
  131. clearrecentdocuments: {
  132. label: 'Clear Menu'
  133. },
  134. showsubstitutions: {
  135. label: 'Show Substitutions'
  136. },
  137. togglesmartquotes: {
  138. label: 'Smart Quotes'
  139. },
  140. togglesmartdashes: {
  141. label: 'Smart Dashes'
  142. },
  143. toggletextreplacement: {
  144. label: 'Text Replacement'
  145. },
  146. startspeaking: {
  147. label: 'Start Speaking'
  148. },
  149. stopspeaking: {
  150. label: 'Stop Speaking'
  151. },
  152. toggledevtools: {
  153. label: 'Toggle Developer Tools',
  154. accelerator: isMac ? 'Alt+Command+I' : 'Ctrl+Shift+I',
  155. nonNativeMacOSRole: true,
  156. webContentsMethod: wc => {
  157. const bw = wc.getOwnerBrowserWindow();
  158. if (bw) bw.webContents.toggleDevTools();
  159. }
  160. },
  161. togglefullscreen: {
  162. label: 'Toggle Full Screen',
  163. accelerator: isMac ? 'Control+Command+F' : 'F11',
  164. windowMethod: (window: BrowserWindow) => {
  165. window.setFullScreen(!window.isFullScreen());
  166. }
  167. },
  168. undo: {
  169. label: 'Undo',
  170. accelerator: 'CommandOrControl+Z',
  171. webContentsMethod: wc => wc.undo()
  172. },
  173. unhide: {
  174. label: 'Show All'
  175. },
  176. window: {
  177. label: 'Window'
  178. },
  179. zoom: {
  180. label: 'Zoom'
  181. },
  182. zoomin: {
  183. label: 'Zoom In',
  184. accelerator: 'CommandOrControl+Plus',
  185. nonNativeMacOSRole: true,
  186. webContentsMethod: (webContents: WebContents) => {
  187. webContents.zoomLevel += 0.5;
  188. }
  189. },
  190. zoomout: {
  191. label: 'Zoom Out',
  192. accelerator: 'CommandOrControl+-',
  193. nonNativeMacOSRole: true,
  194. webContentsMethod: (webContents: WebContents) => {
  195. webContents.zoomLevel -= 0.5;
  196. }
  197. },
  198. togglespellchecker: {
  199. label: 'Check Spelling While Typing',
  200. get checked () {
  201. const wc = webContents.getFocusedWebContents();
  202. const ses = wc ? wc.session : session.defaultSession;
  203. return ses.spellCheckerEnabled;
  204. },
  205. nonNativeMacOSRole: true,
  206. webContentsMethod: (wc: WebContents) => {
  207. const ses = wc ? wc.session : session.defaultSession;
  208. ses.spellCheckerEnabled = !ses.spellCheckerEnabled;
  209. }
  210. },
  211. // App submenu should be used for Mac only
  212. appmenu: {
  213. get label () {
  214. return app.name;
  215. },
  216. submenu: [
  217. { role: 'about' },
  218. { type: 'separator' },
  219. { role: 'services' },
  220. { type: 'separator' },
  221. { role: 'hide' },
  222. { role: 'hideOthers' },
  223. { role: 'unhide' },
  224. { type: 'separator' },
  225. { role: 'quit' }
  226. ]
  227. },
  228. // File submenu
  229. filemenu: {
  230. label: 'File',
  231. submenu: [
  232. isMac ? { role: 'close' } : { role: 'quit' }
  233. ]
  234. },
  235. // Edit submenu
  236. editmenu: {
  237. label: 'Edit',
  238. submenu: [
  239. { role: 'undo' },
  240. { role: 'redo' },
  241. { type: 'separator' },
  242. { role: 'cut' },
  243. { role: 'copy' },
  244. { role: 'paste' },
  245. ...(isMac ? [
  246. { role: 'pasteAndMatchStyle' },
  247. { role: 'delete' },
  248. { role: 'selectAll' },
  249. { type: 'separator' },
  250. {
  251. label: 'Substitutions',
  252. submenu: [
  253. { role: 'showSubstitutions' },
  254. { type: 'separator' },
  255. { role: 'toggleSmartQuotes' },
  256. { role: 'toggleSmartDashes' },
  257. { role: 'toggleTextReplacement' }
  258. ]
  259. },
  260. {
  261. label: 'Speech',
  262. submenu: [
  263. { role: 'startSpeaking' },
  264. { role: 'stopSpeaking' }
  265. ]
  266. }
  267. ] as MenuItemConstructorOptions[] : [
  268. { role: 'delete' },
  269. { type: 'separator' },
  270. { role: 'selectAll' }
  271. ] as MenuItemConstructorOptions[])
  272. ]
  273. },
  274. // View submenu
  275. viewmenu: {
  276. label: 'View',
  277. submenu: [
  278. { role: 'reload' },
  279. { role: 'forceReload' },
  280. { role: 'toggleDevTools' },
  281. { type: 'separator' },
  282. { role: 'resetZoom' },
  283. { role: 'zoomIn' },
  284. { role: 'zoomOut' },
  285. { type: 'separator' },
  286. { role: 'togglefullscreen' }
  287. ]
  288. },
  289. // Window submenu
  290. windowmenu: {
  291. label: 'Window',
  292. submenu: [
  293. { role: 'minimize' },
  294. { role: 'zoom' },
  295. ...(isMac ? [
  296. { type: 'separator' },
  297. { role: 'front' }
  298. ] as MenuItemConstructorOptions[] : [
  299. { role: 'close' }
  300. ] as MenuItemConstructorOptions[])
  301. ]
  302. },
  303. // Share submenu
  304. sharemenu: {
  305. label: 'Share',
  306. submenu: []
  307. }
  308. };
  309. const hasRole = (role: keyof typeof roleList) => {
  310. return Object.hasOwn(roleList, role);
  311. };
  312. const canExecuteRole = (role: keyof typeof roleList) => {
  313. if (!hasRole(role)) return false;
  314. if (!isMac) return true;
  315. // macOS handles all roles natively except for a few
  316. return roleList[role].nonNativeMacOSRole;
  317. };
  318. export function getDefaultType (role: RoleId) {
  319. if (shouldOverrideCheckStatus(role)) return 'checkbox';
  320. return 'normal';
  321. }
  322. export function getDefaultLabel (role: RoleId) {
  323. return hasRole(role) ? roleList[role].label : '';
  324. }
  325. export function getCheckStatus (role: RoleId) {
  326. if (hasRole(role)) return roleList[role].checked;
  327. }
  328. export function shouldOverrideCheckStatus (role: RoleId) {
  329. return hasRole(role) && Object.hasOwn(roleList[role], 'checked');
  330. }
  331. export function getDefaultAccelerator (role: RoleId) {
  332. if (hasRole(role)) return roleList[role].accelerator;
  333. }
  334. export function shouldRegisterAccelerator (role: RoleId) {
  335. const hasRoleRegister = hasRole(role) && roleList[role].registerAccelerator !== undefined;
  336. return hasRoleRegister ? roleList[role].registerAccelerator : true;
  337. }
  338. export function getDefaultSubmenu (role: RoleId) {
  339. if (!hasRole(role)) return;
  340. let { submenu } = roleList[role];
  341. // remove null items from within the submenu
  342. if (Array.isArray(submenu)) {
  343. submenu = submenu.filter((item) => item != null);
  344. }
  345. return submenu;
  346. }
  347. export function execute (role: RoleId, focusedWindow: BrowserWindow, focusedWebContents: WebContents) {
  348. if (!canExecuteRole(role)) return false;
  349. const { appMethod, webContentsMethod, windowMethod } = roleList[role];
  350. if (appMethod) {
  351. appMethod();
  352. return true;
  353. }
  354. if (windowMethod && focusedWindow != null) {
  355. windowMethod(focusedWindow);
  356. return true;
  357. }
  358. if (webContentsMethod && focusedWebContents != null) {
  359. webContentsMethod(focusedWebContents);
  360. return true;
  361. }
  362. return false;
  363. }