Merge pull request #850 from atom/context-menu

Context menu
This commit is contained in:
Matt Colyer
2013-09-20 14:36:11 -07:00
17 changed files with 347 additions and 13 deletions

View File

@@ -42,6 +42,9 @@ directory are added alphabetically.
- `keymaps`(**Optional**): an Array of Strings identifying the order of the
key mappings your package needs to load. If not specified, mappings in the _keymaps_
directory are added alphabetically.
- `menus`(**Optional**): an Array of Strings identifying the order of
the menu mappings your package needs to load. If not specified, mappings
in the _keymap_ directory are added alphabetically.
- `snippets` (**Optional**): an Array of Strings identifying the order of the
snippets your package needs to load. If not specified, snippets in the _snippets_
directory are added alphabetically.
@@ -135,6 +138,33 @@ array in your _package.json_ can specify which keymaps to load and in what order
See the [main keymaps documentation](../internals/keymaps.md) for more information on
how keymaps work.
## Menus
Menus are placed in the _menus_ subdirectory. It's useful to specify a
context menu items if if commands are linked to a specific part of the
interface, say for example adding a file in the tree-view.
By default, all menus are loaded in alphabetical order. An optional
`menus` array in your _package.json_ can specify which menus to load
and in what order.
Context menus are created by determining which element was selected and
then adding all of the menu items whose selectors match that element (in
the order which they were loaded). The process is then repeated for the
elements until reaching the top of the dom tree.
NOTE: Currently you can only specify items to be added to the context
menu, the menu which appears when you right click. There are plans to
add support for adding to global menu.
```
'context-menu':
'.tree-view':
'Add file': 'tree-view:add-file'
'#root-view':
'Inspect Element': 'core:inspect'
```
## Snippets
An extension can supply language snippets in the _snippets_ directory. These can

View File

@@ -171,6 +171,34 @@ describe "the `atom` global", ->
expect(keymap.bindingsForElement(element1)['ctrl-n']).toBe 'keymap-2'
expect(keymap.bindingsForElement(element3)['ctrl-y']).toBeUndefined()
describe "menu loading", ->
beforeEach -> atom.contextMenu.definitions = []
describe "when the metadata does not contain a 'menus' manifest", ->
it "loads all the .cson/.json files in the menus directory", ->
element = ($$ -> @div class: 'test-1')[0]
expect(atom.contextMenu.definitionsForElement(element)).toEqual []
atom.activatePackage("package-with-menus")
expect(atom.contextMenu.definitionsForElement(element)[0].label).toBe "Menu item 1"
expect(atom.contextMenu.definitionsForElement(element)[1].label).toBe "Menu item 2"
expect(atom.contextMenu.definitionsForElement(element)[2].label).toBe "Menu item 3"
describe "when the metadata contains a 'menus' manifest", ->
it "loads only the menus specified by the manifest, in the specified order", ->
element = ($$ -> @div class: 'test-1')[0]
expect(atom.contextMenu.definitionsForElement(element)).toEqual []
atom.activatePackage("package-with-menus-manifest")
expect(atom.contextMenu.definitionsForElement(element)[0].label).toBe "Menu item 2"
expect(atom.contextMenu.definitionsForElement(element)[1].label).toBe "Menu item 1"
expect(atom.contextMenu.definitionsForElement(element)[2]).toBeUndefined()
describe "stylesheet loading", ->
describe "when the metadata contains a 'stylesheets' manifest", ->
it "loads stylesheets from the stylesheets directory as specified by the manifest", ->

View File

