From 5100c2bbd2e27ea7954448fd85f248202b2d455c Mon Sep 17 00:00:00 2001 From: Ryan Sullivan Date: Fri, 20 Sep 2013 16:33:49 -0700 Subject: [PATCH] Add Global Keyboard Shortcuts Not all of these have been fully implemented yet. **Jump To** * `g` then `h` - Home (Latest) * `g` then `l` - Latest * `g` then `n` - New * `g` then `u` - Unread * `g` then `f` - Favorited * `g` then `c` - Categories List **Navigation** * `u` - Back to topic list * `k` / `j` - Newer/Older conversation or post * `o` or `Enter` - Open selected conversation * ` - Go to next section * `~` - Go to previous section **Application** * `c` - Create a new topic * `n` - Open notifications menu * `/` - Search * `?` - Open keyboard shortcut help **Actions** * `f` - Favorite topic * `s` - Share topic * `` + `s` - Share selected post * `r` - Reply to topic * `` + `r` - Reply to selected post * `l` - Like selected post * `!` - Flag selected post * `b` - Bookmark selected post * `e` - Edit selected post * `d` - Delete selected post * `m` then `m` - Mark topic as muted * `m` then `r` - Mark topic as regular * `m` then `t` - Mark topic as tracking * `m` then `w` - Mark topic as watching --- .../keyboard_shortcuts_component.js | 145 ++++++++++++++++ .../keyboard_shortcuts_help_controller.js | 12 ++ .../initializers/keyboard_shortcuts.js | 8 + .../discourse/routes/application_route.js | 4 + .../discourse/templates/header.js.handlebars | 5 +- .../discourse/templates/list.js.handlebars | 2 +- .../keyboard_shortcuts_help.js.handlebars | 50 ++++++ .../templates/site_map.js.handlebars | 3 +- ...keyboard_shortcuts_help_link.js.handlebars | 1 + .../views/buttons/favorite_button.js | 1 + .../views/buttons/notifications_button.js | 1 + .../discourse/views/buttons/share_button.js | 1 + .../modal/keyboard_shortcuts_help_view.js | 12 ++ .../components/keyboard_shortcuts.css.scss | 33 ++++ config/locales/client.en.yml | 41 ++++- .../keyboard_shortcuts_component_test.js | 157 ++++++++++++++++++ 16 files changed, 471 insertions(+), 5 deletions(-) create mode 100644 app/assets/javascripts/discourse/components/keyboard_shortcuts_component.js create mode 100644 app/assets/javascripts/discourse/controllers/keyboard_shortcuts_help_controller.js create mode 100644 app/assets/javascripts/discourse/initializers/keyboard_shortcuts.js create mode 100644 app/assets/javascripts/discourse/templates/modal/keyboard_shortcuts_help.js.handlebars create mode 100644 app/assets/javascripts/discourse/templates/site_map/_keyboard_shortcuts_help_link.js.handlebars create mode 100644 app/assets/javascripts/discourse/views/modal/keyboard_shortcuts_help_view.js create mode 100644 app/assets/stylesheets/common/components/keyboard_shortcuts.css.scss create mode 100644 test/javascripts/components/keyboard_shortcuts_component_test.js 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 @@ + 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(); +});