menu.js 8.5 KB

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