@@ -0,0 +1,122 @@
{$$} = require 'atom'
ContextMenuManager = require '../src/context-menu-manager'
describe "ContextMenuManager", ->
[contextMenu] = []
beforeEach ->
contextMenu = new ContextMenuManager
describe "adding definitions", ->
it 'loads', ->
contextMenu.add 'file-path',
'.selector':
'label': 'command'
expect(contextMenu.definitions['.selector'][0].label).toEqual 'label'
expect(contextMenu.definitions['.selector'][0].command).toEqual 'command'
describe 'dev mode', ->
it 'loads', ->
contextMenu.add 'file-path',
'.selector':
'label': 'command'
, devMode: true
expect(contextMenu.devModeDefinitions['.selector'][0].label).toEqual 'label'
expect(contextMenu.devModeDefinitions['.selector'][0].command).toEqual 'command'
describe "building a menu template", ->
beforeEach ->
contextMenu.definitions = {
'.parent':[
label: 'parent'
command: 'command-p'
]
'.child': [
label: 'child'
command: 'command-c'
]
}
contextMenu.devModeDefinitions =
'.parent': [
label: 'dev-label'
command: 'dev-command'
]
describe "on a single element", ->
[element] = []
beforeEach ->
element = ($$ -> @div class: 'parent')[0]
it "creates a menu with a single item", ->
menu = contextMenu.combinedMenuTemplateForElement(element)
expect(menu[0].label).toEqual 'parent'
expect(menu[0].command).toEqual 'command-p'
expect(menu[1]).toBeUndefined()
describe "in devMode", ->
beforeEach -> contextMenu.devMode = true
it "creates a menu with development items", ->
menu = contextMenu.combinedMenuTemplateForElement(element)
expect(menu[0].label).toEqual 'parent'
expect(menu[0].command).toEqual 'command-p'
expect(menu[1].type).toEqual 'separator'
expect(menu[2].label).toEqual 'dev-label'
expect(menu[2].command).toEqual 'dev-command'
describe "on multiple elements", ->
[element] = []
beforeEach ->
element = $$ ->
@div class: 'parent', =>
@div class: 'child'
element = element.find('.child')[0]
it "creates a menu with a two items", ->
menu = contextMenu.combinedMenuTemplateForElement(element)
expect(menu[0].label).toEqual 'child'
expect(menu[0].command).toEqual 'command-c'
expect(menu[1].label).toEqual 'parent'
expect(menu[1].command).toEqual 'command-p'
expect(menu[2]).toBeUndefined()
describe "in devMode", ->
beforeEach -> contextMenu.devMode = true
xit "creates a menu with development items", ->
menu = contextMenu.combinedMenuTemplateForElement(element)
expect(menu[0].label).toEqual 'child'
expect(menu[0].command).toEqual 'command-c'
expect(menu[1].label).toEqual 'parent'
expect(menu[1].command).toEqual 'command-p'
expect(menu[2].label).toEqual 'dev-label'
expect(menu[2].command).toEqual 'dev-command'
expect(menu[3]).toBeUndefined()
describe "#executeBuildHandlers", ->
menuTemplate = [
label: 'label'
executeAtBuild: ->
]
event =
target: null
it 'should invoke the executeAtBuild fn', ->
buildFn = spyOn(menuTemplate[0], 'executeAtBuild')
contextMenu.executeBuildHandlers(event, menuTemplate)
expect(buildFn).toHaveBeenCalled()
expect(buildFn.mostRecentCall.args[0]).toBe event

View File

@@ -0,0 +1,3 @@
"context-menu":
".test-1":
"Menu item 1": "command-1"

View File

@@ -0,0 +1,3 @@
"context-menu":
".test-1":
"Menu item 2": "command-2"

View File

@@ -0,0 +1,3 @@
"context-menu":
".test-1":
"Menu item 3": "command-3"

View File

@@ -0,0 +1 @@
menus: ["menu-2", "menu-1"]

View File

@@ -0,0 +1,3 @@
"context-menu":
".test-1":
"Menu item 1": "command-1"

View File

@@ -0,0 +1,3 @@
"context-menu":
".test-1":
"Menu item 2": "command-2"

View File

@@ -0,0 +1,3 @@
"context-menu":
".test-1":
"Menu item 3": "command-3"

View File

@@ -132,6 +132,7 @@ class AtomApplication
@on 'application:minimize', -> Menu.sendActionToFirstResponder('performMiniaturize:')
@on 'application:zoom', -> Menu.sendActionToFirstResponder('zoom:')
@on 'application:bring-all-windows-to-front', -> Menu.sendActionToFirstResponder('arrangeInFront:')
@on 'application:inspect', ({x,y}) -> @focusedWindow().browserWindow.inspectElement(x, y)
app.on 'will-quit', =>
fs.unlinkSync socketPath if fs.existsSync(socketPath) # Clean the socket file when quit normally.

View File

