diff --git a/app/assets/javascripts/discourse/components/keyboard_shortcuts_component.js b/app/assets/javascripts/discourse/components/keyboard_shortcuts_component.js
new file mode 100644
index 00000000000..c1db3945c1a
--- /dev/null
+++ b/app/assets/javascripts/discourse/components/keyboard_shortcuts_component.js
@@ -0,0 +1,145 @@
+/**
+ Keyboard Shortcut related functions.
+
+ @class KeyboardShortcuts
+ @namespace Discourse
+ @module Discourse
+**/
+Discourse.KeyboardShortcuts = Ember.Object.createWithMixins({
+ PATH_BINDINGS: {
+ 'g h': '/',
+ 'g l': '/latest',
+ 'g n': '/new',
+ 'g u': '/unread',
+ 'g f': '/favorited',
+ 'g c': '/categories'
+ },
+
+ CLICK_BINDINGS: {
+ 'b': 'article.selected button.bookmark', // bookmark current post
+ 'c': '#create-topic', // create new topic
+ 'd': 'article.selected button.delete', // delete selected post
+ 'e': 'article.selected button.edit', // edit selected post
+
+ // favorite topic
+ 'f': '#topic-footer-buttons button.favorite, #topic-list tr.topic-list-item.selected a.star',
+
+ 'l': 'article.selected button.like', // like selected post
+ 'm m': 'div.notification-options li[data-id="0"] a', // mark topic as muted
+ 'm r': 'div.notification-options li[data-id="1"] a', // mark topic as regular
+ 'm t': 'div.notification-options li[data-id="2"] a', // mark topic as tracking
+ 'm w': 'div.notification-options li[data-id="3"] a', // mark topic as watching
+ 'n': '#user-notifications', // open notifictions menu
+ 'o,enter': '#topic-list tr.topic-list-item.selected a.title', // open selected topic
+ 'r': '#topic-footer-buttons button.create', // reply to topic
+ 'R': 'article.selected button.create', // reply to selected post
+ 's': '#topic-footer-buttons button.share', // share topic
+ 'S': 'article.selected button.share', // share selected post
+ '/': '#search-button', // focus search
+ '!': 'article.selected button.flag', // flag selected post
+ '?': '#keyboard-help' // open keyboard shortcut help
+ },
+
+ FUNCTION_BINDINGS: {
+ 'j': 'selectDown',
+ 'k': 'selectUp',
+ 'u': 'goBack',
+ '`': 'nextSection',
+ '~': 'prevSection'
+ },
+
+ bindEvents: function(keyTrapper) {
+ this.keyTrapper = keyTrapper;
+ _.each(this.PATH_BINDINGS, this._bindToPath, this);
+ _.each(this.CLICK_BINDINGS, this._bindToClick, this);
+ _.each(this.FUNCTION_BINDINGS, this._bindToFunction, this);
+ },
+
+ selectDown: function() {
+ this._moveSelection(1);
+ },
+
+ selectUp: function() {
+ this._moveSelection(-1);
+ },
+
+ goBack: function() {
+ history.back();
+ },
+
+ nextSection: function() {
+ this._changeSection(1);
+ },
+
+ prevSection: function() {
+ this._changeSection(-1);
+ },
+
+ _bindToPath: function(path, binding) {
+ this.keyTrapper.bind(binding, function() {
+ Discourse.URL.routeTo(path);
+ });
+ },
+
+ _bindToClick: function(selector, binding) {
+ binding = binding.split(',');
+ this.keyTrapper.bind(binding, function(e) {
+ if (!_.isUndefined(e) && _.isFunction(e.preventDefault)) {
+ e.preventDefault();
+ }
+
+ $(selector).click();
+ });
+ },
+
+ _bindToFunction: function(func, binding) {
+ if (typeof this[func] === 'function') {
+ this.keyTrapper.bind(binding, _.bind(this[func], this));
+ }
+ },
+
+ _moveSelection: function(num) {
+ var $articles = this._findArticles();
+
+ if (typeof $articles === 'undefined') {
+ return;
+ }
+
+ var $selected = $articles.filter('.selected'),
+ index = $articles.index($selected),
+ $article = $articles.eq(index + num);
+
+ if ($article.size() > 0) {
+ $articles.removeClass('selected');
+ $article.addClass('selected');
+ this._scrollList($article);
+ }
+ },
+
+ _scrollList: function($article) {
+ var $body = $('body'),
+ distToElement = $article.position().top + $article.height() - $(window).height() - $body.scrollTop();
+
+ $('html, body').scrollTop($body.scrollTop() + distToElement);
+ },
+
+ _findArticles: function() {
+ var $topicList = $('#topic-list'),
+ $topicArea = $('.posts-wrapper');
+
+ if ($topicArea.size() > 0) {
+ return $topicArea.find('.topic-post');
+ }
+ else if ($topicList.size() > 0) {
+ return $topicList.find('.topic-list-item');
+ }
+ },
+
+ _changeSection: function(num) {
+ var $sections = $('#category-filter').find('li'),
+ $active = $sections.filter('.active'),
+ index = $sections.index('.active');
+
+ $sections.eq(index + num).find('a').click();
+ }
+});
diff --git a/app/assets/javascripts/discourse/controllers/keyboard_shortcuts_help_controller.js b/app/assets/javascripts/discourse/controllers/keyboard_shortcuts_help_controller.js
new file mode 100644
index 00000000000..c3c15b0924a
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/keyboard_shortcuts_help_controller.js
@@ -0,0 +1,12 @@
+/**
+ This controller is used to display the Keyboard Shortcuts Help Modal
+
+ @class KeyboardShortcutsHelpController
+ @extends Discourse.Controller
+ @namespace Discourse
+ @uses Discourse.ModalFunctionality
+ @module Discourse
+**/
+Discourse.KeyboardShortcutsHelpController = Discourse.Controller.extend(Discourse.ModalFunctionality, {
+ needs: ['modal']
+});
diff --git a/app/assets/javascripts/discourse/initializers/keyboard_shortcuts.js b/app/assets/javascripts/discourse/initializers/keyboard_shortcuts.js
new file mode 100644
index 00000000000..3193ed98c43
--- /dev/null
+++ b/app/assets/javascripts/discourse/initializers/keyboard_shortcuts.js
@@ -0,0 +1,8 @@
+/*global Mousetrap:true*/
+
+/**
+ Initialize Global Keyboard Shortcuts
+**/
+Discourse.addInitializer(function(){
+ Discourse.KeyboardShortcuts.bindEvents(Mousetrap);
+})
diff --git a/app/assets/javascripts/discourse/routes/application_route.js b/app/assets/javascripts/discourse/routes/application_route.js
index d7af47b2f0d..2783dfcef14 100644
--- a/app/assets/javascripts/discourse/routes/application_route.js
+++ b/app/assets/javascripts/discourse/routes/application_route.js
@@ -31,6 +31,10 @@ Discourse.ApplicationRoute = Em.Route.extend({
this.controllerFor('uploadSelector').setProperties({ composerView: composerView });
},
+ showKeyboardShortcutsHelp: function() {
+ Discourse.Route.showModal(this, 'keyboardShortcutsHelp');
+ },
+
/**
Close the current modal, and destroy its state.
diff --git a/app/assets/javascripts/discourse/templates/header.js.handlebars b/app/assets/javascripts/discourse/templates/header.js.handlebars
index c4fc90e2517..6a8f2de4665 100644
--- a/app/assets/javascripts/discourse/templates/header.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/header.js.handlebars
@@ -52,11 +52,12 @@
{{#if Discourse.loginRequired}}
-
+
{{else}}
-
diff --git a/app/assets/javascripts/discourse/templates/list.js.handlebars b/app/assets/javascripts/discourse/templates/list.js.handlebars
index 76d48bf3612..c4f0a428665 100644
--- a/app/assets/javascripts/discourse/templates/list.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/list.js.handlebars
@@ -10,7 +10,7 @@
{{#if canCreateTopic}}
-
+
{{/if}}
{{#if canEditCategory}}
diff --git a/app/assets/javascripts/discourse/templates/modal/keyboard_shortcuts_help.js.handlebars b/app/assets/javascripts/discourse/templates/modal/keyboard_shortcuts_help.js.handlebars
new file mode 100644
index 00000000000..9955d207bd2
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/modal/keyboard_shortcuts_help.js.handlebars
@@ -0,0 +1,50 @@
+
+
+
+
{{i18n keyboard_shortcuts_help.jump_to.title}}
+
+ - {{{i18n keyboard_shortcuts_help.jump_to.home}}}
+ - {{{i18n keyboard_shortcuts_help.jump_to.latest}}}
+ - {{{i18n keyboard_shortcuts_help.jump_to.new}}}
+ - {{{i18n keyboard_shortcuts_help.jump_to.unread}}}
+ - {{{i18n keyboard_shortcuts_help.jump_to.favorited}}}
+ - {{{i18n keyboard_shortcuts_help.jump_to.categories}}}
+
+
+
{{i18n keyboard_shortcuts_help.navigation.title}}
+
+ - {{{i18n keyboard_shortcuts_help.navigation.back}}}
+ - {{{i18n keyboard_shortcuts_help.navigation.up_down}}}
+ - {{{i18n keyboard_shortcuts_help.navigation.open}}}
+ - {{{i18n keyboard_shortcuts_help.navigation.next_prev}}}
+
+
+
{{i18n keyboard_shortcuts_help.application.title}}
+
+ - {{{i18n keyboard_shortcuts_help.application.create}}}
+ - {{{i18n keyboard_shortcuts_help.application.notifications}}}
+ - {{{i18n keyboard_shortcuts_help.application.search}}}
+ - {{{i18n keyboard_shortcuts_help.application.help}}}
+
+
+
+
{{i18n keyboard_shortcuts_help.actions.title}}
+
+ - {{{i18n keyboard_shortcuts_help.actions.favorite}}}
+ - {{{i18n keyboard_shortcuts_help.actions.share_topic}}}
+ - {{{i18n keyboard_shortcuts_help.actions.share_post}}}
+ - {{{i18n keyboard_shortcuts_help.actions.reply_topic}}}
+ - {{{i18n keyboard_shortcuts_help.actions.reply_post}}}
+ - {{{i18n keyboard_shortcuts_help.actions.like}}}
+ - {{{i18n keyboard_shortcuts_help.actions.flag}}}
+ - {{{i18n keyboard_shortcuts_help.actions.bookmark}}}
+ - {{{i18n keyboard_shortcuts_help.actions.edit}}}
+ - {{{i18n keyboard_shortcuts_help.actions.delete}}}
+ - {{{i18n keyboard_shortcuts_help.actions.mark_muted}}}
+ - {{{i18n keyboard_shortcuts_help.actions.mark_regular}}}
+ - {{{i18n keyboard_shortcuts_help.actions.mark_tracking}}}
+ - {{{i18n keyboard_shortcuts_help.actions.mark_watching}}}
+
+
+
+
diff --git a/app/assets/javascripts/discourse/templates/site_map.js.handlebars b/app/assets/javascripts/discourse/templates/site_map.js.handlebars
index 04cf99eab05..ea18375be5d 100644
--- a/app/assets/javascripts/discourse/templates/site_map.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/site_map.js.handlebars
@@ -6,6 +6,7 @@
{{/if}}
{{partial "siteMap/latestTopicsLink"}}
{{partial "siteMap/faqLink"}}
+ {{partial "siteMap/keyboardShortcutsHelpLink"}}
{{#if showMobileToggle}}
{{partial "siteMap/mobileToggleLink"}}
{{/if}}
@@ -22,4 +23,4 @@
{{/each}}
{{/if}}
-
\ No newline at end of file
+
diff --git a/app/assets/javascripts/discourse/templates/site_map/_keyboard_shortcuts_help_link.js.handlebars b/app/assets/javascripts/discourse/templates/site_map/_keyboard_shortcuts_help_link.js.handlebars
new file mode 100644
index 00000000000..21a41681549
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/site_map/_keyboard_shortcuts_help_link.js.handlebars
@@ -0,0 +1 @@
+{{i18n keyboard_shortcuts_help.title}}
diff --git a/app/assets/javascripts/discourse/views/buttons/favorite_button.js b/app/assets/javascripts/discourse/views/buttons/favorite_button.js
index f3fcbcbacb1..c1af9066610 100644
--- a/app/assets/javascripts/discourse/views/buttons/favorite_button.js
+++ b/app/assets/javascripts/discourse/views/buttons/favorite_button.js
@@ -7,6 +7,7 @@
@module Discourse
**/
Discourse.FavoriteButton = Discourse.ButtonView.extend({
+ classNames: ['favorite'],
textKey: 'favorite.title',
helpKeyBinding: 'controller.favoriteTooltipKey',
attributeBindings: ['disabled'],
diff --git a/app/assets/javascripts/discourse/views/buttons/notifications_button.js b/app/assets/javascripts/discourse/views/buttons/notifications_button.js
index 4ba48a1b9a1..00ef949c836 100644
--- a/app/assets/javascripts/discourse/views/buttons/notifications_button.js
+++ b/app/assets/javascripts/discourse/views/buttons/notifications_button.js
@@ -7,6 +7,7 @@
@module Discourse
**/
Discourse.NotificationsButton = Discourse.DropdownButtonView.extend({
+ classNames: ['notification-options'],
title: I18n.t('topic.notifications.title'),
longDescriptionBinding: 'topic.details.notificationReasonText',
topic: Em.computed.alias('controller.model'),
diff --git a/app/assets/javascripts/discourse/views/buttons/share_button.js b/app/assets/javascripts/discourse/views/buttons/share_button.js
index 7e419e8ae31..42c3ed3869b 100644
--- a/app/assets/javascripts/discourse/views/buttons/share_button.js
+++ b/app/assets/javascripts/discourse/views/buttons/share_button.js
@@ -7,6 +7,7 @@
@module Discourse
**/
Discourse.ShareButton = Discourse.ButtonView.extend({
+ classNames: ['share'],
textKey: 'topic.share.title',
helpKey: 'topic.share.help',
'data-share-url': Em.computed.alias('topic.shareUrl'),
diff --git a/app/assets/javascripts/discourse/views/modal/keyboard_shortcuts_help_view.js b/app/assets/javascripts/discourse/views/modal/keyboard_shortcuts_help_view.js
new file mode 100644
index 00000000000..aa851325610
--- /dev/null
+++ b/app/assets/javascripts/discourse/views/modal/keyboard_shortcuts_help_view.js
@@ -0,0 +1,12 @@
+/**
+ A modal view for displaying Keyboard Shortcut Help
+
+ @class KeyboardShortcutsHelpView
+ @extends Discourse.ModalBodyView
+ @namespace Discourse
+ @module Discourse
+**/
+Discourse.KeyboardShortcutsHelpView = Discourse.ModalBodyView.extend({
+ templateName: 'modal/keyboard_shortcuts_help',
+ title: I18n.t('keyboard_shortcuts_help.title')
+});
diff --git a/app/assets/stylesheets/common/components/keyboard_shortcuts.css.scss b/app/assets/stylesheets/common/components/keyboard_shortcuts.css.scss
new file mode 100644
index 00000000000..7a49b080727
--- /dev/null
+++ b/app/assets/stylesheets/common/components/keyboard_shortcuts.css.scss
@@ -0,0 +1,33 @@
+@import "../foundation/variables";
+
+.topic-list-item td:first-child, .topic-post {
+ background-color: inherit;
+ border-left: 1px solid transparent;
+}
+
+.topic-list-item.selected td:first-child, .topic-post.selected {
+ border-left: 1px solid $topic-list-starred-color;
+}
+
+.topic-list-item.selected {
+ background-color: inherit;
+}
+
+#keyboard-shortcuts-help {
+ ul {
+ list-style: none;
+ padding-left: 0;
+
+ li {
+ margin: 5px 0px
+ }
+
+ b {
+ background-color: $nav-stacked-divider-color;
+ color: $nav-stacked-color;
+ display: inline-block;
+ margin: 0px 2px;
+ padding: 2px 4px;
+ }
+ }
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index f50d4b0b30a..35ff7922603 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -244,7 +244,7 @@ en:
change_username:
title: "Change Username"
- confirm: "If you change your username, all prior quotes of your posts and @name mentions will be broken. Are you absolutely sure you want to?"
+ confirm: "I you change your username, all prior quotes of your posts and @name mentions will be broken. Are you absolutely sure you want to?"
taken: "Sorry, that username is taken."
error: "There was an error changing your username."
invalid: "That username is invalid. It must only include numbers and letters"
@@ -1468,3 +1468,42 @@ en:
lightbox:
download: "download"
+
+ keyboard_shortcuts_help:
+ title: 'Keyboard Shortcuts'
+ jump_to:
+ title: 'Jump To'
+ home: 'g then h Home (Latest)'
+ latest: 'g then l Latest'
+ new: 'g then n New'
+ unread: 'g then u Unread'
+ favorited: 'g then f Favorited'
+ categories: 'g then c Categories'
+ navigation:
+ title: 'Navigation'
+ back: 'u Back'
+ up_down: 'k/j Move selection up/down'
+ open: 'o or Enter Open selected topic'
+ next_prev: '`/~ Next/previous section'
+ application:
+ title: 'Application'
+ create: 'c Create a new topic'
+ notifications: 'n Open notifications'
+ search: '/ Search'
+ help: '? Open keyboard shortcuts help'
+ actions:
+ title: 'Actions'
+ favorite: 'f Favorite topic'
+ share_topic: 's Share topic'
+ share_post: 'shift s Share post'
+ reply_topic: 'r Reply to topic'
+ reply_post: 'shift r Reply to post'
+ like: 'l Like post'
+ flag: '! Flag post'
+ bookmark: 'b Bookmark post'
+ edit: 'e Edit post'
+ delete: 'd Delete post'
+ mark_muted: 'm then m Mark topic as muted'
+ mark_regular: 'm then r Mark topic as regular'
+ mark_tracking: 'm then t Mark topic as tracking'
+ mark_watching: 'm then w Mark topic as watching'
diff --git a/test/javascripts/components/keyboard_shortcuts_component_test.js b/test/javascripts/components/keyboard_shortcuts_component_test.js
new file mode 100644
index 00000000000..49ffab555a7
--- /dev/null
+++ b/test/javascripts/components/keyboard_shortcuts_component_test.js
@@ -0,0 +1,157 @@
+module("Discourse.KeyboardShortcuts", {
+ setup: function() {
+ this.testMouseTrap = {
+ bindings: {},
+
+ bind: function(bindings, callback) {
+ var registerBinding = _.bind(function(binding) {
+ this.bindings[binding] = callback;
+ }, this);
+
+ if (_.isArray(bindings)) {
+ _.each(bindings, registerBinding, this);
+ }
+ else {
+ registerBinding(bindings);
+ }
+ },
+
+ trigger: function(binding) {
+ this.bindings[binding].call();
+ }
+ };
+
+ sinon.stub(Discourse.URL, "routeTo");
+
+ $("#qunit-fixture").html([
+ "",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ "",
+ "",
+ "
",
+ " ",
+ " ",
+ " ",
+ " ",
+ "
",
+ "
",
+ "",
+ " ",
+ " ",
+ " |
",
+ "
",
+ "",
+ "",
+ "",
+ "",
+ ""
+ ].join("\n"));
+ },
+
+ teardown: function() {
+ $("#qunit-scratch").html("");
+
+ Discourse.URL.routeTo.restore();
+ }
+});
+
+var pathBindings = Discourse.KeyboardShortcuts.PATH_BINDINGS;
+
+_.each(pathBindings, function(path, binding) {
+ var testName = binding + " goes to " + path;
+
+ test(testName, function() {
+ Discourse.KeyboardShortcuts.bindEvents(this.testMouseTrap);
+ this.testMouseTrap.trigger(binding);
+
+ ok(Discourse.URL.routeTo.calledWith(path));
+ });
+});
+
+var clickBindings = Discourse.KeyboardShortcuts.CLICK_BINDINGS;
+
+_.each(clickBindings, function(selector, binding) {
+ var bindings = binding.split(",");
+
+ var testName = binding + " clicks on " + selector;
+
+ test(testName, bindings.length, function() {
+ Discourse.KeyboardShortcuts.bindEvents(this.testMouseTrap);
+ $(selector).on("click", function() {
+ ok(true, selector + " was clicked");
+ });
+
+ _.each(bindings, function(binding) {
+ this.testMouseTrap.trigger(binding);
+ }, this);
+ });
+});
+
+var functionBindings = Discourse.KeyboardShortcuts.FUNCTION_BINDINGS;
+
+_.each(functionBindings, function(func, binding) {
+ var testName = binding + " calls " + func;
+
+ test(testName, function() {
+ var stub = sinon.stub(Discourse.KeyboardShortcuts, func, function() {
+ ok(true, func + " is called when " + binding + " is triggered");
+ });
+ Discourse.KeyboardShortcuts.bindEvents(this.testMouseTrap);
+
+ this.testMouseTrap.trigger(binding);
+ stub.restore();
+ });
+});
+
+test("selectDown calls _moveSelection with 1", function() {
+ var spy = sinon.spy(Discourse.KeyboardShortcuts, '_moveSelection');
+
+ Discourse.KeyboardShortcuts.selectDown();
+ ok(spy.calledWith(1), "_moveSelection is called with 1");
+ spy.restore();
+});
+
+test("selectUp calls _moveSelection with -1", function() {
+ var spy = sinon.spy(Discourse.KeyboardShortcuts, '_moveSelection');
+
+ Discourse.KeyboardShortcuts.selectUp();
+ ok(spy.calledWith(-1), "_moveSelection is called with -1");
+ spy.restore();
+});
+
+test("goBack calls history.back", function() {
+ var called = false,
+ stub = sinon.stub(history, 'back', function() {
+ called = true;
+ });
+
+ Discourse.KeyboardShortcuts.goBack();
+ ok(called, "history.back is called");
+ stub.restore();
+});
+
+test("nextSection calls _changeSection with 1", function() {
+ var spy = sinon.spy(Discourse.KeyboardShortcuts, '_changeSection');
+
+ Discourse.KeyboardShortcuts.nextSection();
+ ok(spy.calledWith(1), "_changeSection is called with 1");
+ spy.restore();
+});
+
+test("prevSection calls _changeSection with -1", function() {
+ var spy = sinon.spy(Discourse.KeyboardShortcuts, '_changeSection');
+
+ Discourse.KeyboardShortcuts.prevSection();
+ ok(spy.calledWith(-1), "_changeSection is called with -1");
+ spy.restore();
+});