From 65605409b05753dc1b9e5d4e5d7afb89f4b46d2c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 9 Jan 2012 21:31:49 -0800 Subject: [PATCH] Favor key bindings with the most specific CSS selectors Now if a keypress event bubbles to an element with bindings for multiple matching selectors, the most specific selector is chosen. This allows us to override a keybinding by using a more specific selector. For example, say we have an editor instance inside of the file-finder widget, so that the user can use all their normal bindings when they type the name of the file. Except when they hit we want to close the file finder, not do whatever they normally have it bound to. Now we can just say: ``` .file-finder .editor { : close-file-finder } ``` And we're assured that our binding will take precedence. --- spec/atom/key-event-handler-spec.coffee | 21 ++- src/atom/binding-set.coffee | 2 + src/atom/key-event-handler.coffee | 16 +- vendor/slick.js | 232 ++++++++++++++++++++++++ vendor/specificity.js | 33 ++++ 5 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 vendor/slick.js create mode 100644 vendor/specificity.js diff --git a/spec/atom/key-event-handler-spec.coffee b/spec/atom/key-event-handler-spec.coffee index b09223dae..2744aa22c 100644 --- a/spec/atom/key-event-handler-spec.coffee +++ b/spec/atom/key-event-handler-spec.coffee @@ -7,7 +7,7 @@ describe "KeyEventHandler", -> beforeEach -> handler = new KeyEventHandler - fdescribe "handleKeypress", -> + describe "handleKeypress", -> fragment = null deleteCharHandler = null insertCharHandler = null @@ -68,3 +68,22 @@ describe "KeyEventHandler", -> expect(deleteCharHandler).not.toHaveBeenCalled() expect(insertCharHandler).not.toHaveBeenCalled() + describe "when the event bubbles to a node that matches multiple selectors", -> + it "triggers the binding for the most specific selector", -> + handler.bindKeys 'div .child-node', 'x': 'foo' + handler.bindKeys '.command-mode .child-node', 'x': 'baz' + handler.bindKeys '.child-node', 'x': 'bar' + + fooHandler = jasmine.createSpy 'fooHandler' + barHandler = jasmine.createSpy 'barHandler' + bazHandler = jasmine.createSpy 'bazHandler' + fragment.on 'foo', fooHandler + fragment.on 'bar', barHandler + fragment.on 'baz', bazHandler + + target = fragment.find('.grandchild-node')[0] + handler.handleKeypress(keypressEvent('x', target: target)) + + expect(fooHandler).not.toHaveBeenCalled() + expect(barHandler).not.toHaveBeenCalled() + expect(bazHandler).toHaveBeenCalled() diff --git a/src/atom/binding-set.coffee b/src/atom/binding-set.coffee index 08584506a..39d331dcc 100644 --- a/src/atom/binding-set.coffee +++ b/src/atom/binding-set.coffee @@ -1,4 +1,5 @@ $ = require 'jquery' +Specificity = require 'specificity' module.exports = class BindingSet @@ -13,6 +14,7 @@ class BindingSet bindings: null constructor: (@selector, @bindings) -> + @specificity = Specificity(@selector) commandForEvent: (event) -> for pattern, command of @bindings diff --git a/src/atom/key-event-handler.coffee b/src/atom/key-event-handler.coffee index 786cf26b0..132c64e8b 100644 --- a/src/atom/key-event-handler.coffee +++ b/src/atom/key-event-handler.coffee @@ -1,5 +1,6 @@ $ = require 'jquery' BindingSet = require 'binding-set' +Specificity = require 'specificity' module.exports = class KeyEventHandler @@ -12,12 +13,13 @@ class KeyEventHandler @bindingSets.push(new BindingSet(selector, bindings)) handleKeypress: (event) -> - currentNode = event.target + currentNode = $(event.target) while currentNode - for bindingSet in @bindingSets - if $(currentNode).is(bindingSet.selector) - if command = bindingSet.commandForEvent(event) - $(event.target).trigger(command) - return - currentNode = currentNode.parentNode + candidateBindingSets = @bindingSets.filter (set) -> currentNode.is(set.selector) + candidateBindingSets.sort (a, b) -> b.specificity - a.specificity + for bindingSet in candidateBindingSets + if command = bindingSet.commandForEvent(event) + $(event.target).trigger(command) + return + currentNode = currentNode.parent() diff --git a/vendor/slick.js b/vendor/slick.js new file mode 100644 index 000000000..cbeecdbe4 --- /dev/null +++ b/vendor/slick.js @@ -0,0 +1,232 @@ +// changed at the bottom to export the Slick object + +/* +--- +name: Slick.Parser +description: Standalone CSS3 Selector parser +provides: Slick.Parser +... +*/ + +;(function(){ + +var parsed, + separatorIndex, + combinatorIndex, + reversed, + cache = {}, + reverseCache = {}, + reUnescape = /\\/g; + +var parse = function(expression, isReversed){ + if (expression == null) return null; + if (expression.Slick === true) return expression; + expression = ('' + expression).replace(/^\s+|\s+$/g, ''); + reversed = !!isReversed; + var currentCache = (reversed) ? reverseCache : cache; + if (currentCache[expression]) return currentCache[expression]; + parsed = { + Slick: true, + expressions: [], + raw: expression, + reverse: function(){ + return parse(this.raw, true); + } + }; + separatorIndex = -1; + while (expression != (expression = expression.replace(regexp, parser))); + parsed.length = parsed.expressions.length; + return currentCache[parsed.raw] = (reversed) ? reverse(parsed) : parsed; +}; + +var reverseCombinator = function(combinator){ + if (combinator === '!') return ' '; + else if (combinator === ' ') return '!'; + else if ((/^!/).test(combinator)) return combinator.replace(/^!/, ''); + else return '!' + combinator; +}; + +var reverse = function(expression){ + var expressions = expression.expressions; + for (var i = 0; i < expressions.length; i++){ + var exp = expressions[i]; + var last = {parts: [], tag: '*', combinator: reverseCombinator(exp[0].combinator)}; + + for (var j = 0; j < exp.length; j++){ + var cexp = exp[j]; + if (!cexp.reverseCombinator) cexp.reverseCombinator = ' '; + cexp.combinator = cexp.reverseCombinator; + delete cexp.reverseCombinator; + } + + exp.reverse().push(last); + } + return expression; +}; + +var escapeRegExp = function(string){// Credit: XRegExp 0.6.1 (c) 2007-2008 Steven Levithan MIT License + return string.replace(/[-[\]{}()*+?.\\^$|,#\s]/g, function(match){ + return '\\' + match; + }); +}; + +var regexp = new RegExp( +/* +#!/usr/bin/env ruby +puts "\t\t" + DATA.read.gsub(/\(\?x\)|\s+#.*$|\s+|\\$|\\n/,'') +__END__ + "(?x)^(?:\ + \\s* ( , ) \\s* # Separator \n\ + | \\s* ( + ) \\s* # Combinator \n\ + | ( \\s+ ) # CombinatorChildren \n\ + | ( + | \\* ) # Tag \n\ + | \\# ( + ) # ID \n\ + | \\. ( + ) # ClassName \n\ + | # Attribute \n\ + \\[ \ + \\s* (+) (?: \ + \\s* ([*^$!~|]?=) (?: \ + \\s* (?:\ + ([\"']?)(.*?)\\9 \ + )\ + ) \ + )? \\s* \ + \\](?!\\]) \n\ + | :+ ( + )(?:\ + \\( (?:\ + (?:([\"'])([^\\12]*)\\12)|((?:\\([^)]+\\)|[^()]*)+)\ + ) \\)\ + )?\ + )" +*/ + "^(?:\\s*(,)\\s*|\\s*(+)\\s*|(\\s+)|(+|\\*)|\\#(+)|\\.(+)|\\[\\s*(+)(?:\\s*([*^$!~|]?=)(?:\\s*(?:([\"']?)(.*?)\\9)))?\\s*\\](?!\\])|(:+)(+)(?:\\((?:(?:([\"'])([^\\13]*)\\13)|((?:\\([^)]+\\)|[^()]*)+))\\))?)" + .replace(//, '[' + escapeRegExp(">+~`!@$%^&={}\\;/g, '(?:[\\w\\u00a1-\\uFFFF-]|\\\\[^\\s0-9a-f])') + .replace(//g, '(?:[:\\w\\u00a1-\\uFFFF-]|\\\\[^\\s0-9a-f])') +); + +function parser( + rawMatch, + + separator, + combinator, + combinatorChildren, + + tagName, + id, + className, + + attributeKey, + attributeOperator, + attributeQuote, + attributeValue, + + pseudoMarker, + pseudoClass, + pseudoQuote, + pseudoClassQuotedValue, + pseudoClassValue +){ + if (separator || separatorIndex === -1){ + parsed.expressions[++separatorIndex] = []; + combinatorIndex = -1; + if (separator) return ''; + } + + if (combinator || combinatorChildren || combinatorIndex === -1){ + combinator = combinator || ' '; + var currentSeparator = parsed.expressions[separatorIndex]; + if (reversed && currentSeparator[combinatorIndex]) + currentSeparator[combinatorIndex].reverseCombinator = reverseCombinator(combinator); + currentSeparator[++combinatorIndex] = {combinator: combinator, tag: '*'}; + } + + var currentParsed = parsed.expressions[separatorIndex][combinatorIndex]; + + if (tagName){ + currentParsed.tag = tagName.replace(reUnescape, ''); + + } else if (id){ + currentParsed.id = id.replace(reUnescape, ''); + + } else if (className){ + className = className.replace(reUnescape, ''); + + if (!currentParsed.classList) currentParsed.classList = []; + if (!currentParsed.classes) currentParsed.classes = []; + currentParsed.classList.push(className); + currentParsed.classes.push({ + value: className, + regexp: new RegExp('(^|\\s)' + escapeRegExp(className) + '(\\s|$)') + }); + + } else if (pseudoClass){ + pseudoClassValue = pseudoClassValue || pseudoClassQuotedValue; + pseudoClassValue = pseudoClassValue ? pseudoClassValue.replace(reUnescape, '') : null; + + if (!currentParsed.pseudos) currentParsed.pseudos = []; + currentParsed.pseudos.push({ + key: pseudoClass.replace(reUnescape, ''), + value: pseudoClassValue, + type: pseudoMarker.length == 1 ? 'class' : 'element' + }); + + } else if (attributeKey){ + attributeKey = attributeKey.replace(reUnescape, ''); + attributeValue = (attributeValue || '').replace(reUnescape, ''); + + var test, regexp; + + switch (attributeOperator){ + case '^=' : regexp = new RegExp( '^'+ escapeRegExp(attributeValue) ); break; + case '$=' : regexp = new RegExp( escapeRegExp(attributeValue) +'$' ); break; + case '~=' : regexp = new RegExp( '(^|\\s)'+ escapeRegExp(attributeValue) +'(\\s|$)' ); break; + case '|=' : regexp = new RegExp( '^'+ escapeRegExp(attributeValue) +'(-|$)' ); break; + case '=' : test = function(value){ + return attributeValue == value; + }; break; + case '*=' : test = function(value){ + return value && value.indexOf(attributeValue) > -1; + }; break; + case '!=' : test = function(value){ + return attributeValue != value; + }; break; + default : test = function(value){ + return !!value; + }; + } + + if (attributeValue == '' && (/^[*$^]=$/).test(attributeOperator)) test = function(){ + return false; + }; + + if (!test) test = function(value){ + return value && regexp.test(value); + }; + + if (!currentParsed.attributes) currentParsed.attributes = []; + currentParsed.attributes.push({ + key: attributeKey, + operator: attributeOperator, + value: attributeValue, + test: test + }); + + } + + return ''; +}; + +// Slick NS + +var Slick = (this.Slick || {}); + +Slick.parse = function(expression){ + return parse(expression); +}; + +Slick.escapeRegExp = escapeRegExp; + +this.exports = Slick + +}).apply(module); diff --git a/vendor/specificity.js b/vendor/specificity.js new file mode 100644 index 000000000..6059a4a26 --- /dev/null +++ b/vendor/specificity.js @@ -0,0 +1,33 @@ +// source: MooTools DOM branch -> https://raw.github.com/arian/DOM/matcher-specificity/Source/specificity.js +// changed to be compatible with our require system + +var Slick = require('slick'); + +module.exports = function(selector){ + + var parsed = Slick.parse(selector); + var expressions = parsed.expressions; + var specificity = -1; + for (var j = 0; j < expressions.length; j++){ + var b = 0, c = 0, d = 0, s = 0, nots = []; + for (var i = 0; i < expressions[j].length; i++){ + var expression = expressions[j][i], pseudos = expression.pseudos; + if (expression.id) b++; + if (expression.attributes) c += expression.attributes.length; + if (expression.classes) c += expression.classes.length; + if (expression.tag && expression.tag != '*') d++; + if (pseudos){ + d += pseudos.length; + for (var p = 0; p < pseudos.length; p++) if (pseudos[p].key == 'not'){ + nots.push(pseudos[p].value); + d--; + } + } + } + s = b * 1e6 + c * 1e3 + d; + for (var ii = nots.length; ii--;) s += this.specificity(nots[ii]); + if (s > specificity) specificity = s; + } + return specificity; +}; +