menu.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. 'use strict'
  2. const { TopLevelWindow, MenuItem, webContents } = require('electron')
  3. const { sortMenuItems } = require('@electron/internal/browser/api/menu-utils')
  4. const EventEmitter = require('events').EventEmitter
  5. const v8Util = process.electronBinding('v8_util')
  6. const bindings = process.electronBinding('menu')
  7. const { Menu } = bindings
  8. let applicationMenu = null
  9. let groupIdIndex = 0
  10. Object.setPrototypeOf(Menu.prototype, EventEmitter.prototype)
  11. // Menu Delegate.
  12. // This object should hold no reference to |Menu| to avoid cyclic reference.
  13. const delegate = {
  14. isCommandIdChecked: (menu, id) => menu.commandsMap[id] ? menu.commandsMap[id].checked : undefined,
  15. isCommandIdEnabled: (menu, id) => menu.commandsMap[id] ? menu.commandsMap[id].enabled : undefined,
  16. shouldCommandIdWorkWhenHidden: (menu, id) => menu.commandsMap[id] ? menu.commandsMap[id].acceleratorWorksWhenHidden : undefined,
  17. isCommandIdVisible: (menu, id) => menu.commandsMap[id] ? menu.commandsMap[id].visible : undefined,
  18. getAcceleratorForCommandId: (menu, id, useDefaultAccelerator) => {
  19. const command = menu.commandsMap[id]
  20. if (!command) return
  21. if (command.accelerator != null) return command.accelerator
  22. if (useDefaultAccelerator) return command.getDefaultRoleAccelerator()
  23. },
  24. shouldRegisterAcceleratorForCommandId: (menu, id) => menu.commandsMap[id] ? menu.commandsMap[id].registerAccelerator : undefined,
  25. executeCommand: (menu, event, id) => {
  26. const command = menu.commandsMap[id]
  27. if (!command) return
  28. command.click(event, TopLevelWindow.getFocusedWindow(), webContents.getFocusedWebContents())
  29. },
  30. menuWillShow: (menu) => {
  31. // Ensure radio groups have at least one menu item selected
  32. for (const id in menu.groupsMap) {
  33. const found = menu.groupsMap[id].find(item => item.checked) || null
  34. if (!found) v8Util.setHiddenValue(menu.groupsMap[id][0], 'checked', true)
  35. }
  36. }
  37. }
  38. /* Instance Methods */
  39. Menu.prototype._init = function () {
  40. this.commandsMap = {}
  41. this.groupsMap = {}
  42. this.items = []
  43. this.delegate = delegate
  44. }
  45. Menu.prototype.popup = function (options = {}) {
  46. if (options == null || typeof options !== 'object') {
  47. throw new TypeError('Options must be an object')
  48. }
  49. let { window, x, y, positioningItem, callback } = options
  50. // no callback passed
  51. if (!callback || typeof callback !== 'function') callback = () => {}
  52. // set defaults
  53. if (typeof x !== 'number') x = -1
  54. if (typeof y !== 'number') y = -1
  55. if (typeof positioningItem !== 'number') positioningItem = -1
  56. // find which window to use
  57. const wins = TopLevelWindow.getAllWindows()
  58. if (!wins || wins.indexOf(window) === -1) {
  59. window = TopLevelWindow.getFocusedWindow()
  60. if (!window && wins && wins.length > 0) {
  61. window = wins[0]
  62. }
  63. if (!window) {
  64. throw new Error(`Cannot open Menu without a TopLevelWindow present`)
  65. }
  66. }
  67. this.popupAt(window, x, y, positioningItem, callback)
  68. return { browserWindow: window, x, y, position: positioningItem }
  69. }
  70. Menu.prototype.closePopup = function (window) {
  71. if (window instanceof TopLevelWindow) {
  72. this.closePopupAt(window.id)
  73. } else {
  74. // Passing -1 (invalid) would make closePopupAt close the all menu runners
  75. // belong to this menu.
  76. this.closePopupAt(-1)
  77. }
  78. }
  79. Menu.prototype.getMenuItemById = function (id) {
  80. const items = this.items
  81. let found = items.find(item => item.id === id) || null
  82. for (let i = 0; !found && i < items.length; i++) {
  83. if (items[i].submenu) {
  84. found = items[i].submenu.getMenuItemById(id)
  85. }
  86. }
  87. return found
  88. }
  89. Menu.prototype.append = function (item) {
  90. return this.insert(this.getItemCount(), item)
  91. }
  92. Menu.prototype.insert = function (pos, item) {
  93. if ((item ? item.constructor : void 0) !== MenuItem) {
  94. throw new TypeError('Invalid item')
  95. }
  96. if (pos < 0) {
  97. throw new RangeError(`Position ${pos} cannot be less than 0`)
  98. } else if (pos > this.getItemCount()) {
  99. throw new RangeError(`Position ${pos} cannot be greater than the total MenuItem count`)
  100. }
  101. // insert item depending on its type
  102. insertItemByType.call(this, item, pos)
  103. // set item properties
  104. if (item.sublabel) this.setSublabel(pos, item.sublabel)
  105. if (item.icon) this.setIcon(pos, item.icon)
  106. if (item.role) this.setRole(pos, item.role)
  107. // Make menu accessable to items.
  108. item.overrideReadOnlyProperty('menu', this)
  109. // Remember the items.
  110. this.items.splice(pos, 0, item)
  111. this.commandsMap[item.commandId] = item
  112. }
  113. Menu.prototype._callMenuWillShow = function () {
  114. if (this.delegate) this.delegate.menuWillShow(this)
  115. this.items.forEach(item => {
  116. if (item.submenu) item.submenu._callMenuWillShow()
  117. })
  118. }
  119. /* Static Methods */
  120. Menu.getApplicationMenu = () => applicationMenu
  121. Menu.sendActionToFirstResponder = bindings.sendActionToFirstResponder
  122. // set application menu with a preexisting menu
  123. Menu.setApplicationMenu = function (menu) {
  124. if (menu && menu.constructor !== Menu) {
  125. throw new TypeError('Invalid menu')
  126. }
  127. applicationMenu = menu
  128. v8Util.setHiddenValue(global, 'applicationMenuSet', true)
  129. if (process.platform === 'darwin') {
  130. if (!menu) return
  131. menu._callMenuWillShow()
  132. bindings.setApplicationMenu(menu)
  133. } else {
  134. const windows = TopLevelWindow.getAllWindows()
  135. return windows.map(w => w.setMenu(menu))
  136. }
  137. }
  138. Menu.buildFromTemplate = function (template) {
  139. if (!Array.isArray(template)) {
  140. throw new TypeError('Invalid template for Menu: Menu template must be an array')
  141. }
  142. if (!areValidTemplateItems(template)) {
  143. throw new TypeError('Invalid template for MenuItem: must have at least one of label, role or type')
  144. }
  145. const filtered = removeExtraSeparators(template)
  146. const sorted = sortTemplate(filtered)
  147. const menu = new Menu()
  148. sorted.forEach(item => {
  149. if (item instanceof MenuItem) {
  150. menu.append(item)
  151. } else {
  152. menu.append(new MenuItem(item))
  153. }
  154. })
  155. return menu
  156. }
  157. /* Helper Functions */
  158. // validate the template against having the wrong attribute
  159. function areValidTemplateItems (template) {
  160. return template.every(item =>
  161. item != null &&
  162. typeof item === 'object' &&
  163. (item.hasOwnProperty('label') ||
  164. item.hasOwnProperty('role') ||
  165. item.type === 'separator'))
  166. }
  167. function sortTemplate (template) {
  168. const sorted = sortMenuItems(template)
  169. for (const id in sorted) {
  170. const item = sorted[id]
  171. if (Array.isArray(item.submenu)) {
  172. item.submenu = sortTemplate(item.submenu)
  173. }
  174. }
  175. return sorted
  176. }
  177. // Search between separators to find a radio menu item and return its group id
  178. function generateGroupId (items, pos) {
  179. if (pos > 0) {
  180. for (let idx = pos - 1; idx >= 0; idx--) {
  181. if (items[idx].type === 'radio') return items[idx].groupId
  182. if (items[idx].type === 'separator') break
  183. }
  184. } else if (pos < items.length) {
  185. for (let idx = pos; idx <= items.length - 1; idx++) {
  186. if (items[idx].type === 'radio') return items[idx].groupId
  187. if (items[idx].type === 'separator') break
  188. }
  189. }
  190. groupIdIndex += 1
  191. return groupIdIndex
  192. }
  193. function removeExtraSeparators (items) {
  194. // fold adjacent separators together
  195. let ret = items.filter((e, idx, arr) => {
  196. if (e.visible === false) return true
  197. return e.type !== 'separator' || idx === 0 || arr[idx - 1].type !== 'separator'
  198. })
  199. // remove edge separators
  200. ret = ret.filter((e, idx, arr) => {
  201. if (e.visible === false) return true
  202. return e.type !== 'separator' || (idx !== 0 && idx !== arr.length - 1)
  203. })
  204. return ret
  205. }
  206. function insertItemByType (item, pos) {
  207. const types = {
  208. normal: () => this.insertItem(pos, item.commandId, item.label),
  209. checkbox: () => this.insertCheckItem(pos, item.commandId, item.label),
  210. separator: () => this.insertSeparator(pos),
  211. submenu: () => this.insertSubMenu(pos, item.commandId, item.label, item.submenu),
  212. radio: () => {
  213. // Grouping radio menu items
  214. item.overrideReadOnlyProperty('groupId', generateGroupId(this.items, pos))
  215. if (this.groupsMap[item.groupId] == null) {
  216. this.groupsMap[item.groupId] = []
  217. }
  218. this.groupsMap[item.groupId].push(item)
  219. // Setting a radio menu item should flip other items in the group.
  220. v8Util.setHiddenValue(item, 'checked', item.checked)
  221. Object.defineProperty(item, 'checked', {
  222. enumerable: true,
  223. get: () => v8Util.getHiddenValue(item, 'checked'),
  224. set: () => {
  225. this.groupsMap[item.groupId].forEach(other => {
  226. if (other !== item) v8Util.setHiddenValue(other, 'checked', false)
  227. })
  228. v8Util.setHiddenValue(item, 'checked', true)
  229. }
  230. })
  231. this.insertRadioItem(pos, item.commandId, item.label, item.groupId)
  232. }
  233. }
  234. types[item.type]()
  235. }
  236. module.exports = Menu