@@ -17,6 +17,7 @@ class AtomPackage extends Package
metadata: null
keymaps: null
menus: null
stylesheets: null
grammars: null
scopedProperties: null
@@ -32,10 +33,12 @@ class AtomPackage extends Package
if @isTheme()
@stylesheets = []
@keymaps = []
@menus = []
@grammars = []
@scopedProperties = []
else
@loadKeymaps()
@loadMenus()
@loadStylesheets()
@loadGrammars()
@loadScopedProperties()
@@ -77,6 +80,7 @@ class AtomPackage extends Package
activateResources: ->
keymap.add(keymapPath, map) for [keymapPath, map] in @keymaps
atom.contextMenu.add(menuPath, map['context-menu']) for [menuPath, map] in @menus
type = if @metadata.theme then 'theme' else 'bundled'
applyStylesheet(stylesheetPath, content, type) for [stylesheetPath, content] in @stylesheets
syntax.addGrammar(grammar) for grammar in @grammars
@@ -86,6 +90,9 @@ class AtomPackage extends Package
loadKeymaps: ->
@keymaps = @getKeymapPaths().map (keymapPath) -> [keymapPath, CSON.readFileSync(keymapPath)]
loadMenus: ->
@menus = @getMenuPaths().map (menuPath) -> [menuPath, CSON.readFileSync(menuPath)]
getKeymapPaths: ->
keymapsDirPath = path.join(@path, 'keymaps')
if @metadata.keymaps
@@ -93,6 +100,13 @@ class AtomPackage extends Package
else
fsUtils.listSync(keymapsDirPath, ['cson', 'json'])
getMenuPaths: ->
menusDirPath = path.join(@path, 'menus')
if @metadata.menus
@metadata.menus.map (name) -> fsUtils.resolve(menusDirPath, name, ['json', 'cson', ''])
else
fsUtils.listSync(menusDirPath, ['cson', 'json'])
loadStylesheets: ->
@stylesheets = @getStylesheetPaths().map (stylesheetPath) -> [stylesheetPath, loadStylesheet(stylesheetPath)]

View File

@@ -1,6 +1,7 @@
BrowserWindow = require 'browser-window'
Menu = require 'menu'
MenuItem = require 'menu-item'
ContextMenu = require 'context-menu'
app = require 'app'
dialog = require 'dialog'
ipc = require 'ipc'
@@ -12,8 +13,6 @@ _ = require 'underscore'
module.exports =
class AtomWindow
browserWindow: null
contextMenu: null
inspectElementMenuItem: null
loaded: null
isSpec: null
@@ -22,7 +21,6 @@ class AtomWindow
global.atomApplication.addWindow(this)
@setupNodePath(@resourcePath)
@createContextMenu()
@browserWindow = new BrowserWindow show: false, title: 'Atom'
@browserWindow.restart = _.wrap _.bind(@browserWindow.restart, @browserWindow), (restart) =>
@setupNodePath(@resourcePath)
@@ -84,9 +82,8 @@ class AtomWindow
when 0 then @browserWindow.destroy()
when 1 then @browserWindow.restart()
@browserWindow.on 'context-menu', (x, y) =>
@inspectElementMenuItem.click = => @browserWindow.inspectElement(x, y)
@contextMenu.popup(@browserWindow)
@browserWindow.on 'context-menu', (menuTemplate) =>
new ContextMenu(menuTemplate)
if @isSpec
# Spec window's web view should always have focus
@@ -100,11 +97,6 @@ class AtomWindow
else
@browserWindow.once 'window:loaded', => @openPath(pathToOpen, initialLine)
createContextMenu: ->
@contextMenu = new Menu
@inspectElementMenuItem = new MenuItem(label: 'Inspect Element')
@contextMenu.append(@inspectElementMenuItem)
sendCommand: (command, args...) ->
if @handlesAtomCommands()
@sendAtomCommand(command, args...)
@@ -112,7 +104,8 @@ class AtomWindow
@sendNativeCommand(command)
sendAtomCommand: (command, args...) ->
ipc.sendChannel @browserWindow.getProcessId(), @browserWindow.getRoutingId(), 'command', command, args...
action = if args[0]?.contextCommand then 'context-command' else 'command'
ipc.sendChannel @browserWindow.getProcessId(), @browserWindow.getRoutingId(), action, command, args...
sendNativeCommand: (command) ->
switch command

View File

@@ -10,12 +10,14 @@ dialog = remote.require 'dialog'
app = remote.require 'app'
telepath = require 'telepath'
ThemeManager = require './theme-manager'
ContextMenuManager = require './context-menu-manager'
window.atom =
loadedPackages: {}
activePackages: {}
packageStates: {}
themes: new ThemeManager()
contextMenu: new ContextMenuManager(remote.getCurrentWindow().loadSettings.devMode)
getLoadSettings: ->
remote.getCurrentWindow().loadSettings

View File

