/ src / context-menu-manager.coffee
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)