hotkey: Fix Caps Lock incorrectly affecting Shift state in shortcuts.

Earlier, with Caps Lock enabled, pressing letters like 'a', 'i', etc
resulted in the keyboard shortcuts for 'Shift+A', 'Shift+I', etc
getting executed. Ideally, they should work only when 'Shift + Key'
is pressed.

We used to check capitalized letters, which can result either due
to `key` pressed with Caps Lock enabled or `Shift + key` pressed.

This commit fixes the bug by using the event modifiers states to
construct a fully descriptive string like 'Cmd+Ctrl+Alt+Shift+A',
and then look up that string in a single mapping for further execution.

We treat a-z letters case-insensitively. We distinguish between
'a' and 'A' as 'A' vs 'Shift+A'.

This descriptive string approach also resolves a bug where
`Ctrl + Meta + C/K/S/.` was working. We define the valid hotkey as
`Meta + C/K/S/.` for macOS and `Ctrl + C/K/S/.` for others.
This commit is contained in:
Prakhar Pratyush
2025-05-13 13:05:06 +05:30
committed by Tim Abbott
parent c2d0002b4f
commit f506ccc5f4
2 changed files with 137 additions and 142 deletions

View File

@@ -89,6 +89,23 @@ const color_picker_hotkeys = new Set([
"enter",
]);
// A set of characters that, on many keyboard layouts, require 'Shift' modifier key
// to type, or where pressing the key with a 'Shift' modifier is expected to produce
// a different character. We ignore the 'Shift' modifier when processing these keys,
// and disallow assigning hotkeys that involve 'Shift' modifier combined with them.
const KNOWN_IGNORE_SHIFT_MODIFIER_KEYS = new Set([
"*",
"+",
"=",
"-",
"/",
":",
"<",
">",
"?",
"@",
]);
// Note that multiple keys can map to the same event_name, which
// we'll do in cases where they have the exact same semantics.
// DON'T FORGET: update keyboard_shortcuts.html
@@ -99,25 +116,68 @@ const color_picker_hotkeys = new Set([
// in the main message view with a selected message.
// `message_view_only` hotkeys, as a group, are not processed if any
// overlays are open (e.g. settings, streams, etc.).
const keydown_shift_mappings = {
// these can be triggered by Shift + key only
Tab: {name: "shift_tab", message_view_only: false},
" ": {name: "shift_spacebar", message_view_only: true},
ArrowLeft: {name: "left_arrow", message_view_only: false},
ArrowRight: {name: "right_arrow", message_view_only: false},
ArrowUp: {name: "up_arrow", message_view_only: false},
ArrowDown: {name: "down_arrow", message_view_only: false},
H: {name: "view_edit_history", message_view_only: true},
N: {name: "narrow_to_next_unread_followed_topic", message_view_only: false},
V: [
const KEYDOWN_MAPPINGS = {
"Alt+P": {name: "toggle_compose_preview", message_view_only: true},
"Ctrl+[": {name: "escape", message_view_only: false},
"Cmd+Enter": {name: "action_with_enter", message_view_only: true},
"Cmd+C": {name: "copy_with_c", message_view_only: false},
"Cmd+K": {name: "search_with_k", message_view_only: false},
"Cmd+S": {name: "star_message", message_view_only: true},
"Cmd+.": {name: "narrow_to_compose_target", message_view_only: true},
"Cmd+'": {name: "open_saved_snippet_dropdown", message_view_only: true},
"Ctrl+Enter": {name: "action_with_enter", message_view_only: true},
"Ctrl+C": {name: "copy_with_c", message_view_only: false},
"Ctrl+K": {name: "search_with_k", message_view_only: false},
"Ctrl+S": {name: "star_message", message_view_only: true},
"Ctrl+.": {name: "narrow_to_compose_target", message_view_only: true},
"Ctrl+'": {name: "open_saved_snippet_dropdown", message_view_only: true},
"Shift+A": {name: "stream_cycle_backward", message_view_only: true},
"Shift+C": {name: "C_deprecated", message_view_only: true},
"Shift+D": {name: "stream_cycle_forward", message_view_only: true},
"Shift+G": {name: "G_end", message_view_only: true},
"Shift+H": {name: "view_edit_history", message_view_only: true},
"Shift+I": {name: "open_inbox", message_view_only: true},
"Shift+J": {name: "vim_page_down", message_view_only: true},
"Shift+K": {name: "vim_page_up", message_view_only: true},
"Shift+M": {name: "toggle_topic_visibility_policy", message_view_only: true},
"Shift+N": {name: "narrow_to_next_unread_followed_topic", message_view_only: false},
"Shift+P": {name: "narrow_private", message_view_only: true},
"Shift+R": {name: "respond_to_author", message_view_only: true},
"Shift+S": {name: "toggle_stream_subscription", message_view_only: true},
"Shift+U": {name: "mark_unread", message_view_only: true},
"Shift+V": [
{name: "view_selected_stream", message_view_only: false},
{name: "toggle_read_receipts", message_view_only: true},
],
};
const keydown_unshift_mappings = {
// these can be triggered by key only (without Shift)
"Shift+Tab": {name: "shift_tab", message_view_only: false},
"Shift+ ": {name: "shift_spacebar", message_view_only: true},
"Shift+ArrowLeft": {name: "left_arrow", message_view_only: false},
"Shift+ArrowRight": {name: "right_arrow", message_view_only: false},
"Shift+ArrowUp": {name: "up_arrow", message_view_only: false},
"Shift+ArrowDown": {name: "down_arrow", message_view_only: false},
// The shortcut "A" dates from when this was called "All messages".
A: {name: "open_combined_feed", message_view_only: true},
C: {name: "compose", message_view_only: true},
D: {name: "open_drafts", message_view_only: true},
E: {name: "edit_message", message_view_only: true},
G: {name: "gear_menu", message_view_only: true},
H: {name: "vim_left", message_view_only: true},
I: {name: "message_actions", message_view_only: true},
J: {name: "vim_down", message_view_only: true},
K: {name: "vim_up", message_view_only: true},
L: {name: "vim_right", message_view_only: true},
M: {name: "move_message", message_view_only: true},
N: {name: "n_key", message_view_only: false},
P: {name: "p_key", message_view_only: false},
Q: {name: "query_streams", message_view_only: true},
R: {name: "reply_message", message_view_only: true},
S: {name: "toggle_conversation_view", message_view_only: true},
T: {name: "open_recent_view", message_view_only: true},
U: {name: "toggle_sender_info", message_view_only: true},
V: {name: "show_lightbox", message_view_only: true},
W: {name: "query_users", message_view_only: true},
X: {name: "compose_private_message", message_view_only: true},
Z: {name: "zoom_to_message_near", message_view_only: true},
Tab: {name: "tab", message_view_only: false},
Escape: {name: "escape", message_view_only: false},
" ": {name: "spacebar", message_view_only: true},
@@ -129,43 +189,6 @@ const keydown_unshift_mappings = {
ArrowRight: {name: "right_arrow", message_view_only: false},
ArrowUp: {name: "up_arrow", message_view_only: false},
ArrowDown: {name: "down_arrow", message_view_only: false},
};
const keydown_ctrl_mappings = {
"[": {name: "escape", message_view_only: false},
};
const keydown_cmd_or_ctrl_mappings = {
Enter: {name: "action_with_enter", message_view_only: true},
c: {name: "copy_with_c", message_view_only: false},
k: {name: "search_with_k", message_view_only: false},
s: {name: "star_message", message_view_only: true},
".": {name: "narrow_to_compose_target", message_view_only: true},
"'": {name: "open_saved_snippet_dropdown", message_view_only: true},
};
const keydown_alt_mappings = {
p: {name: "toggle_compose_preview", message_view_only: true},
};
const keydown_either_mappings = {
// these can be triggered by key or Shift + key
// Note that codes for letters are still case sensitive!
//
// We may want to revisit both of these. For Backspace, we don't
// have any specific mapping behavior; we are just trying to disable
// the normal browser features for certain OSes when we are in the
// compose box, and the little bit of Backspace-related code here is
// dubious, but may apply to Shift-Backspace.
// For Enter, there is some possibly that Shift-Enter is intended to
// have special behavior for folks that are used to Shift-Enter behavior
// in other apps, but that's also slightly dubious.
Backspace: {name: "backspace", message_view_only: true},
Enter: {name: "enter", message_view_only: false},
Delete: {name: "delete", message_view_only: false},
};
const character_mappings = {
"*": {name: "open_starred_message_view", message_view_only: true},
"+": {name: "thumbs_up_emoji", message_view_only: true},
"=": {name: "upvote_first_emoji", message_view_only: true},
@@ -176,94 +199,55 @@ const character_mappings = {
">": {name: "compose_quote_message", message_view_only: true},
"?": {name: "show_shortcuts", message_view_only: false},
"@": {name: "compose_reply_with_mention", message_view_only: true},
A: {name: "stream_cycle_backward", message_view_only: true},
C: {name: "C_deprecated", message_view_only: true},
D: {name: "stream_cycle_forward", message_view_only: true},
G: {name: "G_end", message_view_only: true},
I: {name: "open_inbox", message_view_only: true},
J: {name: "vim_page_down", message_view_only: true},
K: {name: "vim_page_up", message_view_only: true},
M: {name: "toggle_topic_visibility_policy", message_view_only: true},
P: {name: "narrow_private", message_view_only: true},
R: {name: "respond_to_author", message_view_only: true},
S: {name: "toggle_stream_subscription", message_view_only: true},
U: {name: "mark_unread", message_view_only: true},
// The shortcut "a" dates from when this was called "All messages".
a: {name: "open_combined_feed", message_view_only: true},
c: {name: "compose", message_view_only: true},
d: {name: "open_drafts", message_view_only: true},
e: {name: "edit_message", message_view_only: true},
g: {name: "gear_menu", message_view_only: true},
h: {name: "vim_left", message_view_only: true},
i: {name: "message_actions", message_view_only: true},
j: {name: "vim_down", message_view_only: true},
k: {name: "vim_up", message_view_only: true},
l: {name: "vim_right", message_view_only: true},
m: {name: "move_message", message_view_only: true},
n: {name: "n_key", message_view_only: false},
p: {name: "p_key", message_view_only: false},
q: {name: "query_streams", message_view_only: true},
r: {name: "reply_message", message_view_only: true},
s: {name: "toggle_conversation_view", message_view_only: true},
t: {name: "open_recent_view", message_view_only: true},
u: {name: "toggle_sender_info", message_view_only: true},
v: {name: "show_lightbox", message_view_only: true},
w: {name: "query_users", message_view_only: true},
x: {name: "compose_private_message", message_view_only: true},
z: {name: "zoom_to_message_near", message_view_only: true},
// these can be triggered by key or Shift + key
//
// We may want to revisit both of these. For Backspace, we don't
// have any specific mapping behavior; we are just trying to disable
// the normal browser features for certain OSes when we are in the
// compose box, and the little bit of Backspace-related code here is
// dubious, but may apply to Shift-Backspace.
// For Enter, there is some possibly that Shift-Enter is intended to
// have special behavior for folks that are used to Shift-Enter behavior
// in other apps, but that's also slightly dubious.
Backspace: {name: "backspace", message_view_only: true},
Enter: {name: "enter", message_view_only: false},
Delete: {name: "delete", message_view_only: false},
"Shift+Backspace": {name: "backspace", message_view_only: true},
"Shift+Enter": {name: "enter", message_view_only: false},
"Shift+Delete": {name: "delete", message_view_only: false},
};
// Keyboard Event Viewer tool:
// https://w3c.github.io/uievents/tools/key-event-viewer.html
export function get_keydown_hotkey(e) {
let hotkey;
if (common.has_mac_keyboard() && e.ctrlKey && e.key !== "[") {
// On macOS, Cmd is used instead of Ctrl. Except 'Ctrl + ['.
return undefined;
}
if (!common.has_mac_keyboard() && e.metaKey) {
return undefined;
}
// Make lowercase a-z uppercase; leave others unchanged.
// We distinguish between 'a' and 'A' as 'A' vs 'Shift+A'.
// This helps to avoid Caps Lock affecting shortcuts.
let key = /^[a-z]$/.test(e.key) ? e.key.toUpperCase() : e.key;
if (e.shiftKey && !KNOWN_IGNORE_SHIFT_MODIFIER_KEYS.has(key)) {
key = "Shift+" + key;
}
if (e.altKey) {
hotkey = keydown_alt_mappings[e.key];
if (hotkey) {
return hotkey;
}
return undefined;
key = "Alt+" + key;
}
if (e.ctrlKey) {
key = "Ctrl+" + key;
}
if (e.metaKey) {
assert(common.has_mac_keyboard());
key = "Cmd+" + key;
}
if (e.ctrlKey && !e.shiftKey) {
hotkey = keydown_ctrl_mappings[e.key];
if (hotkey) {
return hotkey;
}
}
const isCmdOrCtrl = common.has_mac_keyboard() ? e.metaKey : e.ctrlKey;
if (isCmdOrCtrl && !e.shiftKey) {
hotkey = keydown_cmd_or_ctrl_mappings[e.key];
if (hotkey) {
return hotkey;
}
return undefined;
} else if (e.metaKey || e.ctrlKey) {
return undefined;
}
if (e.shiftKey) {
hotkey = keydown_shift_mappings[e.key];
if (hotkey) {
return hotkey;
}
}
if (!e.shiftKey) {
hotkey = keydown_unshift_mappings[e.key];
if (hotkey) {
return hotkey;
}
}
hotkey = keydown_either_mappings[e.key];
if (hotkey) {
return hotkey;
}
return character_mappings[e.key];
return KEYDOWN_MAPPINGS[key];
}
export let processing_text = () => {

View File

@@ -195,6 +195,8 @@ run_test("mappings", () => {
assert.equal(map_down(".", false, true).name, "narrow_to_compose_target");
assert.equal(map_down("p", false, false, false, true).name, "toggle_compose_preview"); // Alt + P
assert.equal(map_down("+", false).name, "thumbs_up_emoji");
assert.equal(map_down("+", true).name, "thumbs_up_emoji");
// More negative tests.
assert.equal(map_down("Escape", true), undefined);
@@ -217,12 +219,14 @@ run_test("mappings", () => {
assert.equal(map_down("S", true, true), undefined);
assert.equal(map_down("[", true, true, false), undefined);
assert.equal(map_down("P", true, false, false, true), undefined);
assert.equal(map_down("+", false, true), undefined);
// Cmd tests for MacOS
navigator.platform = "MacIntel";
assert.equal(map_down("[", false, true, false).name, "escape");
assert.equal(map_down("[", false, false, true), undefined);
assert.equal(map_down("c", false, true, true).name, "copy_with_c");
assert.equal(map_down("c", false, false, true).name, "copy_with_c");
assert.equal(map_down("c", false, true, true), undefined);
assert.equal(map_down("c", false, true, false), undefined);
assert.equal(map_down("k", false, false, true).name, "search_with_k");
assert.equal(map_down("k", false, true, false), undefined);
@@ -232,6 +236,12 @@ run_test("mappings", () => {
assert.equal(map_down(".", false, true, false), undefined);
// Reset platform
navigator.platform = "";
// Caps Lock doesn't interfere with shortcuts.
assert.equal(map_down("A").name, "open_combined_feed");
assert.equal(map_down("A", true).name, "stream_cycle_backward");
assert.equal(map_down("C", false, true).name, "copy_with_c");
assert.equal(map_down("P", false, false, false, true).name, "toggle_compose_preview");
});
function process(s, shiftKey) {
@@ -260,7 +270,8 @@ function assert_mapping(c, module, func_name, shiftKey) {
function assert_unmapped(s) {
for (const c of s) {
assert.equal(process(c), false);
const shiftKey = /^[A-Z]$/.test(c);
assert.equal(process(c, shiftKey), false);
}
}
@@ -308,7 +319,7 @@ test_while_not_editing_text("streams", ({override}) => {
delete settings_data.user_can_create_web_public_streams;
override(overlays, "streams_open", () => true);
override(overlays, "any_active", () => true);
assert_mapping("S", stream_settings_ui, "keyboard_sub");
assert_mapping("S", stream_settings_ui, "keyboard_sub", true);
assert_mapping("V", stream_settings_ui, "view_stream", true);
assert_mapping("n", stream_settings_ui, "open_create_stream");
settings_data.user_can_create_private_streams = () => false;
@@ -323,12 +334,12 @@ test_while_not_editing_text("basic mappings", () => {
assert_mapping("w", activity_ui, "initiate_search");
assert_mapping("q", stream_list, "initiate_search");
assert_mapping("A", message_view, "stream_cycle_backward");
assert_mapping("D", message_view, "stream_cycle_forward");
assert_mapping("A", message_view, "stream_cycle_backward", true);
assert_mapping("D", message_view, "stream_cycle_forward", true);
assert_mapping("c", compose_actions, "start");
assert_mapping("x", compose_actions, "start");
assert_mapping("P", message_view, "show");
assert_mapping("P", message_view, "show", true);
assert_mapping("g", gear_menu, "toggle");
});
@@ -381,9 +392,9 @@ test_while_not_editing_text("misc", ({override}) => {
assert_mapping("r", compose_reply, "respond_to_message");
assert_mapping("R", compose_reply, "respond_to_message", true);
assert_mapping("j", navigate, "down");
assert_mapping("J", navigate, "page_down");
assert_mapping("J", navigate, "page_down", true);
assert_mapping("k", navigate, "up");
assert_mapping("K", navigate, "page_up");
assert_mapping("K", navigate, "page_up", true);
assert_mapping("u", popovers, "toggle_sender_info");
assert_mapping("i", message_actions_popover, "toggle_message_actions_menu");
assert_mapping(":", emoji_picker, "toggle_emoji_popover", true);
@@ -446,8 +457,8 @@ run_test("emoji picker", ({override}) => {
test_while_not_editing_text("G/M keys", () => {
// TODO: move
assert_mapping("G", navigate, "to_end");
assert_mapping("M", user_topics_ui, "toggle_topic_visibility_policy");
assert_mapping("G", navigate, "to_end", true);
assert_mapping("M", user_topics_ui, "toggle_topic_visibility_policy", true);
});
test_while_not_editing_text("n/p keys", () => {