@@ -0,0 +1,102 @@
$ = require 'jquery'
_ = require 'underscore'
remote = require 'remote'
# Public: Provides a registry for commands that you'd like to appear in the
# context menu.
#
# Should be accessed via `atom.contextMenu`.
module.exports =
class ContextMenuManager
# Private:
constructor: (@devMode=false) ->
@definitions = {}
@devModeDefinitions = {}
@activeElement = null
@devModeDefinitions['#root-view'] = [
label: 'Inspect Element'
command: 'application:inspect'
executeAtBuild: (e) ->
@.commandOptions = x: e.pageX, y: e.pageY
]
# Public: Creates menu definitions from the object specified by the menu
# cson API.
#
# * name: The path of the file that contains the menu definitions.
# * object: The 'context-menu' object specified in the menu cson API.
# * options:
# + devMode - Determines whether the entries should only be shown when
# the window is in dev mode.
#
# Returns nothing.
add: (name, object, {devMode}={}) ->
for selector, items of object
for label, command of items
@addBySelector(selector, {label, command}, {devMode})
# Private: Registers a command to be displayed when the relevant item is right
# clicked.
#
# * selector: The css selector for the active element which should include
# the given command in its context menu.
# * definition: The object containing keys which match the menu template API.
# * options:
# + devMode: Indicates whether this command should only appear while the
# editor is in dev mode.
addBySelector: (selector, definition, {devMode}={}) ->
definitions = if devMode then @devModeDefinitions else @definitions
(definitions[selector] ?= []).push(definition)
# Private: Returns definitions which match the element and devMode.
definitionsForElement: (element, {devMode}={}) ->
definitions = if devMode then @devModeDefinitions else @definitions
matchedDefinitions = []
for selector, items of definitions when element.webkitMatchesSelector(selector)
matchedDefinitions.push(_.clone(item)) for item in items
matchedDefinitions
# Private: Used to generate the context menu for a specific element and it's
# parents.
#
# The menu items are sorted such that menu items that match closest to the
# active element are listed first. The further down the list you go, the higher
# up the ancestor hierarchy they match.
#
# * element: The DOM element to generate the menu template for.
menuTemplateForMostSpecificElement: (element, {devMode}={}) ->
menuTemplate = @definitionsForElement(element, {devMode})
if element.parentElement
menuTemplate.concat(@menuTemplateForMostSpecificElement(element.parentElement, {devMode}))
else
menuTemplate
# Private: Returns a menu template for both normal entries as well as
# development mode entries.
combinedMenuTemplateForElement: (element) ->
normalItems = @menuTemplateForMostSpecificElement(element)
devItems = if @devMode then @menuTemplateForMostSpecificElement(element, devMode: true) else []
menuTemplate = normalItems
menuTemplate.push({ type: 'separator' }) if normalItems.length > 0 and devItems.length > 0
menuTemplate.concat(devItems)
# Private: Executes `executeAtBuild` if defined for each menu item with
# the provided event and then removes the `executeAtBuild` property from
# the menu item.
#
# This is useful for commands that need to provide data about the event
# to the command.
executeBuildHandlers: (event, menuTemplate) ->
for template in menuTemplate
template?.executeAtBuild?.call(template, event)
delete template.executeAtBuild
# Public: Request a context menu to be displayed.
showForEvent: (event) ->
@activeElement = event.target
menuTemplate = @combinedMenuTemplateForElement(event.target)
@executeBuildHandlers(event, menuTemplate)
remote.getCurrentWindow().emit('context-menu', menuTemplate)

20
src/context-menu.coffee Normal file
View File

@@ -0,0 +1,20 @@
Menu = require 'menu'
BrowserWindow = require 'browser-window'
module.exports =
class ContextMenu
constructor: (template) ->
template = @createClickHandlers(template)
menu = Menu.buildFromTemplate(template)
menu.popup(BrowserWindow.getFocusedWindow())
# Private: It's necessary to build the event handlers in this process, otherwise
# closures are drug across processes and failed to be garbage collected
# appropriately.
createClickHandlers: (template) ->
for item in template
if item.command
(item.commandOptions ?= {}).contextCommand = true
item.click = do (item) ->
=> global.atomApplication.sendCommand(item.command, item.commandOptions)
item

View File

@@ -16,6 +16,9 @@ class WindowEventHandler
@subscribe ipc, 'command', (command, args...) ->
$(window).trigger(command, args...)
@subscribe ipc, 'context-command', (command, args...) ->
$(atom.contextMenu.activeElement).trigger(command, args...)
@subscribe $(window), 'focus', -> $("body").removeClass('is-blurred')
@subscribe $(window), 'blur', -> $("body").addClass('is-blurred')
@subscribe $(window), 'window:open-path', (event, {pathToOpen, initialLine}) ->
@@ -46,7 +49,7 @@ class WindowEventHandler
@subscribe $(document), 'contextmenu', (e) ->
e.preventDefault()
remote.getCurrentWindow().emit('context-menu', e.pageX, e.pageY)
atom.contextMenu.showForEvent(e)
openLink: (event) =>
location = $(event.target).attr('href')