FEATURE: Bookmark keyboard shortcuts (#9318)

Adds keyboard bindings and associated help menu for selecting reminder type in bookmark modal, and pressing Enter to save.

Introduce the following APIs for `KeyboardShortcuts`:

* `pause` - Uses the provided array of combinations and unbinds them using `Mousetrap`.
* `unpause` - Uses the provided combinations and rebinds them to their default shortcuts listed in `KeyboardShortcuts`.
* `addBindings` - Adds the array of keyboard shortcut bindings and calls the provided callback when a binding is fired with Mousetrap.
* `unbind` - Takes an object literal of a binding map and unbinds all of them e.g. `{ enter: { handler: saveAndClose" } };`
This commit is contained in:
Martin Brennan
2020-04-07 14:03:15 +10:00
committed by GitHub
parent d04ba4b3b2
commit 93c38cc175
8 changed files with 205 additions and 28 deletions

View File

@ -1,10 +1,22 @@
import { and } from "@ember/object/computed";
import { next } from "@ember/runloop";
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
import ModalFunctionality from "discourse/mixins/modal-functionality"; import ModalFunctionality from "discourse/mixins/modal-functionality";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts";
// global shortcuts that interfere with these modal shortcuts, they are rebound when the
// modal is closed
//
// c createTopic
// r replyToPost
// l toggle like
// d deletePost
// t replyAsNewTopic
const GLOBAL_SHORTCUTS_TO_PAUSE = ["c", "r", "l", "d", "t"];
const START_OF_DAY_HOUR = 8; const START_OF_DAY_HOUR = 8;
const LATER_TODAY_CUTOFF_HOUR = 17; const LATER_TODAY_CUTOFF_HOUR = 17;
const REMINDER_TYPES = { const REMINDER_TYPES = {
@ -21,6 +33,28 @@ const REMINDER_TYPES = {
LATER_THIS_WEEK: "later_this_week" LATER_THIS_WEEK: "later_this_week"
}; };
const BOOKMARK_BINDINGS = {
enter: { handler: "saveAndClose" },
"l t": { handler: "selectReminderType", args: [REMINDER_TYPES.LATER_TODAY] },
"l w": {
handler: "selectReminderType",
args: [REMINDER_TYPES.LATER_THIS_WEEK]
},
"n b d": {
handler: "selectReminderType",
args: [REMINDER_TYPES.NEXT_BUSINESS_DAY]
},
"n d": { handler: "selectReminderType", args: [REMINDER_TYPES.TOMORROW] },
"n w": { handler: "selectReminderType", args: [REMINDER_TYPES.NEXT_WEEK] },
"n b w": {
handler: "selectReminderType",
args: [REMINDER_TYPES.START_OF_NEXT_BUSINESS_WEEK]
},
"n m": { handler: "selectReminderType", args: [REMINDER_TYPES.NEXT_MONTH] },
"c r": { handler: "selectReminderType", args: [REMINDER_TYPES.CUSTOM] },
"n r": { handler: "selectReminderType", args: [REMINDER_TYPES.NONE] }
};
export default Controller.extend(ModalFunctionality, { export default Controller.extend(ModalFunctionality, {
loading: false, loading: false,
errorMessage: null, errorMessage: null,
@ -33,6 +67,7 @@ export default Controller.extend(ModalFunctionality, {
customReminderTime: null, customReminderTime: null,
lastCustomReminderDate: null, lastCustomReminderDate: null,
lastCustomReminderTime: null, lastCustomReminderTime: null,
mouseTrap: null,
userTimezone: null, userTimezone: null,
onShow() { onShow() {
@ -49,7 +84,12 @@ export default Controller.extend(ModalFunctionality, {
userTimezone: this.currentUser.resolvedTimezone() userTimezone: this.currentUser.resolvedTimezone()
}); });
this.bindKeyboardShortcuts();
this.loadLastUsedCustomReminderDatetime(); this.loadLastUsedCustomReminderDatetime();
// make sure the input is cleared, otherwise the keyboard shortcut to toggle
// bookmark for post ends up in the input
next(() => this.set("name", null));
}, },
loadLastUsedCustomReminderDatetime() { loadLastUsedCustomReminderDatetime() {
@ -71,9 +111,29 @@ export default Controller.extend(ModalFunctionality, {
} }
}, },
bindKeyboardShortcuts() {
KeyboardShortcuts.pause(GLOBAL_SHORTCUTS_TO_PAUSE);
KeyboardShortcuts.addBindings(BOOKMARK_BINDINGS, binding => {
if (binding.args) {
return this.send(binding.handler, ...binding.args);
}
this.send(binding.handler);
});
},
unbindKeyboardShortcuts() {
KeyboardShortcuts.unbind(BOOKMARK_BINDINGS, this.mouseTrap);
},
restoreGlobalShortcuts() {
KeyboardShortcuts.unpause(...GLOBAL_SHORTCUTS_TO_PAUSE);
},
// we always want to save the bookmark unless the user specifically // we always want to save the bookmark unless the user specifically
// clicks the save or cancel button to mimic browser behaviour // clicks the save or cancel button to mimic browser behaviour
onClose() { onClose() {
this.unbindKeyboardShortcuts();
this.restoreGlobalShortcuts();
if (!this.closeWithoutSaving && !this.isSavingBookmarkManually) { if (!this.closeWithoutSaving && !this.isSavingBookmarkManually) {
this.saveBookmark().catch(e => this.handleSaveError(e)); this.saveBookmark().catch(e => this.handleSaveError(e));
} }
@ -102,10 +162,7 @@ export default Controller.extend(ModalFunctionality, {
return REMINDER_TYPES; return REMINDER_TYPES;
}, },
@discourseComputed() showLastCustom: and("lastCustomReminderTime", "lastCustomReminderDate"),
showLastCustom() {
return this.lastCustomReminderTime && this.lastCustomReminderDate;
},
@discourseComputed() @discourseComputed()
showLaterToday() { showLaterToday() {
@ -299,10 +356,16 @@ export default Controller.extend(ModalFunctionality, {
actions: { actions: {
saveAndClose() { saveAndClose() {
if (this.saving) {
return;
}
this.saving = true;
this.isSavingBookmarkManually = true; this.isSavingBookmarkManually = true;
this.saveBookmark() this.saveBookmark()
.then(() => this.send("closeModal")) .then(() => this.send("closeModal"))
.catch(e => this.handleSaveError(e)); .catch(e => this.handleSaveError(e))
.finally(() => (this.saving = false));
}, },
closeWithoutSavingBookmark() { closeWithoutSavingBookmark() {
@ -311,6 +374,9 @@ export default Controller.extend(ModalFunctionality, {
}, },
selectReminderType(type) { selectReminderType(type) {
if (type === REMINDER_TYPES.LATER_TODAY && !this.showLaterToday) {
return;
}
this.set("selectedReminderType", type); this.set("selectedReminderType", type);
} }
} }

View File

@ -1,5 +1,6 @@
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality"; import ModalFunctionality from "discourse/mixins/modal-functionality";
import { setting } from "discourse/lib/computed";
const KEY = "keyboard_shortcuts_help"; const KEY = "keyboard_shortcuts_help";
@ -51,6 +52,8 @@ export default Controller.extend(ModalFunctionality, {
this.set("modal.modalClass", "keyboard-shortcuts-modal"); this.set("modal.modalClass", "keyboard-shortcuts-modal");
}, },
showBookmarkShortcuts: setting("enable_bookmarks_with_reminders"),
shortcuts: { shortcuts: {
jump_to: { jump_to: {
home: buildShortcut("jump_to.home", { keys1: ["g", "h"] }), home: buildShortcut("jump_to.home", { keys1: ["g", "h"] }),
@ -125,6 +128,41 @@ export default Controller.extend(ModalFunctionality, {
keysDelimiter: PLUS keysDelimiter: PLUS
}) })
}, },
bookmarks: {
enter: buildShortcut("bookmarks.enter", { keys1: [ENTER] }),
later_today: buildShortcut("bookmarks.later_today", {
keys1: ["l", "t"],
shortcutsDelimiter: "space"
}),
later_this_week: buildShortcut("bookmarks.later_this_week", {
keys1: ["l", "w"],
shortcutsDelimiter: "space"
}),
tomorrow: buildShortcut("bookmarks.tomorrow", {
keys1: ["n", "d"],
shortcutsDelimiter: "space"
}),
next_week: buildShortcut("bookmarks.next_week", {
keys1: ["n", "w"],
shortcutsDelimiter: "space"
}),
next_business_week: buildShortcut("bookmarks.next_business_week", {
keys1: ["n", "b", "w"],
shortcutsDelimiter: "space"
}),
next_business_day: buildShortcut("bookmarks.next_business_day", {
keys1: ["n", "b", "d"],
shortcutsDelimiter: "space"
}),
custom: buildShortcut("bookmarks.custom", {
keys1: ["c", "r"],
shortcutsDelimiter: "space"
}),
none: buildShortcut("bookmarks.none", {
keys1: ["n", "r"],
shortcutsDelimiter: "space"
})
},
actions: { actions: {
bookmark_topic: buildShortcut("actions.bookmark_topic", { keys1: ["f"] }), bookmark_topic: buildShortcut("actions.bookmark_topic", { keys1: ["f"] }),
reply_as_new_topic: buildShortcut("actions.reply_as_new_topic", { reply_as_new_topic: buildShortcut("actions.reply_as_new_topic", {

View File

@ -662,6 +662,9 @@ export default Controller.extend(bufferedProperty("model"), {
if (!this.currentUser) { if (!this.currentUser) {
return bootbox.alert(I18n.t("bookmarks.not_bookmarked")); return bootbox.alert(I18n.t("bookmarks.not_bookmarked"));
} else if (post) { } else if (post) {
if (this.siteSettings.enable_bookmarks_with_reminders) {
return post.toggleBookmarkWithReminder();
}
return post.toggleBookmark().catch(popupAjaxError); return post.toggleBookmark().catch(popupAjaxError);
} else { } else {
return this.model.toggleBookmark().then(changedIds => { return this.model.toggleBookmark().then(changedIds => {

View File

@ -5,6 +5,7 @@ export default {
name: "keyboard-shortcuts", name: "keyboard-shortcuts",
initialize(container) { initialize(container) {
KeyboardShortcuts.bindEvents(Mousetrap, container); KeyboardShortcuts.init(Mousetrap, container);
KeyboardShortcuts.bindEvents();
} }
}; };

View File

@ -6,7 +6,7 @@ import { ajax } from "discourse/lib/ajax";
import { throttle } from "@ember/runloop"; import { throttle } from "@ember/runloop";
import { INPUT_DELAY } from "discourse-common/config/environment"; import { INPUT_DELAY } from "discourse-common/config/environment";
export let bindings = { export let DEFAULT_BINDINGS = {
"!": { postAction: "showFlags" }, "!": { postAction: "showFlags" },
"#": { handler: "goToPost", anonymous: true }, "#": { handler: "goToPost", anonymous: true },
"/": { handler: "toggleSearch", anonymous: true }, "/": { handler: "toggleSearch", anonymous: true },
@ -84,7 +84,7 @@ export let bindings = {
const animationDuration = 100; const animationDuration = 100;
export default { export default {
bindEvents(keyTrapper, container) { init(keyTrapper, container) {
this.keyTrapper = keyTrapper; this.keyTrapper = keyTrapper;
this.container = container; this.container = container;
this._stopCallback(); this._stopCallback();
@ -96,32 +96,72 @@ export default {
// Disable the shortcut if private messages are disabled // Disable the shortcut if private messages are disabled
if (!siteSettings.enable_personal_messages) { if (!siteSettings.enable_personal_messages) {
delete bindings["g m"]; delete DEFAULT_BINDINGS["g m"];
}
},
bindEvents() {
Object.keys(DEFAULT_BINDINGS).forEach(key => {
this.bindKey(key);
});
},
bindKey(key) {
const binding = DEFAULT_BINDINGS[key];
if (!binding.anonymous && !this.currentUser) {
return;
} }
Object.keys(bindings).forEach(key => { if (binding.path) {
const binding = bindings[key]; this._bindToPath(binding.path, key);
if (!binding.anonymous && !this.currentUser) { } else if (binding.handler) {
return; if (binding.global) {
// global shortcuts will trigger even while focusing on input/textarea
this._globalBindToFunction(binding.handler, key);
} else {
this._bindToFunction(binding.handler, key);
} }
} else if (binding.postAction) {
this._bindToSelectedPost(binding.postAction, key);
} else if (binding.click) {
this._bindToClick(binding.click, key);
}
},
if (binding.path) { // for cases when you want to disable global keyboard shortcuts
this._bindToPath(binding.path, key); // so that you can override them (e.g. inside a modal)
} else if (binding.handler) { pause(combinations) {
if (binding.global) { combinations.forEach(combo => this.keyTrapper.unbind(combo));
// global shortcuts will trigger even while focusing on input/textarea },
this._globalBindToFunction(binding.handler, key);
} else { // restore global shortcuts that you have paused
this._bindToFunction(binding.handler, key); unpause(...combinations) {
} combinations.forEach(combo => this.bindKey(combo));
} else if (binding.postAction) { },
this._bindToSelectedPost(binding.postAction, key);
} else if (binding.click) { // add bindings to the key trapper, if none is specified then
this._bindToClick(binding.click, key); // the shortcuts will be bound globally.
} addBindings(newBindings, callback) {
Object.keys(newBindings).forEach(key => {
let binding = newBindings[key];
this.keyTrapper.bind(key, event => {
// usually the caller that is adding the binding
// will want to decide what to do with it when the
// event is fired
callback(binding, event);
event.stopPropagation();
});
}); });
}, },
// unbinds all the shortcuts in a key binding object e.g.
// {
// 'c': createTopic
// }
unbind(bindings) {
this.pause(Object.keys(bindings));
},
toggleBookmark() { toggleBookmark() {
this.sendToSelectedPost("toggleBookmark"); this.sendToSelectedPost("toggleBookmark");
this.sendToTopicListItemView("toggleBookmark"); this.sendToTopicListItemView("toggleBookmark");

View File

@ -9,7 +9,7 @@
{{/if}} {{/if}}
<div class="control-group"> <div class="control-group">
{{input value=name name="name" class="bookmark-name" enter=(action "saveAndClose") placeholder=(i18n "post.bookmarks.name_placeholder")}} {{input value=name name="name" class="bookmark-name" placeholder=(i18n "post.bookmarks.name_placeholder")}}
</div> </div>
{{#if showBookmarkReminderControls}} {{#if showBookmarkReminderControls}}

View File

@ -55,6 +55,23 @@
<li>{{html-safe shortcuts.actions.quote_post}}</li> <li>{{html-safe shortcuts.actions.quote_post}}</li>
</ul> </ul>
</section> </section>
{{#if showBookmarkShortcuts}}
<section class="keyboard-shortcuts-bookmark-section">
<h4>{{i18n "keyboard_shortcuts_help.bookmarks.title"}}</h4>
<ul>
<li>{{html-safe shortcuts.bookmarks.enter}}</li>
<li>{{html-safe shortcuts.bookmarks.later_today}}</li>
<li>{{html-safe shortcuts.bookmarks.later_this_week}}</li>
<li>{{html-safe shortcuts.bookmarks.tomorrow}}</li>
<li>{{html-safe shortcuts.bookmarks.next_week}}</li>
<li>{{html-safe shortcuts.bookmarks.next_month}}</li>
<li>{{html-safe shortcuts.bookmarks.next_business_week}}</li>
<li>{{html-safe shortcuts.bookmarks.next_business_day}}</li>
<li>{{html-safe shortcuts.bookmarks.custom}}</li>
<li>{{html-safe shortcuts.bookmarks.none}}</li>
</ul>
</section>
{{/if}}
</div> </div>
<div class="column"> <div class="column">
<section> <section>

View File

@ -3075,6 +3075,18 @@ en:
title: "Composing" title: "Composing"
return: "%{shortcut} Return to composer" return: "%{shortcut} Return to composer"
fullscreen: "%{shortcut} Fullscreen composer" fullscreen: "%{shortcut} Fullscreen composer"
bookmarks:
title: "Bookmarking"
enter: "%{shortcut} Save and close"
later_today: "%{shortcut} Later today"
later_this_week: "%{shortcut} Later this week"
tomorrow: "%{shortcut} Tomorrow"
next_week: "%{shortcut} Next week"
next_month: "%{shortcut} Next month"
next_business_week: "%{shortcut} Start of next week"
next_business_day: "%{shortcut} Next business day"
custom: "%{shortcut} Custom date and time"
none: "%{shortcut} No reminder"
actions: actions:
title: "Actions" title: "Actions"
bookmark_topic: "%{shortcut} Toggle bookmark topic" bookmark_topic: "%{shortcut} Toggle bookmark topic"