menu-manager.coffee
1 path = require 'path' 2 3 _ = require 'underscore-plus' 4 {ipcRenderer} = require 'electron' 5 CSON = require 'season' 6 fs = require 'fs-plus' 7 {Disposable} = require 'event-kit' 8 9 MenuHelpers = require './menu-helpers' 10 11 platformMenu = require('../package.json')?._atomMenu?.menu 12 13 # Extended: Provides a registry for menu items that you'd like to appear in the 14 # application menu. 15 # 16 # An instance of this class is always available as the `atom.menu` global. 17 # 18 # ## Menu CSON Format 19 # 20 # Here is an example from the [tree-view](https://github.com/atom/tree-view/blob/master/menus/tree-view.cson): 21 # 22 # ```coffee 23 # [ 24 # { 25 # 'label': 'View' 26 # 'submenu': [ 27 # { 'label': 'Toggle Tree View', 'command': 'tree-view:toggle' } 28 # ] 29 # } 30 # { 31 # 'label': 'Packages' 32 # 'submenu': [ 33 # 'label': 'Tree View' 34 # 'submenu': [ 35 # { 'label': 'Focus', 'command': 'tree-view:toggle-focus' } 36 # { 'label': 'Toggle', 'command': 'tree-view:toggle' } 37 # { 'label': 'Reveal Active File', 'command': 'tree-view:reveal-active-file' } 38 # { 'label': 'Toggle Tree Side', 'command': 'tree-view:toggle-side' } 39 # ] 40 # ] 41 # } 42 # ] 43 # ``` 44 # 45 # Use in your package's menu `.cson` file requires that you place your menu 46 # structure under a `menu` key. 47 # 48 # ```coffee 49 # 'menu': [ 50 # { 51 # 'label': 'View' 52 # 'submenu': [ 53 # { 'label': 'Toggle Tree View', 'command': 'tree-view:toggle' } 54 # ] 55 # } 56 # ] 57 # ``` 58 # 59 # See {::add} for more info about adding menu's directly. 60 module.exports = 61 class MenuManager 62 constructor: ({@resourcePath, @keymapManager, @packageManager}) -> 63 @initialized = false 64 @pendingUpdateOperation = null 65 @template = [] 66 @keymapManager.onDidLoadBundledKeymaps => @loadPlatformItems() 67 @packageManager.onDidActivateInitialPackages => @sortPackagesMenu() 68 69 initialize: ({@resourcePath}) -> 70 @keymapManager.onDidReloadKeymap => @update() 71 @update() 72 @initialized = true 73 74 # Public: Adds the given items to the application menu. 75 # 76 # ## Examples 77 # ```coffee 78 # atom.menu.add [ 79 # { 80 # label: 'Hello' 81 # submenu : [{label: 'World!', command: 'hello:world'}] 82 # } 83 # ] 84 # ``` 85 # 86 # * `items` An {Array} of menu item {Object}s containing the keys: 87 # * `label` The {String} menu label. 88 # * `submenu` An optional {Array} of sub menu items. 89 # * `command` An optional {String} command to trigger when the item is 90 # clicked. 91 # 92 # Returns a {Disposable} on which `.dispose()` can be called to remove the 93 # added menu items. 94 add: (items) -> 95 items = _.deepClone(items) 96 97 for item in items 98 continue unless item.label? # TODO: Should we emit a warning here? 99 @merge(@template, item) 100 101 @update() 102 new Disposable => @remove(items) 103 104 remove: (items) -> 105 @unmerge(@template, item) for item in items 106 @update() 107 108 clear: -> 109 @template = [] 110 @update() 111 112 # Should the binding for the given selector be included in the menu 113 # commands. 114 # 115 # * `selector` A {String} selector to check. 116 # 117 # Returns a {Boolean}, true to include the selector, false otherwise. 118 includeSelector: (selector) -> 119 try 120 return true if document.body.webkitMatchesSelector(selector) 121 catch error 122 # Selector isn't valid 123 return false 124 125 # Simulate an atom-text-editor element attached to a atom-workspace element attached 126 # to a body element that has the same classes as the current body element. 127 unless @testEditor? 128 # Use new document so that custom elements don't actually get created 129 testDocument = document.implementation.createDocument(document.namespaceURI, 'html') 130 131 testBody = testDocument.createElement('body') 132 testBody.classList.add(@classesForElement(document.body)...) 133 134 testWorkspace = testDocument.createElement('atom-workspace') 135 workspaceClasses = @classesForElement(document.body.querySelector('atom-workspace')) 136 workspaceClasses = ['workspace'] if workspaceClasses.length is 0 137 testWorkspace.classList.add(workspaceClasses...) 138 139 testBody.appendChild(testWorkspace) 140 141 @testEditor = testDocument.createElement('atom-text-editor') 142 @testEditor.classList.add('editor') 143 testWorkspace.appendChild(@testEditor) 144 145 element = @testEditor 146 while element 147 return true if element.webkitMatchesSelector(selector) 148 element = element.parentElement 149 150 false 151 152 # Public: Refreshes the currently visible menu. 153 update: -> 154 return unless @initialized 155 156 clearTimeout(@pendingUpdateOperation) if @pendingUpdateOperation? 157 158 @pendingUpdateOperation = setTimeout(=> 159 unsetKeystrokes = new Set 160 for binding in @keymapManager.getKeyBindings() 161 if binding.command is 'unset!' 162 unsetKeystrokes.add(binding.keystrokes) 163 164 keystrokesByCommand = {} 165 for binding in @keymapManager.getKeyBindings() 166 continue unless @includeSelector(binding.selector) 167 continue if unsetKeystrokes.has(binding.keystrokes) 168 continue if process.platform is 'darwin' and /^alt-(shift-)?.$/.test(binding.keystrokes) 169 continue if process.platform is 'win32' and /^ctrl-alt-(shift-)?.$/.test(binding.keystrokes) 170 keystrokesByCommand[binding.command] ?= [] 171 keystrokesByCommand[binding.command].unshift binding.keystrokes 172 173 @sendToBrowserProcess(@template, keystrokesByCommand) 174 , 1) 175 176 loadPlatformItems: -> 177 if platformMenu? 178 @add(platformMenu) 179 else 180 menusDirPath = path.join(@resourcePath, 'menus') 181 platformMenuPath = fs.resolve(menusDirPath, process.platform, ['cson', 'json']) 182 {menu} = CSON.readFileSync(platformMenuPath) 183 @add(menu) 184 185 # Merges an item in a submenu aware way such that new items are always 186 # appended to the bottom of existing menus where possible. 187 merge: (menu, item) -> 188 MenuHelpers.merge(menu, item) 189 190 unmerge: (menu, item) -> 191 MenuHelpers.unmerge(menu, item) 192 193 sendToBrowserProcess: (template, keystrokesByCommand) -> 194 ipcRenderer.send 'update-application-menu', template, keystrokesByCommand 195 196 # Get an {Array} of {String} classes for the given element. 197 classesForElement: (element) -> 198 if classList = element?.classList 199 Array::slice.apply(classList) 200 else 201 [] 202 203 sortPackagesMenu: -> 204 packagesMenu = _.find @template, ({label}) -> MenuHelpers.normalizeLabel(label) is 'Packages' 205 return unless packagesMenu?.submenu? 206 207 packagesMenu.submenu.sort (item1, item2) -> 208 if item1.label and item2.label 209 MenuHelpers.normalizeLabel(item1.label).localeCompare(MenuHelpers.normalizeLabel(item2.label)) 210 else 211 0 212 @update()