mirror of
https://github.com/discourse/discourse.git
synced 2025-06-25 01:30:17 +08:00
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:
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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", {
|
||||||
|
@ -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 => {
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -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");
|
||||||
|
@ -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}}
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
||||||
|
Reference in New Issue
Block a user