diff --git a/spec/context-menu-manager-spec.coffee b/spec/context-menu-manager-spec.coffee index 3cd2b28ff..07abb2da6 100644 --- a/spec/context-menu-manager-spec.coffee +++ b/spec/context-menu-manager-spec.coffee @@ -3,161 +3,97 @@ ContextMenuManager = require '../src/context-menu-manager' describe "ContextMenuManager", -> - [contextMenu] = [] + [contextMenu, parent, child, grandchild] = [] beforeEach -> {resourcePath} = atom.getLoadSettings() contextMenu = new ContextMenuManager({resourcePath}) - describe "adding definitions", -> - it 'loads', -> - contextMenu.add 'file-path', - '.selector': - 'label': 'command' + parent = document.createElement("div") + child = document.createElement("div") + grandchild = document.createElement("div") + parent.classList.add('parent') + child.classList.add('child') + grandchild.classList.add('grandchild') + child.appendChild(grandchild) + parent.appendChild(child) - expect(contextMenu.definitions['.selector'][0].label).toEqual 'label' - expect(contextMenu.definitions['.selector'][0].command).toEqual 'command' + describe "::add(itemsBySelector)", -> + it "can add top-level menu items that can be removed with the returned disposable", -> + disposable = contextMenu.add + '.parent': [{label: 'A', command: 'a'}] + '.child': [{label: 'B', command: 'b'}] + '.grandchild': [{label: 'C', command: 'c'}] - it 'does not add duplicate menu items', -> - contextMenu.add 'file-path', - '.selector': - 'label': 'command' + expect(contextMenu.templateForElement(grandchild)).toEqual [ + {label: 'C', command: 'c'} + {label: 'B', command: 'b'} + {label: 'A', command: 'a'} + ] - contextMenu.add 'file-path', - '.selector': - 'label': 'command' + disposable.dispose() + expect(contextMenu.templateForElement(grandchild)).toEqual [] - expect(contextMenu.definitions['.selector'][0].label).toEqual 'label' - expect(contextMenu.definitions['.selector'][0].command).toEqual 'command' - expect(contextMenu.definitions['.selector'].length).toBe 1 + it "can add submenu items to existing menus that can be removed with the returned disposable", -> + disposable1 = contextMenu.add + '.grandchild': [{label: 'A', submenu: [{label: 'B', command: 'b'}]}] + disposable2 = contextMenu.add + '.grandchild': [{label: 'A', submenu: [{label: 'C', command: 'c'}]}] - it 'allows multiple separators', -> - contextMenu.add 'file-path', - '.selector': - 'separator1': '-' - 'separator2': '-' - - expect(contextMenu.definitions['.selector'].length).toBe 2 - expect(contextMenu.definitions['.selector'][0].type).toEqual 'separator' - expect(contextMenu.definitions['.selector'][1].type).toEqual 'separator' - - it 'allows duplicate commands with different labels', -> - contextMenu.add 'file-path', - '.selector': - 'label': 'command' - - contextMenu.add 'file-path', - '.selector': - 'another label': 'command' - - expect(contextMenu.definitions['.selector'][0].label).toEqual 'label' - expect(contextMenu.definitions['.selector'][0].command).toEqual 'command' - expect(contextMenu.definitions['.selector'][1].label).toEqual 'another label' - expect(contextMenu.definitions['.selector'][1].command).toEqual 'command' - - it "loads submenus", -> - contextMenu.add 'file-path', - '.selector': - 'parent': - 'child-1': 'child-1:trigger' - 'child-2': 'child-2:trigger' - 'parent-2': 'parent-2:trigger' - - expect(contextMenu.definitions['.selector'].length).toBe 2 - expect(contextMenu.definitions['.selector'][0].label).toEqual 'parent' - expect(contextMenu.definitions['.selector'][0].submenu.length).toBe 2 - expect(contextMenu.definitions['.selector'][0].submenu[0].label).toBe 'child-1' - expect(contextMenu.definitions['.selector'][0].submenu[0].command).toBe 'child-1:trigger' - expect(contextMenu.definitions['.selector'][0].submenu[1].label).toBe 'child-2' - expect(contextMenu.definitions['.selector'][0].submenu[1].command).toBe 'child-2:trigger' - - 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' + expect(contextMenu.templateForElement(grandchild)).toEqual [{ + label: 'A', + submenu: [ + {label: 'C', command: 'c'} + {label: 'B', command: 'b'} ] - } + }] - contextMenu.devModeDefinitions = - '.parent': [ - label: 'dev-label' - command: 'dev-command' + disposable2.dispose() + expect(contextMenu.templateForElement(grandchild)).toEqual [{ + label: 'A', + submenu: [ + {label: 'B', command: 'b'} + ] + }] + + disposable1.dispose() + expect(contextMenu.templateForElement(grandchild)).toEqual [] + + it "favors the most specific / recently added item in the case of a duplicate label", -> + grandchild.classList.add('foo') + + disposable1 = contextMenu.add + '.grandchild': [{label: 'A', command: 'a'}] + disposable2 = contextMenu.add + '.grandchild.foo': [{label: 'A', command: 'b'}] + disposable3 = contextMenu.add + '.grandchild': [{label: 'A', command: 'c'}] + + expect(contextMenu.templateForElement(grandchild)).toEqual [{label: 'A', command: 'b'}] + + disposable2.dispose() + expect(contextMenu.templateForElement(grandchild)).toEqual [{label: 'A', command: 'c'}] + + disposable3.dispose() + expect(contextMenu.templateForElement(grandchild)).toEqual [{label: 'A', command: 'a'}] + + it "allows multiple separators", -> + contextMenu.add + '.grandchild': [ + {label: 'A', command: 'a'}, + {type: 'separator'}, + {label: 'B', command: 'b'}, + {type: 'separator'}, + {label: 'C', command: 'c'} ] - 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() + expect(contextMenu.templateForElement(grandchild)).toEqual [ + {label: 'A', command: 'a'}, + {type: 'separator'}, + {label: 'B', command: 'b'}, + {type: 'separator'}, + {label: 'C', command: 'c'} + ] describe "executeBuildHandlers", -> menuTemplate = [ diff --git a/src/context-menu-manager.coffee b/src/context-menu-manager.coffee index a55daabf0..f5c1f1471 100644 --- a/src/context-menu-manager.coffee +++ b/src/context-menu-manager.coffee @@ -4,6 +4,12 @@ remote = require 'remote' path = require 'path' CSON = require 'season' fs = require 'fs-plus' +{specificity} = require 'clear-cut' +{Disposable} = require 'event-kit' +MenuHelpers = require './menu-helpers' + +SpecificityCache = {} +SequenceCount = 0 # Extended: Provides a registry for commands that you'd like to appear in the # context menu. @@ -13,16 +19,17 @@ fs = require 'fs-plus' module.exports = class ContextMenuManager constructor: ({@resourcePath, @devMode}) -> - @definitions = {} - @devModeDefinitions = {} + @definitions = {'.overlayer': []} # TODO: Remove once color picker package stops touching private data @activeElement = null - @devModeDefinitions['.workspace'] = [ - label: 'Inspect Element' - command: 'application:inspect' - executeAtBuild: (e) -> - @commandOptions = x: e.pageX, y: e.pageY - ] + @itemSets = [] + + # @devModeDefinitions['.workspace'] = [ + # label: 'Inspect Element' + # command: 'application:inspect' + # executeAtBuild: (e) -> + # @commandOptions = x: e.pageX, y: e.pageY + # ] atom.keymaps.onDidLoadBundledKeymaps => @loadPlatformItems() @@ -41,91 +48,72 @@ class ContextMenuManager # * `devMode` Determines whether the entries should only be shown when # the window is in dev mode. add: (name, object, {devMode}={}) -> - for selector, items of object - for label, commandOrSubmenu of items + unless typeof arguments[0] is 'object' + return @add(@convertLegacyItems(object), {devMode}) + + itemsBySelector = _.deepClone(arguments[0]) + devMode = arguments[1]?.devMode ? false + addedItemSets = [] + + for selector, items of itemsBySelector + itemSet = new ContextMenuItemSet(selector, items) + addedItemSets.push(itemSet) + @itemSets.push(itemSet) + + new Disposable => + for itemSet in addedItemSets + @itemSets.splice(@itemSets.indexOf(itemSet), 1) + + templateForElement: (element) -> + template = [] + currentTarget = element + + while currentTarget? + matchingItemSets = + @itemSets + .filter (itemSet) -> currentTarget.webkitMatchesSelector(itemSet.selector) + .sort (a, b) -> a.compare(b) + + for {items} in matchingItemSets + MenuHelpers.merge(template, item) for item in items + + currentTarget = currentTarget.parentElement + + template + + convertLegacyItems: (legacyItems) -> + itemsBySelector = {} + + for selector, commandsByLabel of legacyItems + itemsBySelector[selector] = items = [] + + for label, commandOrSubmenu of commandsByLabel if typeof commandOrSubmenu is 'object' - submenu = [] - for submenuLabel, command of commandOrSubmenu - submenu.push(@buildMenuItem(submenuLabel, command)) - @addBySelector(selector, {label: label, submenu: submenu}, {devMode}) + items.push({label, submenu: @convertLegacyItems(commandOrSubmenu)}) + else if commandOrSubmenu is '-' + items.push({type: 'separator'}) else - menuItem = @buildMenuItem(label, commandOrSubmenu) - @addBySelector(selector, menuItem, {devMode}) + items.push({label, command: commandOrSubmenu}) - undefined - - buildMenuItem: (label, command) -> - if command is '-' - {type: 'separator'} - else - {label, command} - - # 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` An optional {Object} with the following keys: - # * `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 - if not _.findWhere(definitions[selector], definition) or _.isEqual(definition, {type: 'separator'}) - (definitions[selector] ?= []).push(definition) - - # 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 - - # 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 - - # 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) - - # 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 + itemsBySelector # Public: Request a context menu to be displayed. # # * `event` A DOM event. showForEvent: (event) -> @activeElement = event.target - menuTemplate = @combinedMenuTemplateForElement(event.target) + menuTemplate = @templateForElement(@activeElement) + return unless menuTemplate?.length > 0 - @executeBuildHandlers(event, menuTemplate) + # @executeBuildHandlers(event, menuTemplate) remote.getCurrentWindow().emit('context-menu', menuTemplate) - undefined + return + +class ContextMenuItemSet + constructor: (@selector, @items) -> + @specificity = (SpecificityCache[@selector] ?= specificity(@selector)) + @sequenceNumber = SequenceCount++ + + compare: (other) -> + other.specificity - @specificity or + other.sequenceNumber - @sequenceNumber diff --git a/src/menu-helpers.coffee b/src/menu-helpers.coffee index 156c83752..e4d40bf81 100644 --- a/src/menu-helpers.coffee +++ b/src/menu-helpers.coffee @@ -15,11 +15,12 @@ unmerge = (menu, item) -> unless matchingItem.submenu?.length > 0 menu.splice(menu.indexOf(matchingItem), 1) -findMatchingItem = (menu, {label, submenu}) -> +findMatchingItem = (menu, {type, label, submenu}) -> + return if type is 'separator' for item in menu if normalizeLabel(item.label) is normalizeLabel(label) and item.submenu? is submenu? return item - null + return normalizeLabel = (label) -> return undefined unless label?