├── .gitignore
├── bower.json
├── .editorconfig
├── LICENSE
├── package.json
├── test
├── karma.conf.js
└── hotkeys.coffee
├── src
├── hotkeys.css
└── hotkeys.js
├── Gruntfile.js
├── .jshintrc
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | test/coverage
3 | bower_components/
4 | .coveralls.yml
5 |
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "angular-hotkeys",
3 | "main": [
4 | "build/hotkeys.js",
5 | "build/hotkeys.css"
6 | ],
7 | "ignore": [
8 | "**/.*",
9 | "node_modules",
10 | "components",
11 | "test",
12 | "example"
13 | ],
14 | "devDependencies": {
15 | "mousetrap": "~1.5.2",
16 | "angular-mocks": "~1.2.15",
17 | "angular-route": "~1.2.15"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig helps developers define and maintain consistent
2 | # coding styles between different editors and IDEs
3 | # editorconfig.org
4 |
5 | root = true
6 |
7 |
8 | [*]
9 |
10 | # Change these settings to your own preference
11 | indent_style = space
12 | indent_size = 2
13 |
14 | # We recommend you to keep these unchanged
15 | end_of_line = lf
16 | charset = utf-8
17 | trim_trailing_whitespace = true
18 | insert_final_newline = true
19 |
20 | [*.md]
21 | trim_trailing_whitespace = false
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Wes Cruver
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "angular-hotkeys",
3 | "author": "Wes Cruver",
4 | "version": "1.7.0",
5 | "license": "MIT",
6 | "description": "Automatic keyboard shortcuts for your Angular Apps",
7 | "homepage": "https://chieffancypants.github.io/angular-hotkeys",
8 | "main": "build/hotkeys.js",
9 | "keywords": [
10 | "angular",
11 | "angularjs",
12 | "keyboard",
13 | "shortcut",
14 | "hotkeys"
15 | ],
16 | "repository": {
17 | "type": "git",
18 | "url": "git://github.com/chieffancypants/angular-hotkeys.git"
19 | },
20 | "bugs": {
21 | "url": "https://github.com/chieffancypants/angular-hotkeys/issues"
22 | },
23 | "scripts": {
24 | "test": "node_modules/karma/bin/karma start test/karma.conf.js"
25 | },
26 | "devDependencies": {
27 | "grunt": "~0.4.1",
28 | "grunt-contrib-concat": "^0.5.1",
29 | "grunt-contrib-cssmin": "^0.12.3",
30 | "grunt-contrib-jshint": "~0.6.4",
31 | "grunt-contrib-uglify": "^0.9.1",
32 | "grunt-contrib-watch": "^0.6.1",
33 | "grunt-karma": "^0.11.0",
34 | "grunt-ng-annotate": "^0.3.0",
35 | "karma": "~0.12.0",
36 | "karma-chrome-launcher": "~0.1.0",
37 | "karma-coffee-preprocessor": "~0.1.0",
38 | "karma-coverage": "~0.1.0",
39 | "karma-firefox-launcher": "~0.1.0",
40 | "karma-html2js-preprocessor": "~0.1.0",
41 | "karma-jasmine": "~0.1.3",
42 | "karma-phantomjs-launcher": "^0.2.0",
43 | "karma-script-launcher": "~0.1.0"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/test/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration
2 | // Generated on Sun Sep 15 2013 20:18:09 GMT-0400 (EDT)
3 |
4 | module.exports = function(config) {
5 | config.set({
6 |
7 | // base path, that will be used to resolve files and exclude
8 | basePath: '',
9 |
10 |
11 | // frameworks to use
12 | frameworks: ['jasmine'],
13 |
14 |
15 | // list of files / patterns to load in the browser
16 | files: [
17 | '../bower_components/angular/angular.js',
18 | '../bower_components/angular-mocks/angular-mocks.js',
19 | '../bower_components/angular-route/angular-route.js',
20 | '../bower_components/mousetrap/mousetrap.js',
21 | '../bower_components/mousetrap/tests/libs/key-event.js',
22 | '../src/*.js',
23 | '*.coffee'
24 | ],
25 |
26 |
27 | // list of files to exclude
28 | exclude: [
29 |
30 | ],
31 |
32 |
33 | // test results reporter to use
34 | // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage'
35 | reporters: ['progress', 'coverage'],
36 |
37 |
38 | // web server port
39 | port: 9876,
40 |
41 |
42 | // enable / disable colors in the output (reporters and logs)
43 | colors: true,
44 |
45 |
46 | // level of logging
47 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
48 | logLevel: config.LOG_INFO,
49 |
50 |
51 | // enable / disable watching file and executing tests whenever any file changes
52 | autoWatch: true,
53 |
54 |
55 | // Start these browsers, currently available:
56 | // - Chrome
57 | // - ChromeCanary
58 | // - Firefox
59 | // - Opera
60 | // - Safari (only Mac)
61 | // - PhantomJS
62 | // - IE (only Windows)
63 | browsers: ['PhantomJS'],
64 |
65 | coverageReporter: {
66 | type : 'html',
67 | dir : 'coverage/',
68 | },
69 |
70 | preprocessors: {
71 | '../src/*.js': ['coverage'],
72 | '*.coffee': 'coffee'
73 | },
74 |
75 |
76 | // If browser does not capture in given timeout [ms], kill it
77 | captureTimeout: 60000,
78 |
79 |
80 | // Continuous Integration mode
81 | // if true, it capture browsers, run tests and exit
82 | singleRun: true,
83 |
84 | });
85 | };
86 |
--------------------------------------------------------------------------------
/src/hotkeys.css:
--------------------------------------------------------------------------------
1 | .cfp-hotkeys-container {
2 | display: table !important;
3 | position: fixed;
4 | width: 100%;
5 | height: 100%;
6 | top: 0;
7 | left: 0;
8 | color: #333;
9 | font-size: 1em;
10 | background-color: rgba(255,255,255,0.9);
11 | }
12 |
13 | .cfp-hotkeys-container.fade {
14 | z-index: -1024;
15 | visibility: hidden;
16 | opacity: 0;
17 | -webkit-transition: opacity 0.15s linear;
18 | -moz-transition: opacity 0.15s linear;
19 | -o-transition: opacity 0.15s linear;
20 | transition: opacity 0.15s linear;
21 | }
22 |
23 | .cfp-hotkeys-container.fade.in {
24 | z-index: 10002;
25 | visibility: visible;
26 | opacity: 1;
27 | }
28 |
29 | .cfp-hotkeys-title {
30 | font-weight: bold;
31 | text-align: center;
32 | font-size: 1.2em;
33 | }
34 |
35 | .cfp-hotkeys {
36 | width: 100%;
37 | height: 100%;
38 | display: table-cell;
39 | vertical-align: middle;
40 | }
41 |
42 | .cfp-hotkeys table {
43 | margin: auto;
44 | color: #333;
45 | }
46 |
47 | .cfp-content {
48 | display: table-cell;
49 | vertical-align: middle;
50 | }
51 |
52 | .cfp-hotkeys-keys {
53 | padding: 5px;
54 | text-align: right;
55 | }
56 |
57 | .cfp-hotkeys-key {
58 | display: inline-block;
59 | color: #fff;
60 | background-color: #333;
61 | border: 1px solid #333;
62 | border-radius: 5px;
63 | text-align: center;
64 | margin-right: 5px;
65 | box-shadow: inset 0 1px 0 #666, 0 1px 0 #bbb;
66 | padding: 5px 9px;
67 | font-size: 1em;
68 | }
69 |
70 | .cfp-hotkeys-text {
71 | padding-left: 10px;
72 | font-size: 1em;
73 | }
74 |
75 | .cfp-hotkeys-close {
76 | position: fixed;
77 | top: 20px;
78 | right: 20px;
79 | font-size: 2em;
80 | font-weight: bold;
81 | padding: 5px 10px;
82 | border: 1px solid #ddd;
83 | border-radius: 5px;
84 | min-height: 45px;
85 | min-width: 45px;
86 | text-align: center;
87 | }
88 |
89 | .cfp-hotkeys-close:hover {
90 | background-color: #fff;
91 | cursor: pointer;
92 | }
93 |
94 | @media all and (max-width: 500px) {
95 | .cfp-hotkeys {
96 | font-size: 0.8em;
97 | }
98 | }
99 |
100 | @media all and (min-width: 750px) {
101 | .cfp-hotkeys {
102 | font-size: 1.2em;
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/Gruntfile.js:
--------------------------------------------------------------------------------
1 | /*global module:false*/
2 | module.exports = function(grunt) {
3 |
4 | grunt.initConfig({
5 |
6 | // Metadata.
7 | pkg: grunt.file.readJSON('package.json'),
8 | banner: '/*! \n * <%= pkg.title || pkg.name %> v<%= pkg.version %>\n' +
9 | ' * <%= pkg.homepage %>\n' +
10 | ' * Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author %>\n' +
11 | ' * License: <%= pkg.license %>\n' +
12 | ' */\n',
13 |
14 | // Task configuration.
15 | uglify: {
16 | options: {
17 | banner: '<%= banner %>',
18 | report: 'gzip'
19 | },
20 | build: {
21 | src: ['build/hotkeys.js', 'bower_components/mousetrap/mousetrap.js'],
22 | dest: 'build/hotkeys.min.js'
23 | }
24 | },
25 |
26 | ngAnnotate: {
27 | options: {
28 | singleQuotes: true,
29 | },
30 | source: {
31 | expand: true,
32 | cwd: 'src',
33 | src: ['*.js'],
34 | dest: 'build'
35 | }
36 | },
37 |
38 | cssmin: {
39 | options: {
40 | banner: '<%= banner %>',
41 | report: 'gzip'
42 | },
43 | minify: {
44 | src: 'src/hotkeys.css',
45 | dest: 'build/hotkeys.min.css'
46 | }
47 | },
48 |
49 | karma: {
50 | unit: {
51 | configFile: 'test/karma.conf.js',
52 | singleRun: true,
53 | coverageReporter: {
54 | type: 'text',
55 | dir: 'coverage/'
56 | }
57 | },
58 | watch: {
59 | configFile: 'test/karma.conf.js',
60 | singleRun: false,
61 | reporters: ['progress'] // Don't display coverage
62 | }
63 | },
64 |
65 | jshint: {
66 | jshintrc: '.jshintrc',
67 | gruntfile: {
68 | src: 'Gruntfile.js'
69 | },
70 | src: {
71 | src: ['src/*.js']
72 | }
73 | },
74 |
75 | concat: {
76 | build: {
77 | options: {
78 | banner: '<%= banner %>'
79 | },
80 | files: {
81 | 'build/hotkeys.css': 'src/hotkeys.css',
82 | 'build/hotkeys.js': ['build/hotkeys.js', 'bower_components/mousetrap/mousetrap.js'],
83 | }
84 | }
85 | },
86 |
87 | watch: {
88 | scripts: {
89 | files: ['src/*.js'],
90 | tasks: ['uglify', 'concat:build'],
91 | options: {
92 | spawn: false,
93 | },
94 | },
95 | css: {
96 | files: ['src/*.css'],
97 | tasks: ['cssmin', 'concat:build'],
98 | options: {
99 | spawn: false,
100 | },
101 | }
102 | },
103 |
104 | });
105 |
106 | grunt.loadNpmTasks('grunt-contrib-uglify');
107 | grunt.loadNpmTasks('grunt-ng-annotate');
108 | grunt.loadNpmTasks('grunt-contrib-jshint');
109 | grunt.loadNpmTasks('grunt-contrib-cssmin');
110 | grunt.loadNpmTasks('grunt-contrib-concat');
111 | grunt.loadNpmTasks('grunt-contrib-watch');
112 | grunt.loadNpmTasks('grunt-karma');
113 |
114 | grunt.registerTask('default', ['jshint', 'karma:unit', 'ngAnnotate', 'uglify', 'cssmin', 'concat:build']);
115 | grunt.registerTask('test', ['karma:watch']);
116 | grunt.registerTask('build', ['default']);
117 |
118 | };
119 |
--------------------------------------------------------------------------------
/.jshintrc:
--------------------------------------------------------------------------------
1 | {
2 | "globals": {
3 | "angular": false
4 | },
5 |
6 | "bitwise" : true, // Prohibit bitwise operators (&, |, ^, etc.).
7 | "curly" : true, // Require {} for every new block or scope.
8 | "eqeqeq" : true, // Require triple equals i.e. `===`.
9 | "forin" : true, // Tolerate `for in` loops without `hasOwnPrototype`.
10 | "immed" : true, // Require immediate invocations to be wrapped in parens e.g. `( function(){}() );`
11 | "latedef" : true, // Prohibit variable use before definition.
12 | "newcap" : true, // Require capitalization of all constructor functions e.g. `new F()`.
13 | "noarg" : true, // Prohibit use of `arguments.caller` and `arguments.callee`.
14 | "noempty" : true, // Prohibit use of empty blocks.
15 | "nonew" : true, // Prohibit use of constructors for side-effects.
16 | "plusplus" : false, // Prohibit use of `++` & `--`.
17 | "regexp" : true, // Prohibit `.` and `[^...]` in regular expressions.
18 | "undef" : true, // Require all non-global variables be declared before they are used.
19 | "strict" : false, // Require `use strict` pragma in every file.
20 | "trailing" : true, // Prohibit trailing whitespaces.
21 |
22 |
23 | // Relaxing options:
24 | "asi" : false, // Tolerate Automatic Semicolon Insertion (no semicolons).
25 | "boss" : false, // Tolerate assignments inside if, for & while. Usually conditions & loops are for comparison, not assignments.
26 | "debug" : false, // Allow debugger statements e.g. browser breakpoints.
27 | "eqnull" : false, // Tolerate use of `== null`.
28 | "es5" : false, // Allow EcmaScript 5 syntax.
29 | "esnext" : false, // Allow ES.next specific features such as `const` and `let`.
30 | "evil" : false, // Tolerate use of `eval`.
31 | "expr" : false, // Tolerate `ExpressionStatement` as Programs.
32 | "funcscope" : false, // Tolerate declarations of variables inside of control structures while accessing them later from the outside.
33 | "globalstrict" : false, // Allow global "use strict" (also enables 'strict').
34 | "iterator" : false, // Allow usage of __iterator__ property.
35 | "lastsemic" : false, // Tolerat missing semicolons when the it is omitted for the last statement in a one-line block.
36 | "laxbreak" : false, // Tolerate unsafe line breaks e.g. `return [\n] x` without semicolons.
37 | "laxcomma" : false, // Suppress warnings about comma-first coding style.
38 | "loopfunc" : false, // Allow functions to be defined within loops.
39 | "multistr" : false, // Tolerate multi-line strings.
40 | "onecase" : false, // Tolerate switches with just one case.
41 | "proto" : false, // Tolerate __proto__ property. This property is deprecated.
42 | "regexdash" : false, // Tolerate unescaped last dash i.e. `[-...]`.
43 | "scripturl" : false, // Tolerate script-targeted URLs.
44 | "smarttabs" : false, // Tolerate mixed tabs and spaces when the latter are used for alignmnent only.
45 | "shadow" : false, // Allows re-define variables later in code e.g. `var x=1; x=2;`.
46 | "sub" : false, // Tolerate all forms of subscript notation besides dot notation e.g. `dict['key']` instead of `dict.key`.
47 | "supernew" : false, // Tolerate `new function () { ... };` and `new Object;`.
48 | "validthis" : false, // Tolerate strict violations when the code is running in strict mode and you use this in a non-constructor function.
49 |
50 | // == Environments ====================================================
51 | //
52 | // These options pre-define global variables that are exposed by
53 | // popular JavaScript libraries and runtime environments—such as
54 | // browser or node.js.
55 |
56 | "browser" : true, // Standard browser globals e.g. `window`, `document`.
57 | "couch" : false, // Enable globals exposed by CouchDB.
58 | "devel" : false, // Allow development statements e.g. `console.log();`.
59 | "dojo" : false, // Enable globals exposed by Dojo Toolkit.
60 | "jquery" : false, // Enable globals exposed by jQuery JavaScript library.
61 | "mootools" : false, // Enable globals exposed by MooTools JavaScript framework.
62 | "node" : false, // Enable globals available when code is running inside of the NodeJS runtime environment.
63 | "nonstandard" : false, // Define non-standard but widely adopted globals such as escape and unescape.
64 | "prototypejs" : false, // Enable globals exposed by Prototype JavaScript framework.
65 | "rhino" : false, // Enable globals available when your code is running inside of the Rhino runtime environment.
66 | "wsh" : false, // Enable globals available when your code is running as a script for the Windows Script Host.
67 |
68 | // == JSLint Legacy ===================================================
69 | //
70 | // These options are legacy from JSLint. Aside from bug fixes they will
71 | // not be improved in any way and might be removed at any point.
72 |
73 | "nomen" : false, // Prohibit use of initial or trailing underbars in names.
74 | "onevar" : false, // Allow only one `var` statement per function.
75 | "passfail" : false, // Stop on first error.
76 | "white" : false, // Check against strict whitespace and indentation rules.
77 |
78 | // == Undocumented Options ============================================
79 | //
80 | // While I've found these options in [example1][2] and [example2][3]
81 | // they are not described in the [JSHint Options documentation][4].
82 | //
83 | // [4]: http://www.jshint.com/options/
84 |
85 | "maxerr" : 100, // Maximum errors before stopping.
86 | "predef" : [ // Extra globals.
87 | //"exampleVar",
88 | //"anotherCoolGlobal",
89 | //"iLoveDouglas"
90 | ],
91 | "indent" : 2 // Specify indentation spacing
92 |
93 | }
94 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | angular-hotkeys
2 | ================
3 | Configuration-centric keyboard shortcuts for your Angular apps.
4 |
5 | [](https://coveralls.io/r/chieffancypants/angular-hotkeys?branch=master)
6 | 
7 |
8 | **Requirements**: Angular 1.2+
9 |
10 | ### Features:
11 | - Define hotkeys on an entire route, automatically binding and unbinding them as you navigate
12 | - Automatic listing of shortcuts when users hit the `?` key
13 | - Super duper unit tests
14 |
15 |
16 | ### Installation:
17 |
18 | #### via bower:
19 |
20 | ```
21 | $ bower install chieffancypants/angular-hotkeys --save
22 | ```
23 |
24 | #### via npm:
25 |
26 | ```
27 | $ npm install angular-hotkeys --save
28 | ```
29 |
30 |
31 | *please use either the minified or unminified file in the `build` directory*
32 |
33 | ### Why I made this:
34 | Other projects out there rely too heavily on HTML markup for keyboard shortcuts. For example:
35 |
36 | ```html
37 |
38 |
39 |
40 |
41 | ```
42 |
43 | While this is a great approach for many Angular apps, some applications do not have a 1 to 1 relationship between DOM elements and controller methods. In my case, many methods on the controller were **only** accessible through the keyboard.
44 |
45 | Additionally, this only allows you to pass a function reference, you can't pass arguments to the function you intend to call. So instead of simply calling `seek(currentTime + 30)` and `seek(currentTime + 60)`, I needed to create a ton of helper functions on the scope (such as `forward30` and `forward60`), and litter my HTML like this:
46 |
47 | ```html
48 |
59 |
60 |
61 |
62 |
63 | ```
64 |
65 | With a few dozen shortcuts, this left the DOM really messy, and with multiple views and directive templates, it was next to impossible to remember where all the different shortcuts were. This became a maintenance nightmare.
66 |
67 |
68 | ### Usage:
69 | You can either define hotkeys in your Controller, or in your Route configuration (or both). To start, though, require the lib as a dependency for your angular app:
70 |
71 | ```js
72 | angular.module('myApp', ['ngRoute', 'cfp.hotkeys']);
73 | ```
74 |
75 | Behind the scenes, I'm using the [Mousetrap](https://github.com/ccampbell/mousetrap) library to manage the key bindings. Check out the docs there for more information on what kind of key combinations can be used. This library is included in the files from the `build` directory, so there is no need to install and include Mousetrap separately.
76 |
77 | **Update:** [A YouTube video tutorial was created for this project](https://www.youtube.com/watch?v=silr0L7rJOY). Thanks guys!
78 |
79 |
80 | #### Binding hotkeys in controllers:
81 | It is important to note that by default, hotkeys bound using the `hotkeys.add()`
82 | method are persistent, meaning they will continue to exist through route
83 | changes, DOM manipulation, or anything else.
84 |
85 | However, it is possible to bind the hotkey to a particular scope, and when that
86 | scope is destroyed, the hotkey is automatically removed. This should be
87 | considered the best practice when binding hotkeys from a controller. For this
88 | usage example, see the `hotkeys.bindTo()` method below:
89 |
90 | ```js
91 | angular.module('myApp').controller('NavbarCtrl', function($scope, hotkeys) {
92 | $scope.volume = 5;
93 |
94 | // You can pass it an object. This hotkey will not be unbound unless manually removed
95 | // using the hotkeys.del() method
96 | hotkeys.add({
97 | combo: 'ctrl+up',
98 | description: 'This one goes to 11',
99 | callback: function() {
100 | $scope.volume += 1;
101 | }
102 | });
103 |
104 | // when you bind it to the controller's scope, it will automatically unbind
105 | // the hotkey when the scope is destroyed (due to ng-if or something that changes the DOM)
106 | hotkeys.bindTo($scope)
107 | .add({
108 | combo: 'w',
109 | description: 'blah blah',
110 | callback: function() {}
111 | })
112 | // you can chain these methods for ease of use:
113 | .add ({...});
114 |
115 | });
116 | ```
117 |
118 | #### Binding hotkeys in routes:
119 | You can also define hotkeys on an entire route, and this lib will bind and unbind them as you navigate the app.
120 |
121 | ```js
122 | angular.module('myApp').config(function ($routeProvider) {
123 | $routeProvider.when('/', {
124 | controller: 'RestaurantsController',
125 | templateUrl: 'views/restaurants.html',
126 | hotkeys: [
127 | ['p', 'Sort by price', 'sort(price)']
128 | ]
129 | });
130 | });
131 | ```
132 |
133 | #### Binding hotkeys in directives:
134 | Lastly, even though binding hotkeys in your templates/html tends to be a bad idea, it can be super useful for simple shortcuts. Think along the lines of a modal directive where you simply want to bind to the escape key or something equally simple. Accomplishing this within a controller is too much overhead, and it may lead to code-reuse.
135 |
136 | Example of how directive-based hotkeys works:
137 |
138 | ```html
139 |
140 | ```
141 |
142 | #### Cheatsheet
143 |
144 | A cheatsheet is created automatically for you, showing which hotkeys are available for the current route, along with a description as to what it does. The default binding to show the cheatsheet is `?`. Be sure to include the `build/hotkeys.css` stylesheet. [Cheatsheet demo](http://chieffancypants.github.io/angular-hotkeys/#features)
145 |
146 | **Disable the cheatsheet:**
147 |
148 | Disabling the cheatsheet can be accomplished by configuring the `hotkeysProvider`:
149 |
150 | ```js
151 | angular.module('myApp', ['cfp.hotkeys'])
152 | .config(function(hotkeysProvider) {
153 | hotkeysProvider.includeCheatSheet = false;
154 | })
155 | ```
156 |
157 | ### Configuration
158 |
159 | **Disable ngRoute integration:**
160 |
161 | To prevent listening for $routeChangeSuccess events use `hotkeysProvider`.
162 | This option defaults to false if ngRoute module is not loaded:
163 |
164 | ```js
165 | angular.module('myApp', ['cfp.hotkeys'])
166 | .config(function(hotkeysProvider) {
167 | hotkeysProvider.useNgRoute = false;
168 | })
169 | ```
170 |
171 | **Cheatsheet template:**
172 |
173 | ```js
174 | angular.module('myApp', ['cfp.hotkeys'])
175 | .config(function(hotkeysProvider) {
176 | hotkeysProvider.template = '
...
';
177 | })
178 | ```
179 |
180 | **Header and footer:**
181 |
182 | You can specify a custom header and footer for the cheatsheet. Both are HTML, and if the header is set it will override the normal title.
183 |
184 | ```js
185 | angular.module('myApp', ['cfp.hotkeys'])
186 | .config(function(hotkeysProvider) {
187 | hotkeysProvider.templateHeader = '
...
';
188 | hotkeysProvider.templateFooter = '';
189 | })
190 | ```
191 |
192 | ### API
193 |
194 | #### hotkeys.add(object)
195 | `object`: An object with the following parameters:
196 | - `combo`: They keyboard combo (shortcut) you want to bind to
197 | - `description`: [OPTIONAL] The description for what the combo does and is only used for the Cheat Sheet. If it is not supplied, it will not show up, and in effect, allows you to have unlisted hotkeys.
198 | - `callback`: The function to execute when the key(s) are pressed. Passes along two arguments, `event` and `hotkey`
199 | - `action`: [OPTIONAL] The type of event to listen for, such as `keypress`, `keydown` or `keyup`. Usage of this parameter is discouraged as the underlying library will pick the most suitable option automatically. This should only be necessary in advanced situations.
200 | - `allowIn`: [OPTIONAL] an array of tag names to allow this combo in ('INPUT', 'SELECT', and/or 'TEXTAREA')
201 |
202 | ```js
203 | hotkeys.add({
204 | combo: 'ctrl+w',
205 | description: 'Description goes here',
206 | callback: function(event, hotkey) {
207 | event.preventDefault();
208 | }
209 | });
210 |
211 | // this hotkey will not show up on the cheat sheet:
212 | hotkeys.add({
213 | combo: 'ctrl+x',
214 | callback: function(event, hotkey) {...}
215 | });
216 | ```
217 |
218 | #### hotkeys.get(key)
219 | Returns the Hotkey object
220 |
221 | ```js
222 | hotkeys.get('ctrl+w');
223 | // -> Hotkey { combo: ['ctrl+w'], description: 'Description goes here', callback: function (event, hotkey) }
224 | ```
225 |
226 | #### hotkeys.del(key)
227 | Removes and unbinds a hotkey
228 |
229 | ```js
230 | hotkeys.del('ctrl+w');
231 | ```
232 |
233 | ### Allowing hotkeys in form elements
234 | By default, Mousetrap prevents hotkey callbacks from firing when their event originates from an `input`, `select`, or `textarea` element. To enable hotkeys in these elements, specify them in the `allowIn` parameter:
235 | ```js
236 | hotkeys.add({
237 | combo: 'ctrl+w',
238 | description: 'Description goes here',
239 | allowIn: ['INPUT', 'SELECT', 'TEXTAREA'],
240 | callback: function(event, hotkey) {
241 | event.preventDefault();
242 | }
243 | });
244 | ```
245 |
246 | ## Credits:
247 |
248 | Muchas gracias to Craig Campbell for his [Mousetrap](https://github.com/ccampbell/mousetrap) library, which provides the underlying library for handling keyboard shortcuts.
249 |
--------------------------------------------------------------------------------
/test/hotkeys.coffee:
--------------------------------------------------------------------------------
1 |
2 | describe 'Angular Hotkeys', ->
3 |
4 | hotkeys = scope = $rootScope = $rootElement = $window = null
5 |
6 | beforeEach ->
7 | module 'cfp.hotkeys', (hotkeysProvider) ->
8 | hotkeysProvider.useNgRoute = true
9 | return
10 |
11 | result = null
12 | inject (_$rootElement_, _$rootScope_, _hotkeys_) ->
13 | hotkeys = _hotkeys_
14 | $rootElement = _$rootElement_
15 | $rootScope = _$rootScope_
16 | scope = $rootScope.$new()
17 |
18 | afterEach ->
19 | hotkeys.del('w')
20 | t = document.getElementById('cfp-test')
21 | if t
22 | t.parentNode.removeChild(t)
23 |
24 | it 'should insert the help menu into the dom', ->
25 | children = angular.element($rootElement).children()
26 | expect(children.hasClass('cfp-hotkeys-container')).toBe true
27 |
28 | it 'add(args)', ->
29 | hotkeys.add 'w', 'description here', ->
30 | expect(hotkeys.get('w').description).toBe 'description here'
31 | expect(hotkeys.get('x')).toBe false
32 |
33 | it 'add(object)', ->
34 | callback = false
35 | hotkeys.add
36 | combo: 'w'
37 | description: 'description'
38 | callback: () ->
39 | callback = true
40 |
41 | expect(hotkeys.get('w').description).toBe 'description'
42 |
43 | # Test callback:
44 | expect(callback).toBe false
45 | KeyEvent.simulate('w'.charCodeAt(0), 90)
46 | expect(callback).toBe true
47 |
48 | it 'description should be optional', ->
49 | # func argument style:
50 | hotkeys.add 'w', ->
51 | expect(hotkeys.get('w').description).toBe '$$undefined$$'
52 |
53 | # object style:
54 | hotkeys.add
55 | combo: 'e'
56 | callback: ->
57 |
58 | expect(hotkeys.get('e').description).toBe '$$undefined$$'
59 |
60 | it 'del()', ->
61 | hotkeys.add 'w', ->
62 | expect(hotkeys.get('w').description).toBe '$$undefined$$'
63 | hotkeys.del 'w'
64 | expect(hotkeys.get('w')).toBe false
65 |
66 | it 'should toggle help when ? is pressed', ->
67 | expect(angular.element($rootElement).children().hasClass('in')).toBe false
68 | KeyEvent.simulate('?'.charCodeAt(0), 90)
69 | expect(angular.element($rootElement).children().hasClass('in')).toBe true
70 |
71 | it 'should bind esc when the cheatsheet is shown', ->
72 | expect(hotkeys.get('esc')).toBe false
73 | expect(angular.element($rootElement).children().hasClass('in')).toBe false
74 | KeyEvent.simulate('?'.charCodeAt(0), 90)
75 | expect(angular.element($rootElement).children().hasClass('in')).toBe true
76 | expect(hotkeys.get('esc').combo).toEqual ['esc']
77 | KeyEvent.simulate('?'.charCodeAt(0), 90)
78 | expect(hotkeys.get('esc')).toBe false
79 |
80 | it 'should remember previously bound ESC when cheatsheet is shown', ->
81 | expect(hotkeys.get('esc')).toBe false
82 |
83 | # bind something to escape:
84 | hotkeys.add 'esc', 'temp', () ->
85 | expect(hotkeys.get('esc').description).toBe 'temp'
86 | originalCallback = hotkeys.get('esc').callback
87 |
88 | # show the cheat-sheet which will overwrite the esc key. however, we want to
89 | # show the original combo description in the callback, yet have the new
90 | # callback bound to remove the cheatsheet from view.
91 | KeyEvent.simulate('?'.charCodeAt(0), 90)
92 | expect(hotkeys.get('esc').description).toBe 'temp'
93 | expect(hotkeys.get('esc').callback).not.toBe originalCallback
94 |
95 | # hide the cheat sheet to verify the previous esc binding is back
96 | KeyEvent.simulate('?'.charCodeAt(0), 90)
97 | expect(hotkeys.get('esc').description).toBe 'temp'
98 |
99 | it 'should (un)bind based on route changes', ->
100 | # fake a route change:
101 | expect(hotkeys.get('w e s')).toBe false
102 | $rootScope.$broadcast('$routeChangeSuccess', { hotkeys: [['w e s', 'Do something Amazing!', 'callme("ishmael")']] });
103 | expect(hotkeys.get('w e s').combo).toEqual ['w e s']
104 |
105 | # ensure hotkey is unbound when the route changes
106 | $rootScope.$broadcast('$routeChangeSuccess', {});
107 | expect(hotkeys.get('w e s')).toBe false
108 |
109 | it 'should callback when the hotkey is pressed', ->
110 | executed = false
111 |
112 | hotkeys.add 'w', ->
113 | executed = true
114 |
115 | KeyEvent.simulate('w'.charCodeAt(0), 90)
116 | expect(executed).toBe true
117 |
118 | it 'should callback according to action', ->
119 | keypressA = false;
120 | keypressB = false;
121 |
122 | hotkeys.add 'a', ->
123 | keypressA = true
124 | , 'keyup'
125 |
126 | hotkeys.add 'b', ->
127 | keypressB = true
128 |
129 | KeyEvent.simulate('a'.charCodeAt(0), 90)
130 | KeyEvent.simulate('b'.charCodeAt(0), 90)
131 | expect(keypressA).toBe false
132 | expect(keypressB).toBe true
133 | expect(hotkeys.get('a').action).toBe 'keyup'
134 |
135 | it 'should allow to invoke hotkey.callback programmatically without event object', ->
136 | called = false;
137 |
138 | hotkeys.add 'a', ->
139 | called = true
140 | , 'keyup'
141 |
142 | hotkeys.get('a').callback()
143 | expect(called).toBe true
144 |
145 |
146 | it 'should run routes-defined hotkey callbacks when scope is available', ->
147 | executed = false
148 | passedArg = null
149 |
150 | $rootScope.callme = (arg) ->
151 | executed = true
152 | passedArg = arg
153 |
154 | $rootScope.$broadcast '$routeChangeSuccess',
155 | hotkeys: [['w', 'Do something Amazing!', 'callme("ishmael")']]
156 | scope: $rootScope
157 |
158 | expect(executed).toBe false
159 | KeyEvent.simulate('w'.charCodeAt(0), 90)
160 | expect(executed).toBe true
161 | expect(passedArg).toBe 'ishmael'
162 |
163 | it 'should callback when hotkey is pressed in input field and allowIn INPUT is configured', ->
164 | executed = no
165 |
166 | $body = angular.element document.body
167 | $input = angular.element ''
168 | $body.prepend $input
169 |
170 | hotkeys.add
171 | combo: 'w'
172 | allowIn: ['INPUT']
173 | callback: -> executed = yes
174 |
175 | KeyEvent.simulate('w'.charCodeAt(0), 90, undefined, $input[0])
176 | expect(executed).toBe yes
177 |
178 | it 'should callback when hotkey is pressed in select field and allowIn SELECT is configured', ->
179 | executed = no
180 |
181 | $body = angular.element document.body
182 | $select = angular.element ''
183 | $body.prepend $select
184 |
185 | hotkeys.add
186 | combo: 'w'
187 | allowIn: ['SELECT']
188 | callback: -> executed = yes
189 |
190 | KeyEvent.simulate('w'.charCodeAt(0), 90, undefined, $select[0])
191 | expect(executed).toBe yes
192 |
193 | it 'should callback when hotkey is pressed in textarea field and allowIn TEXTAREA is configured', ->
194 | executed = no
195 |
196 | $body = angular.element document.body
197 | $textarea = angular.element ''
198 | $body.prepend $textarea
199 |
200 | hotkeys.add
201 | combo: 'w'
202 | allowIn: ['TEXTAREA']
203 | callback: -> executed = yes
204 |
205 | KeyEvent.simulate('w'.charCodeAt(0), 90, undefined, $textarea[0])
206 | expect(executed).toBe yes
207 |
208 | it 'should not callback when hotkey is pressed in input field without allowIn INPUT', ->
209 | executed = no
210 |
211 | $body = angular.element document.body
212 | $input = angular.element ''
213 | $body.prepend $input
214 |
215 | hotkeys.add
216 | combo: 'w'
217 | callback: -> executed = yes
218 |
219 | KeyEvent.simulate('w'.charCodeAt(0), 90, undefined, $input[0])
220 | expect(executed).toBe no
221 |
222 | it 'should not callback when hotkey is pressed in select field without allowIn SELECT', ->
223 | executed = no
224 |
225 | $body = angular.element document.body
226 | $select = angular.element ''
227 | $body.prepend $select
228 |
229 | hotkeys.add
230 | combo: 'w'
231 | callback: -> executed = yes
232 |
233 | KeyEvent.simulate('w'.charCodeAt(0), 90, undefined, $select[0])
234 | expect(executed).toBe no
235 |
236 | it 'should not callback when hotkey is pressed in textarea field without allowIn TEXTAREA', ->
237 | executed = no
238 |
239 | $body = angular.element document.body
240 | $textarea = angular.element ''
241 | $body.prepend $textarea
242 |
243 | hotkeys.add
244 | combo: 'w'
245 | callback: -> executed = yes
246 |
247 | KeyEvent.simulate('w'.charCodeAt(0), 90, undefined, $textarea[0])
248 | expect(executed).toBe no
249 |
250 | it 'should callback when the mousetrap class is present', ->
251 | executed = no
252 |
253 | $body = angular.element document.body
254 | $input = angular.element ''
255 | $body.prepend $input
256 |
257 | hotkeys.add
258 | combo: 'a'
259 | callback: -> executed = yes
260 |
261 | KeyEvent.simulate('a'.charCodeAt(0), 90, undefined, $input[0])
262 | expect(executed).toBe yes
263 |
264 | it 'should be capable of binding to a scope and auto-destroy itself', ->
265 | hotkeys.bindTo(scope)
266 | .add
267 | combo: ['w', 'e', 's']
268 | description: 'description for w'
269 | callback: () ->
270 | persistent: false
271 | .add
272 | combo: 'a'
273 | action: 'keyup'
274 | description: 'description for a',
275 | callback: () ->
276 | .add('b', 'description for b', () ->)
277 | .add('c', 'description for c', () ->)
278 |
279 |
280 | expect(hotkeys.get('w').combo).toEqual ['w', 'e', 's']
281 | expect(hotkeys.get('e').combo).toEqual ['w', 'e', 's']
282 | expect(hotkeys.get('a').combo).toEqual ['a']
283 | expect(hotkeys.get('b').combo).toEqual ['b']
284 | expect(hotkeys.get('c').combo).toEqual ['c']
285 |
286 | scope.$destroy()
287 | expect(hotkeys.get('w')).toBe false
288 | expect(hotkeys.get('e')).toBe false
289 | expect(hotkeys.get('s')).toBe false
290 | expect(hotkeys.get('a')).toBe false
291 | expect(hotkeys.get('b')).toBe false
292 | expect(hotkeys.get('c')).toBe false
293 |
294 | it 'should allow multiple calls to bindTo for same scope and still be auto-destroying', ->
295 | hotkeys.bindTo(scope)
296 | .add
297 | combo: ['w', 'e', 's']
298 | description: 'description for w'
299 | callback: () ->
300 | persistent: false
301 |
302 | hotkeys.bindTo(scope)
303 | .add
304 | combo: 'a'
305 | action: 'keyup'
306 | description: 'description for a',
307 | callback: () ->
308 | .add('b', 'description for b', () ->)
309 | .add('c', 'description for c', () ->)
310 |
311 |
312 | expect(hotkeys.get('w').combo).toEqual ['w', 'e', 's']
313 | expect(hotkeys.get('e').combo).toEqual ['w', 'e', 's']
314 | expect(hotkeys.get('a').combo).toEqual ['a']
315 | expect(hotkeys.get('b').combo).toEqual ['b']
316 | expect(hotkeys.get('c').combo).toEqual ['c']
317 |
318 | scope.$destroy()
319 | expect(hotkeys.get('w')).toBe false
320 | expect(hotkeys.get('e')).toBe false
321 | expect(hotkeys.get('s')).toBe false
322 | expect(hotkeys.get('a')).toBe false
323 | expect(hotkeys.get('b')).toBe false
324 | expect(hotkeys.get('c')).toBe false
325 |
326 | it 'should support pause/unpause for temporary disabling of hotkeys', ->
327 | executed = false
328 |
329 | hotkeys.add 'w', ->
330 | executed = true
331 |
332 | hotkeys.pause()
333 | KeyEvent.simulate('w'.charCodeAt(0), 90)
334 | expect(executed).toBe false
335 | hotkeys.unpause()
336 | KeyEvent.simulate('w'.charCodeAt(0), 90)
337 | expect(executed).toBe true
338 |
339 |
340 | describe 'misc regression tests', ->
341 |
342 | # allowIn arguments were not aligned (issue #40 and #36) so test to prevent regressions:
343 | it 'should unbind hotkeys that have also set allowIn (#36, #40)', ->
344 | # func argument style should be deprecated soon
345 | hotkeys.add 't', 'testing', () ->
346 | test = true
347 | , undefined, undefined, false
348 |
349 | hotkeys.add
350 | combo: 'w'
351 | description: 'description'
352 | callback: () ->
353 | persistent: false
354 |
355 | expect(hotkeys.get('t').combo).toEqual ['t']
356 | expect(hotkeys.get('w').combo).toEqual ['w']
357 | expect(hotkeys.get('t').persistent).toBe false
358 | expect(hotkeys.get('w').persistent).toBe false
359 |
360 | $rootScope.$broadcast('$routeChangeSuccess', {});
361 | expect(hotkeys.get('t')).toBe false
362 | expect(hotkeys.get('w')).toBe false
363 |
364 | it '#42 closing cheatsheet with x should use toggleCheatSheet so esc is unbound', ->
365 | expect(hotkeys.get('esc')).toBe false
366 |
367 | expect(angular.element($rootElement).children().hasClass('in')).toBe false
368 | KeyEvent.simulate('?'.charCodeAt(0), 90)
369 | expect(angular.element($rootElement).children().hasClass('in')).toBe true
370 |
371 | expect(hotkeys.get('esc').combo).toEqual ['esc']
372 | # hotkeys.toggleCheatSheet()
373 | scope.$$prevSibling.toggleCheatSheet()
374 | expect(hotkeys.get('esc')).toBe false
375 |
376 |
377 | describe 'multiple bindings', ->
378 |
379 | it 'get()', ->
380 |
381 | hotkeys.add ['a', 'b', 'c'], ->
382 |
383 | # Make sure they were added:
384 | expect(hotkeys.get('a').combo).toEqual ['a', 'b', 'c']
385 | expect(hotkeys.get('b').combo).toEqual ['a', 'b', 'c']
386 | expect(hotkeys.get('c').combo).toEqual ['a', 'b', 'c']
387 | expect(hotkeys.get('w')).toBe false
388 | # expect(hotkeys.get(['a', 'b'])).toEqual ['a', 'b', 'c']
389 |
390 | it 'should callback', ->
391 | executeCount = 0
392 |
393 | hotkeys.add ['a', 'b', 'c'], ->
394 | executeCount++
395 |
396 | # Make sure they work:
397 | KeyEvent.simulate('a'.charCodeAt(0), 90)
398 | expect(executeCount).toBe 1
399 | KeyEvent.simulate('b'.charCodeAt(0), 90)
400 | expect(executeCount).toBe 2
401 | KeyEvent.simulate('c'.charCodeAt(0), 90)
402 | expect(executeCount).toBe 3
403 | KeyEvent.simulate('w'.charCodeAt(0), 90)
404 | expect(executeCount).toBe 3
405 |
406 | it 'should delete', ->
407 |
408 | hotkeys.add ['w', 'e', 's'], ->
409 |
410 | expect(hotkeys.get('w').combo).toEqual ['w', 'e', 's']
411 | expect(hotkeys.del(['w', 'f'])).toBe false
412 | expect(hotkeys.get('w')).toBe false
413 | expect(hotkeys.get('e').combo).toEqual ['e', 's']
414 | expect(hotkeys.del(['e', 's'])).toBe true
415 |
416 | it 'should still callback when some combos remain', ->
417 |
418 | executeCount = 0
419 | hotkeys.add ['a', 'b', 'c'], ->
420 | executeCount++
421 |
422 | # Delete, but leave a hotkey:
423 | hotkeys.del ['a', 'b']
424 | KeyEvent.simulate('b'.charCodeAt(0), 90)
425 | expect(executeCount).toBe 0
426 | KeyEvent.simulate('c'.charCodeAt(0), 90)
427 | expect(executeCount).toBe 1
428 |
429 | expect(hotkeys.get('a')).toBe false
430 | expect(hotkeys.get('b')).toBe false
431 | expect(hotkeys.get('c').combo).toEqual ['c']
432 |
433 | it '#49 regression test', ->
434 | hotkeys.add
435 | combo: ['1','2','3','4','5','6','7','8','9']
436 | description: 'ensure no regressions'
437 | callback: () ->
438 |
439 | expect(hotkeys.get('1').combo).toEqual ['1','2','3','4','5','6','7','8','9']
440 | hotkeys.del(['1','2','3','4','5','6','7','8','9'])
441 | expect(hotkeys.get('1')).toBe false
442 |
443 |
444 |
445 | describe 'hotkey directive', ->
446 |
447 | elSimple = elAllowIn = elMultiple = scope = hotkeys = $compile = $document = executedSimple = executedAllowIn = null
448 |
449 | beforeEach ->
450 | module('cfp.hotkeys')
451 | executedSimple = no
452 | executedAllowIn = no
453 |
454 | inject ($rootScope, _$compile_, _$document_, _hotkeys_) ->
455 | hotkeys = _hotkeys_
456 | $compile = _$compile_
457 | # el = angular.element()
458 | scope = $rootScope.$new()
459 | scope.callmeSimple = () ->
460 | executedSimple = yes
461 | scope.callmeAllowIn = () ->
462 | executedAllowIn = yes
463 | scope.callmeMultiple = () ->
464 | elSimple = $compile('')(scope)
465 | elAllowIn = $compile('')(scope)
466 | elMultiple = $compile('')(scope)
467 | scope.$digest()
468 |
469 | it 'should allow hotkey binding via directive', ->
470 | expect(hotkeys.get('e').combo).toEqual ['e']
471 | expect(hotkeys.get('w').combo).toEqual ['w']
472 | expect(executedSimple).toBe no
473 | expect(executedAllowIn).toBe no
474 | KeyEvent.simulate('e'.charCodeAt(0), 90)
475 | KeyEvent.simulate('w'.charCodeAt(0), 90)
476 | expect(executedSimple).toBe yes
477 | expect(executedAllowIn).toBe yes
478 |
479 | it 'should accept allowIn arguments', ->
480 |
481 | $body = angular.element document.body
482 | $input = angular.element ''
483 | $body.prepend $input
484 |
485 | expect(executedAllowIn).toBe no
486 | KeyEvent.simulate('w'.charCodeAt(0), 90)
487 | expect(executedAllowIn).toBe yes
488 | expect(hotkeys.get('w').allowIn).toEqual ['INPUT', 'TEXTAREA']
489 |
490 | it 'should unbind the hotkey when the directive is destroyed', ->
491 | expect(hotkeys.get('e').combo).toEqual ['e']
492 | expect(hotkeys.get('w').combo).toEqual ['w']
493 | expect(hotkeys.get('a').combo).toEqual ['a']
494 | expect(hotkeys.get('b').combo).toEqual ['b']
495 | elSimple.remove()
496 | elAllowIn.remove()
497 | elMultiple.remove()
498 | expect(hotkeys.get('e')).toBe no
499 | expect(hotkeys.get('w')).toBe no
500 | expect(hotkeys.get('a')).toBe no
501 | expect(hotkeys.get('b')).toBe no
502 |
503 |
504 | describe 'Platform specific things', ->
505 | beforeEach ->
506 | windowMock =
507 | navigator:
508 | platform: 'Macintosh'
509 |
510 | module 'cfp.hotkeys'
511 |
512 | it 'should display mac key combos', ->
513 | module ($provide) ->
514 | $provide.value '$window', angular.extend window,
515 | navigator:
516 | platform: 'Macintosh'
517 | return
518 |
519 | inject (hotkeys) ->
520 | hotkeys.add 'mod+e', 'description'
521 | expect(hotkeys.get('mod+e').format()[0]).toBe '⌘ + e'
522 |
523 | it 'should display win/linux key combos', ->
524 | module ($provide) ->
525 | $provide.value '$window', angular.extend window,
526 | navigator:
527 | platform: 'Linux x86_64'
528 | return
529 |
530 | inject (hotkeys) ->
531 | hotkeys.add 'mod+e', 'description'
532 | expect(hotkeys.get('mod+e').format()[0]).toBe 'ctrl + e'
533 |
534 |
535 | describe 'Configuration options', ->
536 |
537 | it 'should disable the cheatsheet when configured', ->
538 | module 'cfp.hotkeys', (hotkeysProvider) ->
539 | hotkeysProvider.includeCheatSheet = false
540 | return
541 | inject ($rootElement, hotkeys) ->
542 | children = angular.element($rootElement).children()
543 | expect(children.length).toBe 0
544 |
545 | it 'should enable the cheatsheet when configured', ->
546 | module 'cfp.hotkeys', (hotkeysProvider) ->
547 | hotkeysProvider.includeCheatSheet = true
548 | return
549 | inject ($rootElement, hotkeys) ->
550 | children = angular.element($rootElement).children()
551 | expect(children.length).toBe 1
552 |
553 | it 'should accept an alternate template to inject', ->
554 | module 'cfp.hotkeys', (hotkeysProvider) ->
555 | hotkeysProvider.template = '
boo
'
556 | return
557 | inject ($rootElement, hotkeys) ->
558 | children = angular.element($rootElement).children()
559 | expect(children.hasClass('little-teapot')).toBe true
560 |
561 | it 'should run and inject itself so it is always available', ->
562 | module 'cfp.hotkeys'
563 |
564 | inject ($rootElement) ->
565 | children = angular.element($rootElement).children()
566 | expect(children.hasClass('cfp-hotkeys-container')).toBe true
567 |
568 | it 'should attach to body if $rootElement is document (#8)', inject ($rootElement) ->
569 |
570 | injected = angular.element(document.body).find('div')
571 | expect(injected.length).toBe 0
572 |
573 | injector = angular.bootstrap(document, ['cfp.hotkeys'])
574 | injected = angular.element(document.body).find('div')
575 | expect(injected.length).toBe 3
576 | expect(injected.hasClass('cfp-hotkeys-container')).toBe true
577 |
578 | it 'should have a configurable hotkey and description', ->
579 | module 'cfp.hotkeys', (hotkeysProvider) ->
580 | hotkeysProvider.cheatSheetHotkey = 'h'
581 | hotkeysProvider.cheatSheetDescription = 'Alternate description'
582 | return
583 |
584 | inject ($rootElement, hotkeys) ->
585 | expect(hotkeys.get('h')).not.toBe false
586 | expect(angular.element($rootElement).children().hasClass('in')).toBe false
587 | KeyEvent.simulate('?'.charCodeAt(0), 90)
588 | expect(angular.element($rootElement).children().hasClass('in')).toBe false
589 | KeyEvent.simulate('h'.charCodeAt(0), 90)
590 | expect(angular.element($rootElement).children().hasClass('in')).toBe true
591 |
592 | expect(hotkeys.get('h').description).toBe 'Alternate description'
593 |
594 | it 'should have a configurable useNgRoute defaulted to false if ngRoute is not loaded', ->
595 | module 'cfp.hotkeys'
596 | inject (hotkeys) ->
597 | expect(hotkeys.useNgRoute).toBe false
598 |
599 | it 'should have a configurable useNgRoute defaulted to true if ngRoute is loaded', ->
600 | module 'ngRoute'
601 | module 'cfp.hotkeys'
602 | inject (hotkeys) ->
603 | expect(hotkeys.useNgRoute).toBe true
604 |
--------------------------------------------------------------------------------
/src/hotkeys.js:
--------------------------------------------------------------------------------
1 | /*
2 | * angular-hotkeys
3 | *
4 | * Automatic keyboard shortcuts for your angular apps
5 | *
6 | * (c) 2016 Wes Cruver
7 | * License: MIT
8 | */
9 |
10 | (function() {
11 |
12 | 'use strict';
13 |
14 | angular.module('cfp.hotkeys', []).provider('hotkeys', function($injector) {
15 |
16 | /**
17 | * Configurable setting to disable the cheatsheet entirely
18 | * @type {Boolean}
19 | */
20 | this.includeCheatSheet = true;
21 |
22 | /**
23 | * Configurable setting to disable ngRoute hooks
24 | * @type {Boolean}
25 | */
26 | this.useNgRoute = $injector.has('ngViewDirective');
27 |
28 | /**
29 | * Configurable setting for the cheat sheet title
30 | * @type {String}
31 | */
32 |
33 | this.templateTitle = 'Keyboard Shortcuts:';
34 |
35 | /**
36 | * Configurable settings for the cheat sheet header and footer. Both are HTML, and the header
37 | * overrides the normal title if specified.
38 | * @type {String}
39 | */
40 | this.templateHeader = null;
41 | this.templateFooter = null;
42 |
43 | /**
44 | * Cheat sheet template in the event you want to totally customize it.
45 | * @type {String}
46 | */
47 | this.template = '
' +
48 | '
{{ title }}
' +
49 | '' +
50 | '
' +
51 | '
' +
52 | '
' +
53 | '{{ key }}' +
54 | '
' +
55 | '
{{ hotkey.description }}
' +
56 | '
' +
57 | '
' +
58 | '' +
59 | '
×
' +
60 | '
';
61 |
62 | /**
63 | * Configurable setting for the cheat sheet hotkey
64 | * @type {String}
65 | */
66 | this.cheatSheetHotkey = '?';
67 |
68 | /**
69 | * Configurable setting for the cheat sheet description
70 | * @type {String}
71 | */
72 | this.cheatSheetDescription = 'Show / hide this help menu';
73 |
74 | this.$get = function ($rootElement, $rootScope, $compile, $window, $document) {
75 |
76 | var mouseTrapEnabled = true;
77 |
78 | function pause() {
79 | mouseTrapEnabled = false;
80 | }
81 |
82 | function unpause() {
83 | mouseTrapEnabled = true;
84 | }
85 |
86 | // monkeypatch Mousetrap's stopCallback() function
87 | // this version doesn't return true when the element is an INPUT, SELECT, or TEXTAREA
88 | // (instead we will perform this check per-key in the _add() method)
89 | Mousetrap.prototype.stopCallback = function(event, element) {
90 | if (!mouseTrapEnabled) {
91 | return true;
92 | }
93 |
94 | // if the element has the class "mousetrap" then no need to stop
95 | if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) {
96 | return false;
97 | }
98 |
99 | return (element.contentEditable && element.contentEditable == 'true');
100 | };
101 |
102 | /**
103 | * Convert strings like cmd into symbols like ⌘
104 | * @param {String} combo Key combination, e.g. 'mod+f'
105 | * @return {String} The key combination with symbols
106 | */
107 | function symbolize (combo) {
108 | var map = {
109 | command : '\u2318', // ⌘
110 | shift : '\u21E7', // ⇧
111 | left : '\u2190', // ←
112 | right : '\u2192', // →
113 | up : '\u2191', // ↑
114 | down : '\u2193', // ↓
115 | 'return' : '\u23CE', // ⏎
116 | backspace : '\u232B' // ⌫
117 | };
118 | combo = combo.split('+');
119 |
120 | for (var i = 0; i < combo.length; i++) {
121 | // try to resolve command / ctrl based on OS:
122 | if (combo[i] === 'mod') {
123 | if ($window.navigator && $window.navigator.platform.indexOf('Mac') >=0 ) {
124 | combo[i] = 'command';
125 | } else {
126 | combo[i] = 'ctrl';
127 | }
128 | }
129 |
130 | combo[i] = map[combo[i]] || combo[i];
131 | }
132 |
133 | return combo.join(' + ');
134 | }
135 |
136 | /**
137 | * Hotkey object used internally for consistency
138 | *
139 | * @param {array} combo The keycombo. it's an array to support multiple combos
140 | * @param {String} description Description for the keycombo
141 | * @param {Function} callback function to execute when keycombo pressed
142 | * @param {string} action the type of event to listen for (for mousetrap)
143 | * @param {array} allowIn an array of tag names to allow this combo in ('INPUT', 'SELECT', and/or 'TEXTAREA')
144 | * @param {Boolean} persistent Whether the hotkey persists navigation events
145 | */
146 | function Hotkey (combo, description, callback, action, allowIn, persistent) {
147 | // TODO: Check that the values are sane because we could
148 | // be trying to instantiate a new Hotkey with outside dev's
149 | // supplied values
150 |
151 | this.combo = combo instanceof Array ? combo : [combo];
152 | this.description = description;
153 | this.callback = callback;
154 | this.action = action;
155 | this.allowIn = allowIn;
156 | this.persistent = persistent;
157 | this._formated = null;
158 | }
159 |
160 | /**
161 | * Helper method to format (symbolize) the key combo for display
162 | *
163 | * @return {[Array]} An array of the key combination sequence
164 | * for example: "command+g c i" becomes ["⌘ + g", "c", "i"]
165 | *
166 | */
167 | Hotkey.prototype.format = function() {
168 | if (this._formated === null) {
169 | // Don't show all the possible key combos, just the first one. Not sure
170 | // of usecase here, so open a ticket if my assumptions are wrong
171 | var combo = this.combo[0];
172 |
173 | var sequence = combo.split(/[\s]/);
174 | for (var i = 0; i < sequence.length; i++) {
175 | sequence[i] = symbolize(sequence[i]);
176 | }
177 | this._formated = sequence;
178 | }
179 |
180 | return this._formated;
181 | };
182 |
183 | /**
184 | * A new scope used internally for the cheatsheet
185 | * @type {$rootScope.Scope}
186 | */
187 | var scope = $rootScope.$new();
188 |
189 | /**
190 | * Holds an array of Hotkey objects currently bound
191 | * @type {Array}
192 | */
193 | scope.hotkeys = [];
194 |
195 | /**
196 | * Contains the state of the help's visibility
197 | * @type {Boolean}
198 | */
199 | scope.helpVisible = false;
200 |
201 | /**
202 | * Holds the title string for the help menu
203 | * @type {String}
204 | */
205 | scope.title = this.templateTitle;
206 |
207 | /**
208 | * Holds the header HTML for the help menu
209 | * @type {String}
210 | */
211 | scope.header = this.templateHeader;
212 |
213 | /**
214 | * Holds the footer HTML for the help menu
215 | * @type {String}
216 | */
217 | scope.footer = this.templateFooter;
218 |
219 | /**
220 | * Expose toggleCheatSheet to hotkeys scope so we can call it using
221 | * ng-click from the template
222 | * @type {function}
223 | */
224 | scope.toggleCheatSheet = toggleCheatSheet;
225 |
226 |
227 | /**
228 | * Holds references to the different scopes that have bound hotkeys
229 | * attached. This is useful to catch when the scopes are `$destroy`d and
230 | * then automatically unbind the hotkey.
231 | *
232 | * @type {Object}
233 | */
234 | var boundScopes = {};
235 |
236 | if (this.useNgRoute) {
237 | $rootScope.$on('$routeChangeSuccess', function (event, route) {
238 | purgeHotkeys();
239 |
240 | if (route && route.hotkeys) {
241 | angular.forEach(route.hotkeys, function (hotkey) {
242 | // a string was given, which implies this is a function that is to be
243 | // $eval()'d within that controller's scope
244 | // TODO: hotkey here is super confusing. sometimes a function (that gets turned into an array), sometimes a string
245 | var callback = hotkey[2];
246 | if (typeof(callback) === 'string' || callback instanceof String) {
247 | hotkey[2] = [callback, route];
248 | }
249 |
250 | // todo: perform check to make sure not already defined:
251 | // this came from a route, so it's likely not meant to be persistent
252 | hotkey[5] = false;
253 | _add.apply(this, hotkey);
254 | });
255 | }
256 | });
257 | }
258 |
259 |
260 |
261 | // Auto-create a help menu:
262 | if (this.includeCheatSheet) {
263 | var document = $document[0];
264 | var element = $rootElement[0];
265 | var helpMenu = angular.element(this.template);
266 | _add(this.cheatSheetHotkey, this.cheatSheetDescription, toggleCheatSheet);
267 |
268 | // If $rootElement is document or documentElement, then body must be used
269 | if (element === document || element === document.documentElement) {
270 | element = document.body;
271 | }
272 |
273 | angular.element(element).append($compile(helpMenu)(scope));
274 | }
275 |
276 |
277 | /**
278 | * Purges all non-persistent hotkeys (such as those defined in routes)
279 | *
280 | * Without this, the same hotkey would get recreated everytime
281 | * the route is accessed.
282 | */
283 | function purgeHotkeys() {
284 | var i = scope.hotkeys.length;
285 | while (i--) {
286 | var hotkey = scope.hotkeys[i];
287 | if (hotkey && !hotkey.persistent) {
288 | _del(hotkey);
289 | }
290 | }
291 | }
292 |
293 | /**
294 | * Toggles the help menu element's visiblity
295 | */
296 | var previousEsc = false;
297 |
298 | function toggleCheatSheet() {
299 | scope.helpVisible = !scope.helpVisible;
300 |
301 | // Bind to esc to remove the cheat sheet. Ideally, this would be done
302 | // as a directive in the template, but that would create a nasty
303 | // circular dependency issue that I don't feel like sorting out.
304 | if (scope.helpVisible) {
305 | previousEsc = _get('esc');
306 | _del('esc');
307 |
308 | // Here's an odd way to do this: we're going to use the original
309 | // description of the hotkey on the cheat sheet so that it shows up.
310 | // without it, no entry for esc will ever show up (#22)
311 | _add('esc', previousEsc.description, toggleCheatSheet, null, ['INPUT', 'SELECT', 'TEXTAREA']);
312 | } else {
313 | _del('esc');
314 |
315 | // restore the previously bound ESC key
316 | if (previousEsc !== false) {
317 | _add(previousEsc);
318 | }
319 | }
320 | }
321 |
322 | /**
323 | * Creates a new Hotkey and creates the Mousetrap binding
324 | *
325 | * @param {string} combo mousetrap key binding
326 | * @param {string} description description for the help menu
327 | * @param {Function} callback method to call when key is pressed
328 | * @param {string} action the type of event to listen for (for mousetrap)
329 | * @param {array} allowIn an array of tag names to allow this combo in ('INPUT', 'SELECT', and/or 'TEXTAREA')
330 | * @param {boolean} persistent if true, the binding is preserved upon route changes
331 | */
332 | function _add (combo, description, callback, action, allowIn, persistent) {
333 |
334 | // used to save original callback for "allowIn" wrapping:
335 | var _callback;
336 |
337 | // these elements are prevented by the default Mousetrap.stopCallback():
338 | var preventIn = ['INPUT', 'SELECT', 'TEXTAREA'];
339 |
340 | // Determine if object format was given:
341 | var objType = Object.prototype.toString.call(combo);
342 |
343 | if (objType === '[object Object]') {
344 | description = combo.description;
345 | callback = combo.callback;
346 | action = combo.action;
347 | persistent = combo.persistent;
348 | allowIn = combo.allowIn;
349 | combo = combo.combo;
350 | }
351 |
352 | // no duplicates please
353 | _del(combo);
354 |
355 | // description is optional:
356 | if (description instanceof Function) {
357 | action = callback;
358 | callback = description;
359 | description = '$$undefined$$';
360 | } else if (angular.isUndefined(description)) {
361 | description = '$$undefined$$';
362 | }
363 |
364 | // any items added through the public API are for controllers
365 | // that persist through navigation, and thus undefined should mean
366 | // true in this case.
367 | if (persistent === undefined) {
368 | persistent = true;
369 | }
370 | // if callback is defined, then wrap it in a function
371 | // that checks if the event originated from a form element.
372 | // the function blocks the callback from executing unless the element is specified
373 | // in allowIn (emulates Mousetrap.stopCallback() on a per-key level)
374 | if (typeof callback === 'function') {
375 |
376 | // save the original callback
377 | _callback = callback;
378 |
379 | // make sure allowIn is an array
380 | if (!(allowIn instanceof Array)) {
381 | allowIn = [];
382 | }
383 |
384 | // remove anything from preventIn that's present in allowIn
385 | var index;
386 | for (var i=0; i < allowIn.length; i++) {
387 | allowIn[i] = allowIn[i].toUpperCase();
388 | index = preventIn.indexOf(allowIn[i]);
389 | if (index !== -1) {
390 | preventIn.splice(index, 1);
391 | }
392 | }
393 |
394 | // create the new wrapper callback
395 | callback = function(event) {
396 | var shouldExecute = true;
397 |
398 | // if the callback is executed directly `hotkey.get('w').callback()`
399 | // there will be no event, so just execute the callback.
400 | if (event) {
401 | var target = event.target || event.srcElement; // srcElement is IE only
402 | var nodeName = target.nodeName.toUpperCase();
403 |
404 | // check if the input has a mousetrap class, and skip checking preventIn if so
405 | if ((' ' + target.className + ' ').indexOf(' mousetrap ') > -1) {
406 | shouldExecute = true;
407 | } else {
408 | // don't execute callback if the event was fired from inside an element listed in preventIn
409 | for (var i=0; i -1) {
457 | // if the combo has other combos bound, don't unbind the whole thing, just the one combo:
458 | if (scope.hotkeys[index].combo.length > 1) {
459 | scope.hotkeys[index].combo.splice(scope.hotkeys[index].combo.indexOf(combo), 1);
460 | } else {
461 |
462 | // remove hotkey from bound scopes
463 | angular.forEach(boundScopes, function (boundScope) {
464 | var scopeIndex = boundScope.indexOf(scope.hotkeys[index]);
465 | if (scopeIndex !== -1) {
466 | boundScope.splice(scopeIndex, 1);
467 | }
468 | });
469 |
470 | scope.hotkeys.splice(index, 1);
471 | }
472 | return true;
473 | }
474 | }
475 |
476 | return false;
477 |
478 | }
479 |
480 | /**
481 | * Get a Hotkey object by key binding
482 | *
483 | * @param {[string]} [combo] the key the Hotkey is bound to. Returns all key bindings if no key is passed
484 | * @return {Hotkey} The Hotkey object
485 | */
486 | function _get (combo) {
487 |
488 | if (!combo) {
489 | return scope.hotkeys;
490 | }
491 |
492 | var hotkey;
493 |
494 | for (var i = 0; i < scope.hotkeys.length; i++) {
495 | hotkey = scope.hotkeys[i];
496 |
497 | if (hotkey.combo.indexOf(combo) > -1) {
498 | return hotkey;
499 | }
500 | }
501 |
502 | return false;
503 | }
504 |
505 | /**
506 | * Binds the hotkey to a particular scope. Useful if the scope is
507 | * destroyed, we can automatically destroy the hotkey binding.
508 | *
509 | * @param {Object} scope The scope to bind to
510 | */
511 | function bindTo (scope) {
512 | // Only initialize once to allow multiple calls for same scope.
513 | if (!(scope.$id in boundScopes)) {
514 |
515 | // Add the scope to the list of bound scopes
516 | boundScopes[scope.$id] = [];
517 |
518 | scope.$on('$destroy', function () {
519 | var i = boundScopes[scope.$id].length;
520 | while (i--) {
521 | _del(boundScopes[scope.$id].pop());
522 | }
523 | });
524 | }
525 | // return an object with an add function so we can keep track of the
526 | // hotkeys and their scope that we added via this chaining method
527 | return {
528 | add: function (args) {
529 | var hotkey;
530 |
531 | if (arguments.length > 1) {
532 | hotkey = _add.apply(this, arguments);
533 | } else {
534 | hotkey = _add(args);
535 | }
536 |
537 | boundScopes[scope.$id].push(hotkey);
538 | return this;
539 | }
540 | };
541 | }
542 |
543 | /**
544 | * All callbacks sent to Mousetrap are wrapped using this function
545 | * so that we can force a $scope.$apply()
546 | *
547 | * @param {Function} callback [description]
548 | * @return {[type]} [description]
549 | */
550 | function wrapApply (callback) {
551 | // return mousetrap a function to call
552 | return function (event, combo) {
553 |
554 | // if this is an array, it means we provided a route object
555 | // because the scope wasn't available yet, so rewrap the callback
556 | // now that the scope is available:
557 | if (callback instanceof Array) {
558 | var funcString = callback[0];
559 | var route = callback[1];
560 | callback = function (event) {
561 | route.scope.$eval(funcString);
562 | };
563 | }
564 |
565 | // this takes place outside angular, so we'll have to call
566 | // $apply() to make sure angular's digest happens
567 | $rootScope.$apply(function() {
568 | // call the original hotkey callback with the keyboard event
569 | callback(event, _get(combo));
570 | });
571 | };
572 | }
573 |
574 | var publicApi = {
575 | add : _add,
576 | del : _del,
577 | get : _get,
578 | bindTo : bindTo,
579 | template : this.template,
580 | toggleCheatSheet : toggleCheatSheet,
581 | includeCheatSheet : this.includeCheatSheet,
582 | cheatSheetHotkey : this.cheatSheetHotkey,
583 | cheatSheetDescription : this.cheatSheetDescription,
584 | useNgRoute : this.useNgRoute,
585 | purgeHotkeys : purgeHotkeys,
586 | templateTitle : this.templateTitle,
587 | pause : pause,
588 | unpause : unpause
589 | };
590 |
591 | return publicApi;
592 |
593 | };
594 |
595 |
596 | })
597 |
598 | .directive('hotkey', function (hotkeys) {
599 | return {
600 | restrict: 'A',
601 | link: function (scope, el, attrs) {
602 | var keys = [],
603 | allowIn;
604 |
605 | angular.forEach(scope.$eval(attrs.hotkey), function (func, hotkey) {
606 | // split and trim the hotkeys string into array
607 | allowIn = typeof attrs.hotkeyAllowIn === "string" ? attrs.hotkeyAllowIn.split(/[\s,]+/) : [];
608 |
609 | keys.push(hotkey);
610 |
611 | hotkeys.add({
612 | combo: hotkey,
613 | description: attrs.hotkeyDescription,
614 | callback: func,
615 | action: attrs.hotkeyAction,
616 | allowIn: allowIn
617 | });
618 | });
619 |
620 | // remove the hotkey if the directive is destroyed:
621 | el.bind('$destroy', function() {
622 | angular.forEach(keys, hotkeys.del);
623 | });
624 | }
625 | };
626 | })
627 |
628 | .run(function(hotkeys) {
629 | // force hotkeys to run by injecting it. Without this, hotkeys only runs
630 | // when a controller or something else asks for it via DI.
631 | });
632 |
633 | })();
634 |
--------------------------------------------------------------------------------