diff --git a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 index e3a4a3c6ed6..980e5671590 100644 --- a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 +++ b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 @@ -1,86 +1,92 @@ -import DiscourseURL from "discourse/lib/url"; -import Composer from "discourse/models/composer"; -import { minimumOffset } from "discourse/lib/offset-calculator"; +import DiscourseURL from 'discourse/lib/url'; +import Composer from 'discourse/models/composer'; +import { minimumOffset } from 'discourse/lib/offset-calculator'; const bindings = { - "!": { postAction: "showFlags" }, - "#": { handler: "goToPost", anonymous: true }, - "/": { handler: "toggleSearch", anonymous: true }, - "ctrl+alt+f": { handler: "toggleSearch", anonymous: true }, - "=": { handler: "toggleHamburgerMenu", anonymous: true }, - "?": { handler: "showHelpModal", anonymous: true }, - ".": { click: ".alert.alert-info.clickable", anonymous: true }, // show incoming/updated topics - b: { handler: "toggleBookmark" }, - c: { handler: "createTopic" }, - C: { handler: "focusComposer" }, - "ctrl+f": { handler: "showPageSearch", anonymous: true }, - "command+f": { handler: "showPageSearch", anonymous: true }, - "ctrl+p": { handler: "printTopic", anonymous: true }, - "command+p": { handler: "printTopic", anonymous: true }, - d: { postAction: "deletePost" }, - e: { postAction: "editPost" }, - end: { handler: "goToLastPost", anonymous: true }, - "command+down": { handler: "goToLastPost", anonymous: true }, - f: { handler: "toggleBookmarkTopic" }, - "g h": { path: "/", anonymous: true }, - "g l": { path: "/latest", anonymous: true }, - "g n": { path: "/new" }, - "g u": { path: "/unread" }, - "g c": { path: "/categories", anonymous: true }, - "g t": { path: "/top", anonymous: true }, - "g b": { path: "/bookmarks" }, - "g p": { path: "/my/activity" }, - "g m": { path: "/my/messages" }, - home: { handler: "goToFirstPost", anonymous: true }, - "command+up": { handler: "goToFirstPost", anonymous: true }, - j: { handler: "selectDown", anonymous: true }, - k: { handler: "selectUp", anonymous: true }, - l: { click: ".topic-post.selected button.toggle-like" }, - "m m": { handler: "setTrackingToMuted" }, // mark topic as muted - "m r": { handler: "setTrackingToRegular" }, // mark topic as regular - "m t": { handler: "setTrackingToTracking" }, // mark topic as tracking - "m w": { handler: "setTrackingToWatching" }, // mark topic as watching - "o,enter": { click: ".topic-list tr.selected a.title", anonymous: true }, // open selected topic - p: { handler: "showCurrentUser" }, - q: { handler: "quoteReply" }, - r: { postAction: "replyToPost" }, - s: { click: ".topic-post.selected a.post-date", anonymous: true }, // share post - "shift+j": { handler: "nextSection", anonymous: true }, - "shift+k": { handler: "prevSection", anonymous: true }, - "shift+p": { handler: "pinUnpinTopic" }, - "shift+r": { handler: "replyToTopic" }, - "shift+s": { click: "#topic-footer-buttons button.share", anonymous: true }, // share topic - "shift+u": { handler: "goToUnreadPost" }, - "shift+z shift+z": { handler: "logout" }, - t: { postAction: "replyAsNewTopic" }, - u: { handler: "goBack", anonymous: true }, - "x r": { - click: "#dismiss-new,#dismiss-new-top,#dismiss-posts,#dismiss-posts-top" - }, // dismiss new/posts - "x t": { click: "#dismiss-topics,#dismiss-topics-top" } // dismiss topics + '!': {postAction: 'showFlags'}, + '#': {handler: 'goToPost', anonymous: true}, + '/': {handler: 'toggleSearch', anonymous: true}, + 'ctrl+alt+f': {handler: 'toggleSearch', anonymous: true}, + '=': {handler: 'toggleHamburgerMenu', anonymous: true}, + '?': {handler: 'showHelpModal', anonymous: true}, + '.': {click: '.alert.alert-info.clickable', anonymous: true}, // show incoming/updated topics + 'b': {handler: 'toggleBookmark'}, + 'c': {handler: 'createTopic'}, + 'C': {handler: 'focusComposer'}, + 'ctrl+f': {handler: 'showPageSearch', anonymous: true}, + 'command+f': {handler: 'showPageSearch', anonymous: true}, + 'ctrl+p': {handler: 'printTopic', anonymous: true}, + 'command+p': {handler: 'printTopic', anonymous: true}, + 'd': {postAction: 'deletePost'}, + 'e': {postAction: 'editPost'}, + 'end': {handler: 'goToLastPost', anonymous: true}, + 'command+down': {handler: 'goToLastPost', anonymous: true}, + 'f': {handler: 'toggleBookmarkTopic'}, + 'g h': {path: '/', anonymous: true}, + 'g l': {path: '/latest', anonymous: true}, + 'g n': {path: '/new'}, + 'g u': {path: '/unread'}, + 'g c': {path: '/categories', anonymous: true}, + 'g t': {path: '/top', anonymous: true}, + 'g b': {path: '/bookmarks'}, + 'g p': {path: '/my/activity'}, + 'g m': {path: '/my/messages'}, + 'home': {handler: 'goToFirstPost', anonymous: true}, + 'command+up': {handler: 'goToFirstPost', anonymous: true}, + 'j': {handler: 'selectDown', anonymous: true}, + 'k': {handler: 'selectUp', anonymous: true}, + 'l': {click: '.topic-post.selected button.toggle-like'}, + 'm m': {handler: 'setTrackingToMuted'}, // mark topic as muted + 'm r': {handler: 'setTrackingToRegular'}, // mark topic as regular + 'm t': {handler: 'setTrackingToTracking'}, // mark topic as tracking + 'm w': {handler: 'setTrackingToWatching'}, // mark topic as watching + 'o,enter': {click: ['.topic-list tr.selected a.title', + '.category-list tr.selected .category-text-title', + '.subcategory-list span.selected a.badge-wrapper', + '.categories-topics-list .categories-topics-list-item.selected div.main-link a.title', + '.latest .featured-topic.selected a.title' + ].join(", "), anonymous: true}, // open selected topic, category, subcategory or featured topic + 'tab': {handler: 'switchFocusCategoriesPage', anonymous: true}, + 'p': {handler: 'showCurrentUser'}, + 'q': {handler: 'quoteReply'}, + 'r': {postAction: 'replyToPost'}, + 's': {click: '.topic-post.selected a.post-date', anonymous: true}, // share post + 'shift+j': {handler: 'nextSection', anonymous: true}, + 'shift+k': {handler: 'prevSection', anonymous: true}, + 'shift+p': {handler: 'pinUnpinTopic'}, + 'shift+r': {handler: 'replyToTopic'}, + 'shift+s': {click: '#topic-footer-buttons button.share', anonymous: true}, // share topic + 'shift+u': {handler: 'goToUnreadPost'}, + 'shift+z shift+z': {handler: 'logout'}, + 't': {postAction: 'replyAsNewTopic'}, + 'u': {handler: 'goBack', anonymous: true}, + 'x r': {click: '#dismiss-new,#dismiss-new-top,#dismiss-posts,#dismiss-posts-top'}, // dismiss new/posts + 'x t': {click: '#dismiss-topics,#dismiss-topics-top'} // dismiss topics }; +const FOCUS_ORDER = ["parent-cats", "sub-cats", "latest-topics", "none"]; +let CURRENT_FOCUS = FOCUS_ORDER[0]; +let $FOCUSED_PARENT; + export default { bindEvents(keyTrapper, container) { this.keyTrapper = keyTrapper; this.container = container; this._stopCallback(); - this.searchService = this.container.lookup("search-service:main"); - this.appEvents = this.container.lookup("app-events:main"); - this.currentUser = this.container.lookup("current-user:main"); - let siteSettings = this.container.lookup("site-settings:main"); + this.searchService = this.container.lookup('search-service:main'); + this.appEvents = this.container.lookup('app-events:main'); + this.currentUser = this.container.lookup('current-user:main'); + let siteSettings = this.container.lookup('site-settings:main'); // Disable the shortcut if private messages are disabled if (!siteSettings.enable_personal_messages) { - delete bindings["g m"]; + delete bindings['g m']; } Object.keys(bindings).forEach(key => { const binding = bindings[key]; - if (!binding.anonymous && !this.currentUser) { - return; - } + if (!binding.anonymous && !this.currentUser) { return; } if (binding.path) { this._bindToPath(binding.path, key); @@ -95,47 +101,47 @@ export default { }, toggleBookmark() { - this.sendToSelectedPost("toggleBookmark"); - this.sendToTopicListItemView("toggleBookmark"); + this.sendToSelectedPost('toggleBookmark'); + this.sendToTopicListItemView('toggleBookmark'); }, toggleBookmarkTopic() { const topic = this.currentTopic(); // BIG hack, need a cleaner way - if (topic && $(".posts-wrapper").length > 0) { - this.container.lookup("controller:topic").send("toggleBookmark"); + if (topic && $('.posts-wrapper').length > 0) { + this.container.lookup('controller:topic').send('toggleBookmark'); } else { - this.sendToTopicListItemView("toggleBookmark"); + this.sendToTopicListItemView('toggleBookmark'); } }, logout() { - this.container.lookup("route:application").send("logout"); + this.container.lookup('route:application').send('logout'); }, quoteReply() { this.sendToSelectedPost("replyToPost"); // lazy but should work for now setTimeout(function() { - $(".d-editor .quote").click(); + $('.d-editor .quote').click(); }, 500); }, goToFirstPost() { - this._jumpTo("jumpTop"); + this._jumpTo('jumpTop'); }, goToLastPost() { - this._jumpTo("jumpBottom"); + this._jumpTo('jumpBottom'); }, goToUnreadPost() { - this._jumpTo("jumpUnread"); + this._jumpTo('jumpUnread'); }, _jumpTo(direction) { - if ($(".container.posts").length) { - this.container.lookup("controller:topic").send(direction); + if ($('.container.posts').length) { + this.container.lookup('controller:topic').send(direction); } }, @@ -165,108 +171,78 @@ export default { showPageSearch(event) { Ember.run(() => { - this.appEvents.trigger("header:keyboard-trigger", { - type: "page-search", - event - }); + this.appEvents.trigger('header:keyboard-trigger', {type: 'page-search', event}); }); }, printTopic(event) { Ember.run(() => { - if ($(".container.posts").length) { + if ($('.container.posts').length) { event.preventDefault(); // We need to stop printing the current page in Firefox - this.container.lookup("controller:topic").print(); + this.container.lookup('controller:topic').print(); } }); }, createTopic() { if (this.currentUser && this.currentUser.can_create_topic) { - this.container.lookup("controller:composer").open({ - action: Composer.CREATE_TOPIC, - draftKey: Composer.CREATE_TOPIC - }); + this.container.lookup('controller:composer').open({action: Composer.CREATE_TOPIC, draftKey: Composer.CREATE_TOPIC}); } }, focusComposer() { - const composer = this.container.lookup("controller:composer"); - if (composer.get("model.viewOpen")) { - setTimeout(() => $("textarea.d-editor-input").focus(), 0); + const composer = this.container.lookup('controller:composer'); + if (composer.get('model.viewOpen')) { + setTimeout(() => $('textarea.d-editor-input').focus(), 0); } else { - composer.send("openIfDraft"); + composer.send('openIfDraft'); } }, pinUnpinTopic() { - this.container.lookup("controller:topic").togglePinnedState(); + this.container.lookup('controller:topic').togglePinnedState(); }, goToPost() { - this.appEvents.trigger("topic:keyboard-trigger", { type: "jump" }); + this.appEvents.trigger('topic:keyboard-trigger', { type: 'jump' }); }, toggleSearch(event) { - this.appEvents.trigger("header:keyboard-trigger", { - type: "search", - event - }); + this.appEvents.trigger('header:keyboard-trigger', {type: 'search', event}); }, toggleHamburgerMenu(event) { - this.appEvents.trigger("header:keyboard-trigger", { - type: "hamburger", - event - }); + this.appEvents.trigger('header:keyboard-trigger', {type: 'hamburger', event}); }, showCurrentUser(event) { - this.appEvents.trigger("header:keyboard-trigger", { type: "user", event }); + this.appEvents.trigger('header:keyboard-trigger', {type: 'user', event}); }, showHelpModal() { - this.container - .lookup("controller:application") - .send("showKeyboardShortcutsHelp"); + this.container.lookup('controller:application').send('showKeyboardShortcutsHelp'); }, setTrackingToMuted(event) { - this.appEvents.trigger("topic-notifications-button:changed", { - type: "notification", - id: 0, - event - }); + this.appEvents.trigger('topic-notifications-button:changed', {type: 'notification', id: 0, event}); }, setTrackingToRegular(event) { - this.appEvents.trigger("topic-notifications-button:changed", { - type: "notification", - id: 1, - event - }); + this.appEvents.trigger('topic-notifications-button:changed', {type: 'notification', id: 1, event}); }, setTrackingToTracking(event) { - this.appEvents.trigger("topic-notifications-button:changed", { - type: "notification", - id: 2, - event - }); + this.appEvents.trigger('topic-notifications-button:changed', {type: 'notification', id: 2, event}); }, setTrackingToWatching(event) { - this.appEvents.trigger("topic-notifications-button:changed", { - type: "notification", - id: 3, - event - }); + this.appEvents.trigger('topic-notifications-button:changed', {type: 'notification', id: 3, event}); }, sendToTopicListItemView(action) { - const elem = $("tr.selected.topic-list-item.ember-view")[0]; + const elem = $('tr.selected.topic-list-item.ember-view')[0]; if (elem) { - const registry = this.container.lookup("-view-registry:main"); + const registry = this.container.lookup('-view-registry:main'); if (registry) { const view = registry[elem.id]; view.send(action); @@ -275,9 +251,9 @@ export default { }, currentTopic() { - const topicController = this.container.lookup("controller:topic"); + const topicController = this.container.lookup('controller:topic'); if (topicController) { - const topic = topicController.get("model"); + const topic = topicController.get('model'); if (topic) { return topic; } @@ -287,26 +263,21 @@ export default { sendToSelectedPost(action) { const container = this.container; // TODO: We should keep track of the post without a CSS class - const selectedPostId = parseInt( - $(".topic-post.selected article.boxed").data("post-id"), - 10 - ); + const selectedPostId = parseInt($('.topic-post.selected article.boxed').data('post-id'), 10); if (selectedPostId) { - const topicController = container.lookup("controller:topic"); - const post = topicController - .get("model.postStream.posts") - .findBy("id", selectedPostId); + const topicController = container.lookup('controller:topic'); + const post = topicController.get('model.postStream.posts').findBy('id', selectedPostId); if (post) { // TODO: Use ember closure actions let actionMethod = topicController._actions[action]; if (!actionMethod) { - const topicRoute = container.lookup("route:topic"); + const topicRoute = container.lookup('route:topic'); actionMethod = topicRoute._actions[action]; } const result = actionMethod.call(topicController, post); if (result && result.then) { - this.appEvents.trigger("post-stream:refresh", { id: selectedPostId }); + this.appEvents.trigger('post-stream:refresh', { id: selectedPostId }); } } } @@ -317,13 +288,11 @@ export default { }, _bindToPath(path, key) { - this.keyTrapper.bind(key, () => - DiscourseURL.routeTo(Discourse.BaseUri + path) - ); + this.keyTrapper.bind(key, () => DiscourseURL.routeTo(Discourse.BaseUri + path)); }, _bindToClick(selector, binding) { - binding = binding.split(","); + binding = binding.split(','); this.keyTrapper.bind(binding, function(e) { const $sel = $(selector); @@ -343,7 +312,7 @@ export default { }, _bindToFunction(func, binding) { - if (typeof this[func] === "function") { + if (typeof this[func] === 'function') { this.keyTrapper.bind(binding, _.bind(this[func], this)); } }, @@ -351,38 +320,35 @@ export default { _moveSelection(direction) { const $articles = this._findArticles(); - if (typeof $articles === "undefined") return; + if (typeof $articles === 'undefined') return; - const $selected = - $articles.filter(".selected").length !== 0 - ? $articles.filter(".selected") - : $articles.filter("[data-islastviewedtopic=true]"); + const $selected = ($articles.filter('.selected').length !== 0) + ? $articles.filter('.selected') + : $articles.filter('[data-islastviewedtopic=true]'); let index = $articles.index($selected); if ($selected.length !== 0) { if (direction === -1 && index === 0) return; - if (direction === 1 && index === $articles.length - 1) return; + if (direction === 1 && index === $articles.length - 1) return; } // when nothing is selected if ($selected.length === 0) { // select the first post with its top visible const offset = minimumOffset(); - index = $articles - .toArray() - .findIndex(article => article.getBoundingClientRect().top > offset); + index = $articles.toArray().findIndex(article => article.getBoundingClientRect().top > offset); direction = 0; } const $article = $articles.eq(index + direction); if ($article.length > 0) { - $articles.removeClass("selected"); - $article.addClass("selected"); + $articles.removeClass('selected'); + $article.addClass('selected'); - if ($article.is(".topic-post")) { - $("a.tabLoc", $article).focus(); + if ($article.is('.topic-post')) { + $('a.tabLoc', $article).focus(); this._scrollToPost($article); } else { this._scrollList($article, direction); @@ -402,72 +368,143 @@ export default { // Try to keep the article on screen const pos = $article.offset(); const height = $article.height(); - const headerHeight = $("header.d-header").height(); + const headerHeight = $('header.d-header').height(); const scrollTop = $(window).scrollTop(); const windowHeight = $(window).height(); // skip if completely on screen - if ( - pos.top - headerHeight > scrollTop && - pos.top + height < scrollTop + windowHeight - ) { + if ((pos.top - headerHeight) > scrollTop && (pos.top + height) < (scrollTop + windowHeight)) { return; } - let scrollPos = pos.top + height / 2 - windowHeight * 0.5; - if (height > windowHeight - headerHeight) { - scrollPos = pos.top - headerHeight; - } - if (scrollPos < 0) { - scrollPos = 0; - } + let scrollPos = (pos.top + (height/2)) - (windowHeight * 0.5); + if (height > (windowHeight - headerHeight)) { scrollPos = (pos.top - headerHeight); } + if (scrollPos < 0) { scrollPos = 0; } if (this._scrollAnimation) { this._scrollAnimation.stop(); } - this._scrollAnimation = $("html, body").animate( - { scrollTop: scrollPos + "px" }, - 100 - ); + this._scrollAnimation = $("html, body").animate({ scrollTop: scrollPos + "px"}, 100); + }, + + setCurrentFocus(newFocus) { + CURRENT_FOCUS = newFocus; + }, + + shouldHijackTab() { + return $FOCUSED_PARENT || [ + this.parentCategoriesSelection(), + this.topicsSelection() + ].some(list => list.is(".selected")); + }, + + switchFocusCategoriesPage(e) { + if (!this.shouldHijackTab()) { return; } + e.preventDefault(); + + const prevFocusedParent = $FOCUSED_PARENT; + const prevFocus = CURRENT_FOCUS; + const nextFocus = this.findNextFocus(CURRENT_FOCUS); + + if (CURRENT_FOCUS === "sub-cats") { + $FOCUSED_PARENT = undefined; + } + + this.setCurrentFocus(nextFocus); + + this.codesMapToItemLists(CURRENT_FOCUS).first().addClass("selected"); + this.codesMapToItemLists(prevFocus, prevFocusedParent).removeClass("selected"); + }, + + codesMapToItemLists(code, prevParent) { + const map = { + "parent-cats": this.parentCategoriesSelection(), + "sub-cats": this.subcategoriesSelection(prevParent), + "latest-topics": this.topicsSelection(), + "none": $() + }; + return map[code]; + }, + + findNextFocus(current) { + let index = FOCUS_ORDER.indexOf(current) + 1; + + if (current === "parent-cats") { + const $selectedCat = this.parentCategoriesSelection().filter(".selected"); + if (this.subcategoriesSelection($selectedCat).length === 0) { + ++index; + } else { + $FOCUSED_PARENT = $selectedCat; + } + } + + if (this.topicsSelection().length === 0 && FOCUS_ORDER[index] === "latest-topics") { ++index; } + + return FOCUS_ORDER[index] || _.first(FOCUS_ORDER); + }, + + parentCategoriesSelection() { + return $(".category-list .category-list-item"); + }, + + subcategoriesSelection($parent) { + $parent = $parent || $FOCUSED_PARENT; + return $parent && $parent.find(".subcategory-list .subcategory-list-item"); + }, + + topicsSelection() { + const setting = this.container.lookup('site-settings:main').desktop_category_page_style; + switch (setting) { + case "categories_only": + return $(); + case "categories_with_featured_topics": + return $(".latest .featured-topic"); + default: + return $(".categories-topics-list .categories-topics-list-item"); // latest or top topics + } + }, + + categoriesPageList() { + switch (CURRENT_FOCUS) { + case "sub-cats": + return this.subcategoriesSelection(); + case "latest-topics": + return this.topicsSelection(); + default: + this.setCurrentFocus("parent-cats"); + return this.parentCategoriesSelection(); + } }, _findArticles() { const $topicList = $(".topic-list"); const $postsWrapper = $(".posts-wrapper"); + const $categoriesPageList = this.categoriesPageList(); if ($postsWrapper.length > 0) { return $(".posts-wrapper .topic-post, .topic-list tbody tr"); } else if ($topicList.length > 0) { return $topicList.find(".topic-list-item"); + } else if ($categoriesPageList.length > 0) { + return $categoriesPageList; } }, _changeSection(direction) { - const $sections = $(".nav.nav-pills li"), - active = $(".nav.nav-pills li.active"), - index = $sections.index(active) + direction; + const $sections = $('.nav.nav-pills li'), + active = $('.nav.nav-pills li.active'), + index = $sections.index(active) + direction; if (index >= 0 && index < $sections.length) { - $sections - .eq(index) - .find("a") - .click(); + $sections.eq(index).find('a').click(); } }, _stopCallback() { const oldStopCallback = this.keyTrapper.prototype.stopCallback; - this.keyTrapper.prototype.stopCallback = function( - e, - element, - combo, - sequence - ) { - if ( - (combo === "ctrl+f" || combo === "command+f") && - element.id === "search-term" - ) { + this.keyTrapper.prototype.stopCallback = function(e, element, combo, sequence) { + if ((combo === 'ctrl+f' || combo === 'command+f') && element.id === 'search-term') { return false; } return oldStopCallback.call(this, e, element, combo, sequence); @@ -475,6 +512,6 @@ export default { }, _replyToPost() { - this.container.lookup("controller:topic").send("replyToPost"); + this.container.lookup('controller:topic').send('replyToPost'); } -}; +}; \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/components/categories-and-latest-topics.hbs b/app/assets/javascripts/discourse/templates/components/categories-and-latest-topics.hbs index be7ee3e4a31..e7bad9fd38a 100644 --- a/app/assets/javascripts/discourse/templates/components/categories-and-latest-topics.hbs +++ b/app/assets/javascripts/discourse/templates/components/categories-and-latest-topics.hbs @@ -3,5 +3,5 @@