FIX: Isolate modal and global key-binds (#12477)

This change makes is so that when a time-picking modal (e.g.  "Add bookmark" modal) is visible, **all** global key bindings are paused.

1. Fixes an issue where opening and closing a time-picking modal would break global single-key keybinds, so for example, <kbd>L</kbd> would no longer like posts, but <kbd>L</kbd> <kbd>L</kbd> would
2. Fixes a related issue, where doing the above would also override custom keybinds provided by plugins (e.g. <kbd>L</kbd> shortcut that discourse-reactions uses)

Included:

* DEV: Reset Mousetraps instead of unbinding
* FIX: Make unbind use unbind
* DEV: Don't check for keyTrapper twice
* DEV: Use an instance of Mousetrap
* DEV: Remove an invalid `for` attribute (`set_reminder` doesn't exist)
* DEV: Add ability to pause all KeyboardShortcuts
* FIX: Pause all keybinds when in a time-picking modal
* DEV: Move bookmark keybind resets to willDestroyElement
* DEV: Fix shortcuts-related tests
This commit is contained in:
Jarek Radosz 2021-03-29 13:58:03 +02:00 committed by GitHub
parent 8335c8dc1a
commit f0b2e77abb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 94 additions and 92 deletions

View File

@ -7,14 +7,13 @@ import {
startOfDay, startOfDay,
tomorrow, tomorrow,
} from "discourse/lib/time-utils"; } from "discourse/lib/time-utils";
import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark"; import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark";
import Component from "@ember/component"; import Component from "@ember/component";
import I18n from "I18n"; import I18n from "I18n";
import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts"; import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts";
import Mousetrap from "mousetrap";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
import { TIME_SHORTCUT_TYPES } from "discourse/lib/time-shortcut"; import { TIME_SHORTCUT_TYPES } from "discourse/lib/time-shortcut";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import bootbox from "bootbox"; import bootbox from "bootbox";
@ -24,11 +23,6 @@ import { and, notEmpty } from "@ember/object/computed";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { later } from "@ember/runloop"; import { later } from "@ember/runloop";
// global shortcuts that interfere with these modal shortcuts, they are rebound when the
// modal is closed
//
// d deletePost
const GLOBAL_SHORTCUTS_TO_PAUSE = ["d"];
const BOOKMARK_BINDINGS = { const BOOKMARK_BINDINGS = {
enter: { handler: "saveAndClose" }, enter: { handler: "saveAndClose" },
"d d": { handler: "delete" }, "d d": { handler: "delete" },
@ -127,26 +121,18 @@ export default Component.extend({
}, },
_bindKeyboardShortcuts() { _bindKeyboardShortcuts() {
KeyboardShortcuts.pause(GLOBAL_SHORTCUTS_TO_PAUSE); KeyboardShortcuts.pause();
this._mousetrap = new Mousetrap();
Object.keys(BOOKMARK_BINDINGS).forEach((shortcut) => { Object.keys(BOOKMARK_BINDINGS).forEach((shortcut) => {
KeyboardShortcuts.addShortcut(shortcut, () => { this._mousetrap.bind(shortcut, () => {
let binding = BOOKMARK_BINDINGS[shortcut]; let binding = BOOKMARK_BINDINGS[shortcut];
if (binding.args) {
return this.send(binding.handler, ...binding.args);
}
this.send(binding.handler); this.send(binding.handler);
return false;
}); });
}); });
}, },
_unbindKeyboardShortcuts() {
KeyboardShortcuts.unbind(BOOKMARK_BINDINGS);
},
_restoreGlobalShortcuts() {
KeyboardShortcuts.unpause(GLOBAL_SHORTCUTS_TO_PAUSE);
},
_loadPostLocalDates() { _loadPostLocalDates() {
let postEl = document.querySelector( let postEl = document.querySelector(
`[data-post-id="${this.model.postId}"]` `[data-post-id="${this.model.postId}"]`
@ -270,9 +256,6 @@ export default Component.extend({
this._closeWithoutSaving = this._closeWithoutSaving =
this._closeWithoutSaving || initiatedByCloseButton; this._closeWithoutSaving || initiatedByCloseButton;
this._unbindKeyboardShortcuts();
this._restoreGlobalShortcuts();
if (!this._closeWithoutSaving && !this._savingBookmarkManually) { if (!this._closeWithoutSaving && !this._savingBookmarkManually) {
this._saveBookmark().catch((e) => this._handleSaveError(e)); this._saveBookmark().catch((e) => this._handleSaveError(e));
} }
@ -281,6 +264,12 @@ export default Component.extend({
} }
}, },
willDestroyElement() {
this._super(...arguments);
this._mousetrap.reset();
KeyboardShortcuts.unpause();
},
showExistingReminderAt: notEmpty("model.reminderAt"), showExistingReminderAt: notEmpty("model.reminderAt"),
showDelete: notEmpty("model.id"), showDelete: notEmpty("model.id"),
userHasTimezoneSet: notEmpty("userTimezone"), userHasTimezoneSet: notEmpty("userTimezone"),

View File

@ -274,12 +274,14 @@ export default Component.extend({
scheduleOnce("afterRender", this, this._readyNow); scheduleOnce("afterRender", this, this._readyNow);
const mouseTrap = Mousetrap(this.element.querySelector(".d-editor-input")); this._mouseTrap = new Mousetrap(
this.element.querySelector(".d-editor-input")
);
const shortcuts = this.get("toolbar.shortcuts"); const shortcuts = this.get("toolbar.shortcuts");
Object.keys(shortcuts).forEach((sc) => { Object.keys(shortcuts).forEach((sc) => {
const button = shortcuts[sc]; const button = shortcuts[sc];
mouseTrap.bind(sc, () => { this._mouseTrap.bind(sc, () => {
button.action(button); button.action(button);
return false; return false;
}); });
@ -317,7 +319,6 @@ export default Component.extend({
this.appEvents.on("composer:insert-text", this, "_insertText"); this.appEvents.on("composer:insert-text", this, "_insertText");
this.appEvents.on("composer:replace-text", this, "_replaceText"); this.appEvents.on("composer:replace-text", this, "_replaceText");
} }
this._mouseTrap = mouseTrap;
if (isTesting()) { if (isTesting()) {
this.element.addEventListener("paste", this.paste.bind(this)); this.element.addEventListener("paste", this.paste.bind(this));
@ -340,10 +341,7 @@ export default Component.extend({
this.appEvents.off("composer:replace-text", this, "_replaceText"); this.appEvents.off("composer:replace-text", this, "_replaceText");
} }
const mouseTrap = this._mouseTrap; this._mouseTrap.reset();
Object.keys(this.get("toolbar.shortcuts")).forEach((sc) =>
mouseTrap.unbind(sc)
);
$(this.element.querySelector(".d-editor-preview")).off("click.preview"); $(this.element.querySelector(".d-editor-preview")).off("click.preview");
if (isTesting()) { if (isTesting()) {

View File

@ -8,13 +8,15 @@ import {
PUBLISH_TO_CATEGORY_STATUS_TYPE, PUBLISH_TO_CATEGORY_STATUS_TYPE,
} from "discourse/controllers/edit-topic-timer"; } from "discourse/controllers/edit-topic-timer";
import { FORMAT } from "select-kit/components/future-date-input-selector"; import { FORMAT } from "select-kit/components/future-date-input-selector";
import discourseComputed, { on } from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
import { equal, or, readOnly } from "@ember/object/computed"; import { equal, or, readOnly } from "@ember/object/computed";
import I18n from "I18n"; import I18n from "I18n";
import { action } from "@ember/object"; import { action } from "@ember/object";
import Component from "@ember/component"; import Component from "@ember/component";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import { now, startOfDay, thisWeekend } from "discourse/lib/time-utils"; import { now, startOfDay, thisWeekend } from "discourse/lib/time-utils";
import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts";
import Mousetrap from "mousetrap";
export default Component.extend({ export default Component.extend({
statusType: readOnly("topicTimer.status_type"), statusType: readOnly("topicTimer.status_type"),
@ -37,18 +39,31 @@ export default Component.extend({
), ),
duration: null, duration: null,
@on("init") init() {
preloadDuration() { this._super(...arguments);
KeyboardShortcuts.pause();
this._mousetrap = new Mousetrap();
this.set("duration", this.initialDuration);
},
get initialDuration() {
if (!this.useDuration || !this.topicTimer.duration_minutes) { if (!this.useDuration || !this.topicTimer.duration_minutes) {
return; return null;
} } else if (this.durationType === "days") {
if (this.durationType === "days") { return this.topicTimer.duration_minutes / 60 / 24;
this.set("duration", this.topicTimer.duration_minutes / 60 / 24);
} else { } else {
this.set("duration", this.topicTimer.duration_minutes / 60); return this.topicTimer.duration_minutes / 60;
} }
}, },
willDestroyElement() {
this._super(...arguments);
this._mousetrap.reset();
KeyboardShortcuts.unpause();
},
@discourseComputed("autoDeleteReplies") @discourseComputed("autoDeleteReplies")
durationType(autoDeleteReplies) { durationType(autoDeleteReplies) {
return autoDeleteReplies ? "days" : "hours"; return autoDeleteReplies ? "days" : "hours";

View File

@ -251,8 +251,8 @@ const SiteHeaderComponent = MountWidget.extend(
} }
const header = document.querySelector("header.d-header"); const header = document.querySelector("header.d-header");
const mousetrap = new Mousetrap(header); this._mousetrap = new Mousetrap(header);
mousetrap.bind(["right", "left"], (e) => { this._mousetrap.bind(["right", "left"], (e) => {
const activeTab = document.querySelector(".glyphs .menu-link.active"); const activeTab = document.querySelector(".glyphs .menu-link.active");
if (activeTab) { if (activeTab) {
@ -267,8 +267,6 @@ const SiteHeaderComponent = MountWidget.extend(
}); });
} }
}); });
this.set("_mousetrap", mousetrap);
}, },
_cleanDom() { _cleanDom() {
@ -290,7 +288,7 @@ const SiteHeaderComponent = MountWidget.extend(
cancel(this._scheduledRemoveAnimate); cancel(this._scheduledRemoveAnimate);
window.cancelAnimationFrame(this._scheduledMovingAnimation); window.cancelAnimationFrame(this._scheduledMovingAnimation);
this._mousetrap.unbind(["right", "left"]); this._mousetrap.reset();
document.removeEventListener("click", this._dismissFirstNotification); document.removeEventListener("click", this._dismissFirstNotification);
}, },

View File

@ -16,18 +16,9 @@ import discourseComputed, {
import Component from "@ember/component"; import Component from "@ember/component";
import I18n from "I18n"; import I18n from "I18n";
import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { and, equal } from "@ember/object/computed"; import { and, equal } from "@ember/object/computed";
// global shortcuts that interfere with these modal shortcuts, they are rebound when the
// component is destroyed
//
// c createTopic
// r replyToPost
// l toggle like
// t replyAsNewTopic
const GLOBAL_SHORTCUTS_TO_PAUSE = ["c", "r", "l", "t"];
const BINDINGS = { const BINDINGS = {
"l t": { "l t": {
handler: "selectShortcut", handler: "selectShortcut",
@ -113,10 +104,9 @@ export default Component.extend({
} }
}, },
@on("willDestroyElement") willDestroyElement() {
_resetKeyboardShortcuts() { this._super(...arguments);
KeyboardShortcuts.unbind(BINDINGS); this.mousetrap.unbind(Object.keys(BINDINGS));
KeyboardShortcuts.unpause(GLOBAL_SHORTCUTS_TO_PAUSE);
}, },
parsePrefilledDatetime() { parsePrefilledDatetime() {
@ -157,14 +147,11 @@ export default Component.extend({
}, },
_bindKeyboardShortcuts() { _bindKeyboardShortcuts() {
KeyboardShortcuts.pause(GLOBAL_SHORTCUTS_TO_PAUSE);
Object.keys(BINDINGS).forEach((shortcut) => { Object.keys(BINDINGS).forEach((shortcut) => {
KeyboardShortcuts.addShortcut(shortcut, () => { this.mousetrap.bind(shortcut, () => {
let binding = BINDINGS[shortcut]; let binding = BINDINGS[shortcut];
if (binding.args) { this.send(binding.handler, ...binding.args);
return this.send(binding.handler, ...binding.args); return false;
}
this.send(binding.handler);
}); });
}); });
}, },

View File

@ -100,7 +100,7 @@ function preventKeyboardEvent(event) {
export default { export default {
init(keyTrapper, container) { init(keyTrapper, container) {
this.keyTrapper = keyTrapper; this.keyTrapper = new keyTrapper();
this.container = container; this.container = container;
this._stopCallback(); this._stopCallback();
@ -168,6 +168,12 @@ export default {
if (this.isTornDown()) { if (this.isTornDown()) {
return; return;
} }
if (!combinations) {
this.keyTrapper.paused = true;
return;
}
combinations.forEach((combo) => this.keyTrapper.unbind(combo)); combinations.forEach((combo) => this.keyTrapper.unbind(combo));
}, },
@ -176,10 +182,12 @@ export default {
if (this.isTornDown()) { if (this.isTornDown()) {
return; return;
} }
// if the keytrapper has already been torn down this will error
if (this.keyTrapper == null) { if (!combinations) {
this.keyTrapper.paused = false;
return; return;
} }
combinations.forEach((combo) => this.bindKey(combo)); combinations.forEach((combo) => this.bindKey(combo));
}, },
@ -210,7 +218,7 @@ export default {
// 'c': createTopic // 'c': createTopic
// } // }
unbind(combinations) { unbind(combinations) {
this.pause(Object.keys(combinations)); Object.keys(combinations).forEach((combo) => this.keyTrapper.unbind(combo));
}, },
toggleBookmark(event) { toggleBookmark(event) {
@ -758,20 +766,21 @@ export default {
}, },
_stopCallback() { _stopCallback() {
const oldStopCallback = this.keyTrapper.prototype.stopCallback; const prototype = Object.getPrototypeOf(this.keyTrapper);
const oldStopCallback = prototype.stopCallback;
prototype.stopCallback = function (e, element, combo, sequence) {
if (this.paused) {
return true;
}
this.keyTrapper.prototype.stopCallback = function (
e,
element,
combo,
sequence
) {
if ( if (
(combo === "ctrl+f" || combo === "command+f") && (combo === "ctrl+f" || combo === "command+f") &&
element.id === "search-term" element.id === "search-term"
) { ) {
return false; return false;
} }
return oldStopCallback.call(this, e, element, combo, sequence); return oldStopCallback.call(this, e, element, combo, sequence);
}; };
}, },

View File

@ -30,12 +30,18 @@
{{/if}} {{/if}}
<div class="control-group"> <div class="control-group">
<label class="control-label" for="set_reminder"> <label class="control-label">
{{i18n "post.bookmarks.set_reminder"}} {{i18n "post.bookmarks.set_reminder"}}
</label> </label>
{{#if userHasTimezoneSet}} {{#if userHasTimezoneSet}}
{{time-shortcut-picker prefilledDatetime=prefilledDatetime onTimeSelected=(action "onTimeSelected") customOptions=customTimeShortcutOptions additionalOptionsToShow=additionalTimeShortcutOptions}} {{time-shortcut-picker
prefilledDatetime=prefilledDatetime
onTimeSelected=(action "onTimeSelected")
customOptions=customTimeShortcutOptions
additionalOptionsToShow=additionalTimeShortcutOptions
mousetrap=_mousetrap
}}
{{else}} {{else}}
<div class="alert alert-info">{{html-safe (i18n "bookmarks.no_timezone" basePath=(base-path))}}</div> <div class="alert alert-info">{{html-safe (i18n "bookmarks.no_timezone" basePath=(base-path))}}</div>
{{/if}} {{/if}}

View File

@ -19,7 +19,13 @@
{{/if}} {{/if}}
{{#if showFutureDateInput}} {{#if showFutureDateInput}}
<label class="control-label">{{i18n "topic.topic_status_update.when"}}</label> <label class="control-label">{{i18n "topic.topic_status_update.when"}}</label>
{{time-shortcut-picker prefilledDatetime=topicTimer.execute_at onTimeSelected=onTimeSelected customOptions=customTimeShortcutOptions hiddenOptions=hiddenTimeShortcutOptions}} {{time-shortcut-picker
prefilledDatetime=topicTimer.execute_at
onTimeSelected=onTimeSelected
customOptions=customTimeShortcutOptions
hiddenOptions=hiddenTimeShortcutOptions
mousetrap=_mousetrap
}}
{{/if}} {{/if}}
{{#if useDuration}} {{#if useDuration}}
<div class="controls"> <div class="controls">
@ -45,4 +51,3 @@
</div> </div>
{{/if}} {{/if}}
</form> </form>

View File

@ -4,12 +4,11 @@ import {
loggedInUser, loggedInUser,
queryAll, queryAll,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import { click, fillIn, getApplication, visit } from "@ember/test-helpers"; import { click, fillIn, visit } from "@ember/test-helpers";
import I18n from "I18n"; import I18n from "I18n";
import selectKit from "discourse/tests/helpers/select-kit-helper"; import selectKit from "discourse/tests/helpers/select-kit-helper";
import { test } from "qunit"; import { test } from "qunit";
import topicFixtures from "discourse/tests/fixtures/topic"; import topicFixtures from "discourse/tests/fixtures/topic";
import KeyboardShortcutInitializer from "discourse/initializers/keyboard-shortcuts";
async function openBookmarkModal(postNumber = 1) { async function openBookmarkModal(postNumber = 1) {
if (exists(`#post_${postNumber} button.show-more-actions`)) { if (exists(`#post_${postNumber} button.show-more-actions`)) {
@ -27,7 +26,6 @@ acceptance("Bookmarking", function (needs) {
let steps = []; let steps = [];
needs.hooks.beforeEach(function () { needs.hooks.beforeEach(function () {
KeyboardShortcutInitializer.initialize(getApplication());
steps = []; steps = [];
}); });

View File

@ -1,5 +1,4 @@
import { getApplication, triggerKeyEvent, visit } from "@ember/test-helpers"; import { triggerKeyEvent, visit } from "@ember/test-helpers";
import KeyboardShortcutInitializer from "discourse/initializers/keyboard-shortcuts";
import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts"; import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts";
import { acceptance } from "discourse/tests/helpers/qunit-helpers"; import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import sinon from "sinon"; import sinon from "sinon";
@ -8,10 +7,11 @@ import { withPluginApi } from "discourse/lib/plugin-api";
acceptance("Plugin Keyboard Shortcuts - Logged In", function (needs) { acceptance("Plugin Keyboard Shortcuts - Logged In", function (needs) {
needs.user(); needs.user();
needs.hooks.beforeEach(function () {
KeyboardShortcutInitializer.initialize(getApplication());
});
test("a plugin can add a keyboard shortcut", async function (assert) { test("a plugin can add a keyboard shortcut", async function (assert) {
// Initialize the app (required in the legacy testing env)
await visit("/");
withPluginApi("0.8.38", (api) => { withPluginApi("0.8.38", (api) => {
api.addKeyboardShortcut("]", () => { api.addKeyboardShortcut("]", () => {
$("#qunit-fixture").html( $("#qunit-fixture").html(
@ -30,11 +30,11 @@ acceptance("Plugin Keyboard Shortcuts - Logged In", function (needs) {
}); });
}); });
acceptance("Plugin Keyboard Shortcuts - Anonymous", function (needs) { acceptance("Plugin Keyboard Shortcuts - Anonymous", function () {
needs.hooks.beforeEach(function () {
KeyboardShortcutInitializer.initialize(getApplication());
});
test("a plugin can add a keyboard shortcut with an option", async function (assert) { test("a plugin can add a keyboard shortcut with an option", async function (assert) {
// Initialize the app (required in the legacy testing env)
await visit("/");
let spy = sinon.spy(KeyboardShortcuts, "_bindToPath"); let spy = sinon.spy(KeyboardShortcuts, "_bindToPath");
withPluginApi("0.8.38", (api) => { withPluginApi("0.8.38", (api) => {
api.addKeyboardShortcut("]", () => {}, { api.addKeyboardShortcut("]", () => {}, {

View File

@ -6,8 +6,6 @@ import {
fakeTime, fakeTime,
query, query,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import KeyboardShortcutInitializer from "discourse/initializers/keyboard-shortcuts";
import { getApplication } from "@ember/test-helpers";
import sinon from "sinon"; import sinon from "sinon";
let clock = null; let clock = null;
@ -23,7 +21,6 @@ discourseModule("Integration | Component | bookmark", function (hooks) {
'{{bookmark model=model afterSave=afterSave afterDelete=afterDelete onCloseWithoutSaving=onCloseWithoutSaving registerOnCloseHandler=(action "registerOnCloseHandler") closeModal=(action "closeModal")}}'; '{{bookmark model=model afterSave=afterSave afterDelete=afterDelete onCloseWithoutSaving=onCloseWithoutSaving registerOnCloseHandler=(action "registerOnCloseHandler") closeModal=(action "closeModal")}}';
hooks.beforeEach(function () { hooks.beforeEach(function () {
KeyboardShortcutInitializer.initialize(getApplication());
this.actions.registerOnCloseHandler = () => {}; this.actions.registerOnCloseHandler = () => {};
this.actions.closeModal = () => {}; this.actions.closeModal = () => {};
this.setProperties({ this.setProperties({