context-menu-manager.coffee
1 path = require 'path' 2 CSON = require 'season' 3 fs = require 'fs-plus' 4 {calculateSpecificity, validateSelector} = require 'clear-cut' 5 {Disposable} = require 'event-kit' 6 {remote} = require 'electron' 7 MenuHelpers = require './menu-helpers' 8 {sortMenuItems} = require './menu-sort-helpers' 9 _ = require 'underscore-plus' 10 11 platformContextMenu = require('../package.json')?._atomMenu?['context-menu'] 12 13 # Extended: Provides a registry for commands that you'd like to appear in the 14 # context menu. 15 # 16 # An instance of this class is always available as the `atom.contextMenu` 17 # global. 18 # 19 # ## Context Menu CSON Format 20 # 21 # ```coffee 22 # 'atom-workspace': [{label: 'Help', command: 'application:open-documentation'}] 23 # 'atom-text-editor': [{ 24 # label: 'History', 25 # submenu: [ 26 # {label: 'Undo', command:'core:undo'} 27 # {label: 'Redo', command:'core:redo'} 28 # ] 29 # }] 30 # ``` 31 # 32 # In your package's menu `.cson` file you need to specify it under a 33 # `context-menu` key: 34 # 35 # ```coffee 36 # 'context-menu': 37 # 'atom-workspace': [{label: 'Help', command: 'application:open-documentation'}] 38 # ... 39 # ``` 40 # 41 # The format for use in {::add} is the same minus the `context-menu` key. See 42 # {::add} for more information. 43 module.exports = 44 class ContextMenuManager 45 constructor: ({@keymapManager}) -> 46 @definitions = {'.overlayer': []} # TODO: Remove once color picker package stops touching private data 47 @clear() 48 49 @keymapManager.onDidLoadBundledKeymaps => @loadPlatformItems() 50 51 initialize: ({@resourcePath, @devMode}) -> 52 53 loadPlatformItems: -> 54 if platformContextMenu? 55 @add(platformContextMenu, @devMode ? false) 56 else 57 menusDirPath = path.join(@resourcePath, 'menus') 58 platformMenuPath = fs.resolve(menusDirPath, process.platform, ['cson', 'json']) 59 map = CSON.readFileSync(platformMenuPath) 60 @add(map['context-menu']) 61 62 # Public: Add context menu items scoped by CSS selectors. 63 # 64 # ## Examples 65 # 66 # To add a context menu, pass a selector matching the elements to which you 67 # want the menu to apply as the top level key, followed by a menu descriptor. 68 # The invocation below adds a global 'Help' context menu item and a 'History' 69 # submenu on the editor supporting undo/redo. This is just for example 70 # purposes and not the way the menu is actually configured in Atom by default. 71 # 72 # ```coffee 73 # atom.contextMenu.add { 74 # 'atom-workspace': [{label: 'Help', command: 'application:open-documentation'}] 75 # 'atom-text-editor': [{ 76 # label: 'History', 77 # submenu: [ 78 # {label: 'Undo', command:'core:undo'} 79 # {label: 'Redo', command:'core:redo'} 80 # ] 81 # }] 82 # } 83 # ``` 84 # 85 # ## Arguments 86 # 87 # * `itemsBySelector` An {Object} whose keys are CSS selectors and whose 88 # values are {Array}s of item {Object}s containing the following keys: 89 # * `label` (optional) A {String} containing the menu item's label. 90 # * `command` (optional) A {String} containing the command to invoke on the 91 # target of the right click that invoked the context menu. 92 # * `enabled` (optional) A {Boolean} indicating whether the menu item 93 # should be clickable. Disabled menu items typically appear grayed out. 94 # Defaults to `true`. 95 # * `submenu` (optional) An {Array} of additional items. 96 # * `type` (optional) If you want to create a separator, provide an item 97 # with `type: 'separator'` and no other keys. 98 # * `visible` (optional) A {Boolean} indicating whether the menu item 99 # should appear in the menu. Defaults to `true`. 100 # * `created` (optional) A {Function} that is called on the item each time a 101 # context menu is created via a right click. You can assign properties to 102 # `this` to dynamically compute the command, label, etc. This method is 103 # actually called on a clone of the original item template to prevent state 104 # from leaking across context menu deployments. Called with the following 105 # argument: 106 # * `event` The click event that deployed the context menu. 107 # * `shouldDisplay` (optional) A {Function} that is called to determine 108 # whether to display this item on a given context menu deployment. Called 109 # with the following argument: 110 # * `event` The click event that deployed the context menu. 111 # 112 # Returns a {Disposable} on which `.dispose()` can be called to remove the 113 # added menu items. 114 add: (itemsBySelector, throwOnInvalidSelector = true) -> 115 addedItemSets = [] 116 117 for selector, items of itemsBySelector 118 validateSelector(selector) if throwOnInvalidSelector 119 itemSet = new ContextMenuItemSet(selector, items) 120 addedItemSets.push(itemSet) 121 @itemSets.push(itemSet) 122 123 new Disposable => 124 for itemSet in addedItemSets 125 @itemSets.splice(@itemSets.indexOf(itemSet), 1) 126 return 127 128 templateForElement: (target) -> 129 @templateForEvent({target}) 130 131 templateForEvent: (event) -> 132 template = [] 133 currentTarget = event.target 134 135 while currentTarget? 136 currentTargetItems = [] 137 matchingItemSets = 138 @itemSets.filter (itemSet) -> currentTarget.webkitMatchesSelector(itemSet.selector) 139 140 for itemSet in matchingItemSets 141 for item in itemSet.items 142 itemForEvent = @cloneItemForEvent(item, event) 143 if itemForEvent 144 MenuHelpers.merge(currentTargetItems, itemForEvent, itemSet.specificity) 145 146 for item in currentTargetItems 147 MenuHelpers.merge(template, item, false) 148 149 currentTarget = currentTarget.parentElement 150 151 @pruneRedundantSeparators(template) 152 @addAccelerators(template) 153 154 return @sortTemplate(template) 155 156 # Adds an `accelerator` property to items that have key bindings. Electron 157 # uses this property to surface the relevant keymaps in the context menu. 158 addAccelerators: (template) -> 159 for id, item of template 160 if item.command 161 keymaps = @keymapManager.findKeyBindings({command: item.command, target: document.activeElement}) 162 keystrokes = keymaps?[0]?.keystrokes 163 if keystrokes 164 # Electron does not support multi-keystroke accelerators. Therefore, 165 # when the command maps to a multi-stroke key binding, show the 166 # keystrokes next to the item's label. 167 if keystrokes.includes(' ') 168 item.label += " [#{_.humanizeKeystroke(keystrokes)}]" 169 else 170 item.accelerator = MenuHelpers.acceleratorForKeystroke(keystrokes) 171 if Array.isArray(item.submenu) 172 @addAccelerators(item.submenu) 173 174 pruneRedundantSeparators: (menu) -> 175 keepNextItemIfSeparator = false 176 index = 0 177 while index < menu.length 178 if menu[index].type is 'separator' 179 if not keepNextItemIfSeparator or index is menu.length - 1 180 menu.splice(index, 1) 181 else 182 index++ 183 else 184 keepNextItemIfSeparator = true 185 index++ 186 187 sortTemplate: (template) -> 188 template = sortMenuItems(template) 189 for id, item of template 190 if Array.isArray(item.submenu) 191 item.submenu = @sortTemplate(item.submenu) 192 return template 193 194 # Returns an object compatible with `::add()` or `null`. 195 cloneItemForEvent: (item, event) -> 196 return null if item.devMode and not @devMode 197 item = Object.create(item) 198 if typeof item.shouldDisplay is 'function' 199 return null unless item.shouldDisplay(event) 200 item.created?(event) 201 if Array.isArray(item.submenu) 202 item.submenu = item.submenu 203 .map((submenuItem) => @cloneItemForEvent(submenuItem, event)) 204 .filter((submenuItem) -> submenuItem isnt null) 205 return item 206 207 showForEvent: (event) -> 208 @activeElement = event.target 209 menuTemplate = @templateForEvent(event) 210 211 return unless menuTemplate?.length > 0 212 remote.getCurrentWindow().emit('context-menu', menuTemplate) 213 return 214 215 clear: -> 216 @activeElement = null 217 @itemSets = [] 218 inspectElement = { 219 'atom-workspace': [{ 220 label: 'Inspect Element' 221 command: 'application:inspect' 222 devMode: true 223 created: (event) -> 224 {pageX, pageY} = event 225 @commandDetail = {x: pageX, y: pageY} 226 }] 227 } 228 @add(inspectElement, false) 229 230 class ContextMenuItemSet 231 constructor: (@selector, @items) -> 232 @specificity = calculateSpecificity(@selector)