diff --git a/docs/user-guide/first-steps/shortcuts.njk b/docs/user-guide/first-steps/shortcuts.njk
index e4708c9800..ab8279486a 100644
--- a/docs/user-guide/first-steps/shortcuts.njk
+++ b/docs/user-guide/first-steps/shortcuts.njk
@@ -35,7 +35,9 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
Paste cannot be customized.
Keyboard layouts
-Punctuation keys (such as -, [, / or ') are recorded by their physical position on the keyboard, so they work the same on AZERTY, QWERTZ and other layouts. Letters and numbers are recorded by the character they type. With Shift, the base key is recorded, not the shifted character.
+Letters are recorded by the character they type, so they follow your layout (on AZERTY, the A key records a). With Shift, digit keys record the digit itself (Shift0, not the shifted symbol), on every layout and in every browser.
+Punctuation keys (such as -, [, / or ') are recorded by their physical position on the keyboard, so the same key works on AZERTY, QWERTZ and other layouts — but only in browsers that expose the keyboard layout (Chromium). There, Shift with the + key records Shift+. In browsers without that support, such as Firefox or Safari, punctuation falls back to the typed character (Shift* instead): those shortcuts still work in the browser where you recorded them, but Shift combinations with punctuation may stop working if you switch browsers. If that affects you, record those shortcuts in a Chromium browser.
+Two known limitations: number keys set opacity (0–9) only where digits are typed without Shift, so AZERTY layouts need the numeric keypad for them; and \ needs AltGr on some layouts (Spanish), where it is recorded with Alt and its exact form depends on the browser.
Reset to default
When you have custom shortcuts, a Reset all button appears next to the search field. It lists every shortcut that will go back to its default before you confirm.
diff --git a/frontend/packages/mousetrap/README.md b/frontend/packages/mousetrap/README.md
new file mode 100644
index 0000000000..848c8d8bfc
--- /dev/null
+++ b/frontend/packages/mousetrap/README.md
@@ -0,0 +1,19 @@
+# @penpot/mousetrap
+
+Vendored keyboard-shortcut library (based on Mousetrap 1.6.5), migrated
+from `code`/`keyCode` to `event.key` with lowercase canonical tokens so
+shortcuts behave the same across keyboard layouts.
+
+## Tests
+
+Unit suite with the Node standard runner, no browser and no extra
+dependencies:
+
+```bash
+pnpm test # node --test "test/*.test.js"
+```
+
+The suite builds real `Mousetrap` instances over fake targets and
+synthetic events, with fake timers for sequences. It runs in the frontend
+CI through `frontend/scripts/test` and must stay deterministic (no
+network, no real time, no platform reads).
diff --git a/frontend/packages/mousetrap/index.js b/frontend/packages/mousetrap/index.js
index 4558a758b0..e3864396a4 100644
--- a/frontend/packages/mousetrap/index.js
+++ b/frontend/packages/mousetrap/index.js
@@ -21,233 +21,103 @@
*/
const globalDocument = globalThis?.document;
/**
- * mapping of special keycodes to their corresponding keys
+ * Canonical key names, all lowercase.
*
- * everything in this dictionary cannot use keypress events
- * so it has to be here to map to the correct keycodes for
- * keyup/keydown events
+ * `event.key` (final character for the active layout) is the source of
+ * truth. Single printable characters are lowercased, `" "` becomes
+ * `"space"`, and named keys map through this table.modifiers pressed
+ * alone (`Shift`, `Control`, ...) normalize to `shift`, `ctrl`, `alt`,
+ * `meta` so bind-time and event-time tokens always match.
*
* @type {Object}
*/
-var _MAP = {
- 8: "backspace",
- 9: "tab",
- 13: "enter",
- 16: "shift",
- 17: "ctrl",
- 18: "alt",
- 20: "capslock",
- 27: "esc",
- 32: "space",
- 33: "pageup",
- 34: "pagedown",
- 35: "end",
- 36: "home",
- 37: "left",
- 38: "up",
- 39: "right",
- 40: "down",
- 45: "ins",
- 46: "del",
- 91: "meta",
- 93: "meta",
- 224: "meta",
- 219: "219",
+const _KEY_NAMES = {
+ " ": "space",
+ Spacebar: "space",
+ ArrowUp: "up",
+ Up: "up",
+ ArrowDown: "down",
+ Down: "down",
+ ArrowLeft: "left",
+ Left: "left",
+ ArrowRight: "right",
+ Right: "right",
+ Escape: "escape",
+ Esc: "escape",
+ Enter: "enter",
+ Backspace: "backspace",
+ Delete: "del",
+ Del: "del",
+ Insert: "ins",
+ Ins: "ins",
+ Tab: "tab",
+ Home: "home",
+ End: "end",
+ PageUp: "pageup",
+ PageDown: "pagedown",
+ CapsLock: "capslock",
+ Shift: "shift",
+ Control: "ctrl",
+ Alt: "alt",
+ Meta: "meta",
};
/**
- * mapping for special characters so they can support
- *
- * this dictionary is only used incase you want to bind a
- * keyup or keydown event to one of these keys
+ * Aliases accepted in bind strings, all resolving to lowercase canonical
+ * tokens. `mod` follows the platform (Meta on Apple, Ctrl elsewhere).
*
* @type {Object}
*/
-var _KEYCODE_MAP = {
- 106: "*",
- 107: "+",
- 109: "-",
- 110: ".",
- 111: "/",
- 186: ";",
- 187: "=",
- 188: ",",
- 189: "-",
- 190: ".",
- 191: "/",
- 192: "`",
- 219: "[",
- 220: "\\",
- 221: "]",
- 222: "'",
-};
+const globalNavigator = globalThis.navigator;
-/**
- * this is a mapping of keys that require shift on a US keypad
- * back to the non shift equivelents
- *
- * this is so you can use keyup events with these keys
- *
- * note that this will only work reliably on US keyboards
- *
- * @type {Object}
- */
-var _SHIFT_MAP = {
- "~": "`",
- "!": "1",
- "@": "2",
- "#": "3",
- $: "4",
- "%": "5",
- "^": "6",
- "&": "7",
- "*": "8",
- "(": "9",
- ")": "0",
- _: "-",
- "+": "=",
- ":": ";",
- '\"': "'",
- "<": ",",
- ">": ".",
- "?": "/",
- "|": "\\",
-};
-
-/**
- * this is a list of special strings you can use to map
- * to modifier keys when you specify your keyboard shortcuts
- *
- * @type {Object}
- */
-
-var globalNavigator = globalThis.navigator;
-
-var _SPECIAL_ALIASES = {
+const _SPECIAL_ALIASES = {
+ ctrl: "ctrl",
+ control: "ctrl",
+ alt: "alt",
option: "alt",
+ shift: "shift",
+ meta: "meta",
command: "meta",
- return: "enter",
- escape: "esc",
- plus: "+",
mod: /Mac|iPod|iPhone|iPad/.test(globalNavigator?.platform) ? "meta" : "ctrl",
+ return: "enter",
+ enter: "enter",
+ esc: "escape",
+ escape: "escape",
+ del: "del",
+ delete: "del",
+ ins: "ins",
+ insert: "ins",
+ space: "space",
+ plus: "+",
};
/**
- * variable to store the flipped version of _MAP from above
- * needed to check if we should use keypress or not when no action
- * is specified
+ * Physical codes treated positionally. Letters and digits resolve by
+ * glyph (`event.key`), but punctuation resolves by position (`event.code`)
+ * through the layout map, so the same physical key matches on every
+ * layout with and without `Shift`. Without a map the typed glyph applies.
*
- * @type {Object|undefined}
+ * @type {Set}
*/
-var _REVERSE_MAP;
+const _PUNCTUATION_CODES = new Set([
+ "Minus",
+ "Equal",
+ "BracketLeft",
+ "BracketRight",
+ "Backslash",
+ "Semicolon",
+ "Quote",
+ "Backquote",
+ "Comma",
+ "Period",
+ "Slash",
+]);
/**
- * loop through the f keys, f1 to f19 and add them to the map
- * programatically
+ * Resolves an event to its canonical key. Implemented as
+ * `Mousetrap#_characterFromEvent` so punctuation can consult the
+ * instance layout map (see `setLayoutMap`).
*/
-for (var i = 1; i < 20; ++i) {
- _MAP[111 + i] = "f" + i;
-}
-
-/**
- * loop through to map numbers on the numeric keypad
- */
-for (i = 0; i <= 9; ++i) {
- // This needs to use a string cause otherwise since 0 is falsey
- // mousetrap will never fire for numpad 0 pressed as part of a keydown
- // event.
- //
- // @see https://github.com/ccampbell/mousetrap/pull/258
- _MAP[i + 96] = i.toString();
-}
-
-/**
- * cross browser add event method
- *
- * @param {Element|HTMLDocument} object
- * @param {string} type
- * @param {Function} callback
- * @returns void
- */
-function _addEvent(object, type, callback) {
- if (object.addEventListener) {
- object.addEventListener(type, callback, false);
- return;
- }
-
- object.attachEvent("on" + type, callback);
-}
-
-/**
- * true for the letter (A-Z) and main-row digit (0-9) keycodes
- *
- * @param {number} which
- * @return {boolean}
- */
-function _isLetterOrDigit(which) {
- return (which >= 48 && which <= 57) || (which >= 65 && which <= 90);
-}
-
-/**
- * takes the event and returns the key character
- *
- * @param {Event} e
- * @return {string}
- */
-function _characterFromEvent(e) {
- // Numpad digits as "num0".."num9" — keeps them separate from main-row bindings across NumLock states and event types.
- if (e.code && e.code.indexOf("Numpad") === 0) {
- var suffix = e.code.substring(6);
- if (suffix.length === 1 && suffix >= "0" && suffix <= "9") {
- return "num" + suffix;
- }
- }
-
- // for keypress events we should return the character as is
- if (e.type == "keypress") {
- var character = String.fromCharCode(e.which);
-
- // if the shift key is not pressed then it is safe to assume
- // that we want the character to be lowercase. this means if
- // you accidentally have caps lock on then your key bindings
- // will continue to work
- //
- // the only side effect that might not be desired is if you
- // bind something like 'A' cause you want to trigger an
- // event when capital A is pressed caps lock will no longer
- // trigger the event. shift+a will though.
- if (!e.shiftKey) {
- character = character.toLowerCase();
- }
-
- return character;
- }
-
- // for non keypress events the special maps are needed
- if (_MAP[e.which]) {
- return _MAP[e.which];
- }
-
- if (_KEYCODE_MAP[e.which]) {
- return _KEYCODE_MAP[e.which];
- }
-
- // keydown bindings name the key, not the glyph it types under modifiers
- // ("fi" for Option+Shift+5, ")" for Shift+0)
- if (_isLetterOrDigit(e.which)) {
- return String.fromCharCode(e.which).toLowerCase();
- }
-
- // if it is not in the special map
- if (typeof e.key === "string") {
- return e.key.toLowerCase();
- }
-
- // with keydown and keyup events the character seems to always
- // come in as an uppercase character whether you are pressing shift
- // or not. we should make sure it is always lowercase for comparisons
- return String.fromCharCode(e.which).toLowerCase();
-}
/**
* checks if two arrays are equal
@@ -263,11 +133,18 @@ function _modifiersMatch(modifiers1, modifiers2) {
/**
* takes a key event and figures out what the modifiers are
*
+ * On Windows `AltGr` reports `ctrlKey + altKey` as well. When the
+ * `AltGraph` modifier is active the phantom `ctrl` is dropped so typing
+ * (e.g. `]` on Spanish keyboards) does not fire `ctrl+alt` shortcuts.
+ *
* @param {Event} e
* @returns {Array}
*/
function _eventModifiers(e) {
- var modifiers = [];
+ const modifiers = [];
+ const altGraph =
+ typeof e.getModifierState === "function" &&
+ e.getModifierState("AltGraph");
if (e.shiftKey) {
modifiers.push("shift");
@@ -277,7 +154,7 @@ function _eventModifiers(e) {
modifiers.push("alt");
}
- if (e.ctrlKey) {
+ if (e.ctrlKey && !altGraph) {
modifiers.push("ctrl");
}
@@ -289,92 +166,15 @@ function _eventModifiers(e) {
}
/**
- * prevents default for this event
+ * determines if the key is a modifier key or not
*
- * @param {Event} e
- * @returns void
- */
-function _preventDefault(e) {
- if (e.preventDefault) {
- e.preventDefault();
- return;
- }
-
- e.returnValue = false;
-}
-
-/**
- * stops propogation for this event
- *
- * @param {Event} e
- * @returns void
- */
-function _stopPropagation(e) {
- if (e.stopPropagation) {
- e.stopPropagation();
- return;
- }
-
- e.cancelBubble = true;
-}
-
-/**
- * determines if the keycode specified is a modifier key or not
- *
- * @param {string} key
+ * @param {string} key canonical lowercase key
* @returns {boolean}
*/
function _isModifier(key) {
return key == "shift" || key == "ctrl" || key == "alt" || key == "meta";
}
-/**
- * reverses the map lookup so that we can look for specific keys
- * to see what can and can't use keypress
- *
- * @return {Object}
- */
-function _getReverseMap() {
- if (!_REVERSE_MAP) {
- _REVERSE_MAP = {};
- for (var key in _MAP) {
- // pull out the numeric keypad from here cause keypress should
- // be able to detect the keys from the character
- if (key > 95 && key < 112) {
- continue;
- }
-
- if (_MAP.hasOwnProperty(key)) {
- _REVERSE_MAP[_MAP[key]] = key;
- }
- }
- }
- return _REVERSE_MAP;
-}
-
-/**
- * picks the best action based on the key combination
- *
- * @param {string} key - character for key
- * @param {Array} modifiers
- * @param {string=} action passed in
- */
-function _pickBestAction(key, modifiers, action) {
- // if no action was picked in we should try to pick the one
- // that we think would work best for this key
- if (!action) {
- action = _getReverseMap()[key] ? "keydown" : "keypress";
- }
-
- // modifier keys don't work as expected with keypress,
- // switch to keydown
- if (action == "keypress" && modifiers.length) {
- action = "keydown";
- }
-
- return action;
-}
-
/**
* Converts from a string key combination to an array
*
@@ -398,29 +198,24 @@ function _keysFromString(combination) {
* @returns {Object}
*/
function _getKeyInfo(combination, action) {
- var keys;
- var key;
- var i;
- var modifiers = [];
+ let keys;
+ let key;
+ const modifiers = [];
// take the keys from this pattern and figure out what the actual
// pattern is all about
keys = _keysFromString(combination);
- for (i = 0; i < keys.length; ++i) {
+ for (let i = 0; i < keys.length; ++i) {
key = keys[i];
- // normalize key names
+ // normalize key names to lowercase canonical tokens
if (_SPECIAL_ALIASES[key]) {
key = _SPECIAL_ALIASES[key];
- }
-
- // if this is not a keypress event then we should
- // be smart about using shift keys
- // this will only work for US keyboards however
- if (action && action != "keypress" && _SHIFT_MAP[key]) {
- key = _SHIFT_MAP[key];
- modifiers.push("shift");
+ } else if (key.length === 1) {
+ key = key.toLowerCase();
+ } else {
+ key = (_KEY_NAMES[key] || key).toLowerCase();
}
// if this key is a modifier then add it to the list of modifiers
@@ -429,9 +224,10 @@ function _getKeyInfo(combination, action) {
}
}
- // depending on what the key combination is
- // we will try to pick the best event for it
- action = _pickBestAction(key, modifiers, action);
+ // everything runs on keydown unless the caller asked for keyup
+ if (!action) {
+ action = "keydown";
+ }
return {
key: key,
@@ -440,240 +236,297 @@ function _getKeyInfo(combination, action) {
};
}
-function _belongsTo(element, ancestor) {
- if (element === null || element === globalDocument) {
- return false;
- }
+class Mousetrap {
+ constructor(targetElement = globalDocument) {
+ /**
+ * a list of all the callbacks setup via Mousetrap.bind()
+ *
+ * @type {Object}
+ */
+ this._callbacks = {};
- if (element === ancestor) {
- return true;
- }
+ /**
+ * direct map of string combinations to callbacks used for trigger()
+ *
+ * @type {Object}
+ */
+ this._directMap = {};
- return _belongsTo(element.parentNode, ancestor);
-}
+ /**
+ * keeps track of what level each sequence is at since multiple
+ * sequences can start out with the same sequence
+ *
+ * @type {Object}
+ */
+ this._sequenceLevels = {};
-function Mousetrap(targetElement) {
- var self = this;
+ /**
+ * variable to store the setTimeout call
+ *
+ * @type {null|number}
+ */
+ this._resetTimer = undefined;
- targetElement = targetElement || globalDocument;
+ /**
+ * temporary state where we will ignore the next keyup
+ *
+ * @type {boolean|string}
+ */
+ this._ignoreNextKeyup = false;
- /**
- * element to attach key events to
- *
- * @type {Element}
- */
- self.target = targetElement;
+ /**
+ * are we currently inside of a sequence?
+ * type of action ("keyup" or "keydown") or false
+ *
+ * @type {boolean|string}
+ */
+ this._nextExpectedAction = false;
- /**
- * a list of all the callbacks setup via Mousetrap.bind()
- *
- * @type {Object}
- */
- self._callbacks = {};
+ /**
+ * layout map for positional punctuation resolution, as returned by
+ * `navigator.keyboard.getLayoutMap()` (an object with `.get(code)`).
+ * `null` means glyph fallback. Set via `setLayoutMap`.
+ *
+ * @type {Object|null}
+ */
+ this._layoutMap = null;
- /**
- * direct map of string combinations to callbacks used for trigger()
- *
- * @type {Object}
- */
- self._directMap = {};
-
- /**
- * keeps track of what level each sequence is at since multiple
- * sequences can start out with the same sequence
- *
- * @type {Object}
- */
- var _sequenceLevels = {};
-
- /**
- * variable to store the setTimeout call
- *
- * @type {null|number}
- */
- var _resetTimer;
-
- /**
- * temporary state where we will ignore the next keyup
- *
- * @type {boolean|string}
- */
- var _ignoreNextKeyup = false;
-
- /**
- * temporary state where we will ignore the next keypress
- *
- * @type {boolean}
- */
- var _ignoreNextKeypress = false;
-
- /**
- * are we currently inside of a sequence?
- * type of action ("keyup" or "keydown" or "keypress") or false
- *
- * @type {boolean|string}
- */
- var _nextExpectedAction = false;
-
- /**
- * resets all sequence counters except for the ones passed in
- *
- * @param {Object} doNotReset
- * @returns void
- */
- function _resetSequences(doNotReset) {
- doNotReset = doNotReset || {};
-
- var activeSequences = false,
- key;
-
- for (key in _sequenceLevels) {
- if (doNotReset[key]) {
- activeSequences = true;
- continue;
- }
- _sequenceLevels[key] = 0;
- }
-
- if (!activeSequences) {
- _nextExpectedAction = false;
+ if (targetElement) {
+ targetElement.addEventListener("keydown", (e) => this._handleKeyEvent(e));
+ targetElement.addEventListener("keyup", (e) => this._handleKeyEvent(e));
}
}
/**
- * finds all callbacks that match based on the keycode, modifiers,
- * and action
+ * binds an event to mousetrap
*
- * @param {string} character
- * @param {Array} modifiers
- * @param {Event|Object} e
- * @param {string=} sequenceName - name of the sequence we are looking for
- * @param {string=} combination
- * @param {number=} level
- * @returns {Array}
- */
- function _getMatches(
- character,
- modifiers,
- e,
- sequenceName,
- combination,
- level,
- ) {
- var i;
- var callback;
- var matches = [];
- var action = e.type;
-
- // if there are no events related to this keycode
- if (!self._callbacks[character]) {
- return [];
- }
-
- // if a modifier key is coming up on its own we should allow it
- if (action == "keyup" && _isModifier(character)) {
- modifiers = [character];
- }
-
- // loop through all callbacks for the key that was pressed
- // and see if any of them match
- for (i = 0; i < self._callbacks[character].length; ++i) {
- callback = self._callbacks[character][i];
-
- // if a sequence name is not specified, but this is a sequence at
- // the wrong level then move onto the next match
- if (
- !sequenceName &&
- callback.seq &&
- _sequenceLevels[callback.seq] != callback.level
- ) {
- continue;
- }
-
- // if the action we are looking for doesn't match the action we got
- // then we should keep going
- if (action != callback.action) {
- continue;
- }
-
- // if this is a keypress event and the meta key and control key
- // are not pressed that means that we need to only look at the
- // character, otherwise check the modifiers as well
- //
- // chrome will not fire a keypress if meta or control is down
- // safari will fire a keypress if meta or meta+shift is down
- // firefox will fire a keypress if meta or control is down
- if (
- (action == "keypress" && !e.metaKey && !e.ctrlKey) ||
- _modifiersMatch(modifiers, callback.modifiers)
- ) {
- // when you bind a combination or sequence a second time it
- // should overwrite the first one. if a sequenceName or
- // combination is specified in this call it does just that
- //
- // @todo make deleting its own method?
- var deleteCombo = !sequenceName && callback.combo == combination;
- var deleteSequence =
- sequenceName &&
- callback.seq == sequenceName &&
- callback.level == level;
- if (deleteCombo || deleteSequence) {
- self._callbacks[character].splice(i, 1);
- }
-
- matches.push(callback);
- }
- }
-
- return matches;
- }
-
- /**
- * actually calls the callback function
+ * can be a single key, a combination of keys separated with +,
+ * an array of keys, or a sequence of keys separated by spaces
*
- * if your callback function returns false this will use the jquery
- * convention - prevent default and stop propogation on the event
+ * be sure to list the modifier keys first to make sure that the
+ * correct key ends up getting bound (the last key in the pattern)
*
+ * @param {string|Array} keys
* @param {Function} callback
- * @param {Event} e
+ * @param {string=} action - 'keydown' or 'keyup' (defaults to 'keydown')
* @returns void
*/
- function _fireCallback(callback, e, combo, sequence) {
- // if this event should not happen stop here
- if (self.stopCallback(e, e.target || e.srcElement, combo, sequence)) {
- return;
+ bind(keys, callback, action, overwrite) {
+ keys = keys instanceof Array ? keys : [keys];
+ this._bindMultiple(keys, callback, action, overwrite);
+ return this;
+ }
+
+ /**
+ * unbinds an event to mousetrap
+ *
+ * the unbinding sets the callback function of the specified key combo
+ * to an empty function and deletes the corresponding key in the
+ * _directMap dict.
+ *
+ * TODO: actually remove this from the _callbacks dictionary instead
+ * of binding an empty function
+ *
+ * the keycombo+action has to be exactly the same as
+ * it was defined in the bind method
+ *
+ * @param {string|Array} keys
+ * @param {string} action
+ * @returns void
+ */
+ unbind(keys, action) {
+ return this.bind(keys, function () {}, action);
+ }
+
+ /**
+ * triggers an event that has already been bound
+ *
+ * @param {string} keys
+ * @param {string=} action
+ * @returns void
+ */
+ trigger(keys, action) {
+ if (this._directMap[keys + ":" + action]) {
+ this._directMap[keys + ":" + action]({}, keys);
+ }
+ return this;
+ }
+
+ /**
+ * resets the library back to its initial state. this is useful
+ * if you want to clear out the current keyboard shortcuts and bind
+ * new ones - for example if you switch to another page
+ *
+ * @returns void
+ */
+ reset() {
+ this._callbacks = {};
+ this._directMap = {};
+ return this;
+ }
+
+ /**
+ * sets the keyboard layout map used for positional punctuation
+ * resolution (the same map the shortcut recorder uses).
+ *
+ * Pass the `KeyboardLayoutMap` from
+ * `navigator.keyboard.getLayoutMap()`, or `null` to go back to the
+ * glyph fallback. Read live at dispatch time, so call it whenever
+ * the map resolves without rebinding.
+ *
+ * @param {Object|null} map - object with `.get(code)`, or null
+ * @returns void
+ */
+ setLayoutMap(map) {
+ this._layoutMap = map;
+ return this;
+ }
+
+ /**
+ * takes the event and returns the canonical key
+ *
+ * Letters, digits and named keys resolve by glyph: single characters
+ * lowercase so `Shift+Z` matches a `ctrl+shift+z` bind, `" "` maps to
+ * `space`, and named keys map through `_KEY_NAMES`. The exception is a
+ * digit row key held with `Shift`, which resolves to the digit itself
+ * (`Digit0` types ")" on US layouts but "0" on AZERTY, so `shift+0`
+ * must mean the physical digit key everywhere). Punctuation
+ * resolves positionally via the layout map (see `setLayoutMap`),
+ * falling back to the glyph when no map is set. Numpad digits stay
+ * positional (`num0`..`num9`) so they keep their own bindings.
+ *
+ * @param {Event} e
+ * @return {string|null}
+ */
+ _characterFromEvent(e) {
+ // Numpad digits as "num0".."num9" — keeps them separate from main-row bindings across NumLock states and event types.
+ if (e.code && e.code.indexOf("Numpad") === 0) {
+ const suffix = e.code.substring(6);
+ if (suffix.length === 1 && suffix >= "0" && suffix <= "9") {
+ return "num" + suffix;
+ }
}
- if (callback(e, combo) === false) {
- _preventDefault(e);
- _stopPropagation(e);
+ // Punctuation is positional: with a layout map the primary
+ // (unshifted) character of the physical key wins, so `Shift+*` on a
+ // Spanish layout (code `BracketRight`) resolves to `+` like the
+ // recorder stores. Without a map the typed glyph applies.
+ if (e.code && _PUNCTUATION_CODES.has(e.code) && this._layoutMap) {
+ let mapped = null;
+ try {
+ mapped = this._layoutMap.get(e.code);
+ } catch {
+ mapped = null;
+ }
+ if (typeof mapped === "string" && mapped !== "") {
+ return mapped.toLowerCase();
+ }
}
+
+ const key = e.key;
+ if (typeof key !== "string" || key === "") {
+ return null;
+ }
+
+ // Shifted digits resolve to the digit itself: with `Shift` held,
+ // `Digit0` types ")" on US layouts but "0" on AZERTY, so the glyph
+ // can't be the token. `shift+0` means the physical digit key
+ // everywhere, like `shift+z` means `z` regardless of `Shift`.
+ if (e.shiftKey && e.code && /^Digit[0-9]$/.test(e.code) && key.length === 1) {
+ return e.code.charAt(5);
+ }
+
+ if (_KEY_NAMES[key]) {
+ return _KEY_NAMES[key];
+ }
+
+ if (key.length === 1) {
+ return key.toLowerCase();
+ }
+
+ if (/^F\d{1,2}$/.test(key)) {
+ return key.toLowerCase();
+ }
+
+ if (key === "Dead") {
+ return null;
+ }
+
+ return key.toLowerCase();
+ }
+
+ /**
+ * should we stop this event before firing off callbacks
+ *
+ * @param {Event} e
+ * @param {Element} element
+ * @return {boolean}
+ */
+ stopCallback(e, element, combo) {
+ // if the element has the data attribute "mousetrap-dont-stop" then no need
+ // to stop. It should be used like ...
+ // or :div {:data-mousetrap-dont-stop true}
+ if ("mousetrapDontStop" in element.dataset) {
+ return false;
+ }
+
+ if ((" " + element.className + " ").indexOf(" mousetrap ") > -1) {
+ return false;
+ }
+
+ // Keyup events need to be dispatched always. Otherwise some events can be stuck
+ if (e.type == "keyup") {
+ return false;
+ }
+
+ if ("composedPath" in e && typeof e.composedPath === "function") {
+ // For open shadow trees, update `element` so that the following check works.
+ const initialEventTarget = e.composedPath()[0];
+ if (initialEventTarget !== e.target) {
+ element = initialEventTarget;
+ }
+ }
+
+ // stop for input, select, textarea and button
+ const shouldStop =
+ element.tagName == "INPUT" ||
+ element.tagName == "SELECT" ||
+ element.tagName == "TEXTAREA" ||
+ (element.tagName == "BUTTON" &&
+ typeof combo === "string" &&
+ combo.includes("tab")) ||
+ (element.contentEditable &&
+ (element.contentEditable == "true" ||
+ element.contentEditable === "plaintext-only"));
+
+ return shouldStop;
}
/**
* handles a character key event
*
+ * exposed publicly so it can be overwritten by extensions
+ *
* @param {string} character
* @param {Array} modifiers
* @param {Event} e
* @returns void
*/
- self._handleKey = function (character, modifiers, e) {
- var callbacks = _getMatches(character, modifiers, e);
- var i;
- var doNotReset = {};
- var maxLevel = 0;
- var processedSequenceCallback = false;
+ handleKey(character, modifiers, e) {
+ const callbacks = this._getMatches(character, modifiers, e);
+ const doNotReset = {};
+ let maxLevel = 0;
+ let processedSequenceCallback = false;
// Calculate the maxLevel for sequences so we can only execute the longest callback sequence
- for (i = 0; i < callbacks.length; ++i) {
+ for (let i = 0; i < callbacks.length; ++i) {
if (callbacks[i].seq) {
maxLevel = Math.max(maxLevel, callbacks[i].level);
}
}
// loop through matching callbacks for this key event
- for (i = 0; i < callbacks.length; ++i) {
+ for (let i = 0; i < callbacks.length; ++i) {
// fire for all sequence callbacks
// this is because if for example you have multiple sequences
// bound such as "g i" and "g t" they both need to fire the
@@ -696,7 +549,7 @@ function Mousetrap(targetElement) {
// keep a list of which sequences were matches for later
doNotReset[callbacks[i].seq] = 1;
- _fireCallback(
+ this._fireCallback(
callbacks[i].callback,
e,
callbacks[i].combo,
@@ -708,12 +561,12 @@ function Mousetrap(targetElement) {
// if there were no sequence matches but we are still here
// that means this is a regular match so we should fire that
if (!processedSequenceCallback) {
- _fireCallback(callbacks[i].callback, e, callbacks[i].combo);
+ this._fireCallback(callbacks[i].callback, e, callbacks[i].combo);
}
}
// if the key you pressed matches the type of sequence without
- // being a modifier (ie "keyup" or "keypress") then we should
+ // being a modifier (ie "keyup" or "keydown") then we should
// reset all sequences that were not matched by this event
//
// this is so, for example, if you have the sequence "h a t" and you
@@ -723,136 +576,29 @@ function Mousetrap(targetElement) {
// modifier keys are ignored because you can have a sequence
// that contains modifiers such as "enter ctrl+space" and in most
// cases the modifier key will be pressed before the next key
- //
- // also if you have a sequence such as "ctrl+b a" then pressing the
- // "b" key will trigger a "keypress" and a "keydown"
- //
- // the "keydown" is expected when there is a modifier, but the
- // "keypress" ends up matching the _nextExpectedAction since it occurs
- // after and that causes the sequence to reset
- //
- // we ignore keypresses in a sequence that directly follow a keydown
- // for the same character
- var ignoreThisKeypress = e.type == "keypress" && _ignoreNextKeypress;
- if (
- e.type == _nextExpectedAction &&
- !_isModifier(character) &&
- !ignoreThisKeypress
- ) {
- _resetSequences(doNotReset);
+ if (e.type == this._nextExpectedAction && !_isModifier(character)) {
+ this._resetSequences(doNotReset);
}
-
- _ignoreNextKeypress = processedSequenceCallback && e.type == "keydown";
- };
-
- /**
- * handles a keydown event
- *
- * @param {Event} e
- * @returns void
- */
- function _handleKeyEvent(e) {
- // normalize e.which for key events
- // @see http://stackoverflow.com/questions/4285627/javascript-keycode-vs-charcode-utter-confusion
- if (typeof e.which !== "number") {
- e.which = e.keyCode;
- }
-
- var character = _characterFromEvent(e);
-
- // no character found then stop
- if (!character) {
- return;
- }
-
- // need to use === for the character check because the character can be 0
- if (e.type == "keyup" && _ignoreNextKeyup === character) {
- _ignoreNextKeyup = false;
- return;
- }
-
- self.handleKey(character, _eventModifiers(e), e);
}
/**
- * called to set a 1 second timeout on the specified sequence
+ * binds multiple combinations to the same callback
*
- * this is so after each key press in the sequence you have 1 second
- * to press the next key before you have to start over
- *
- * @returns void
- */
- function _resetSequenceTimer() {
- clearTimeout(_resetTimer);
- _resetTimer = setTimeout(_resetSequences, 1000);
- }
-
- /**
- * binds a key sequence to an event
- *
- * @param {string} combo - combo specified in bind call
- * @param {Array} keys
+ * @param {Array} combinations
* @param {Function} callback
- * @param {string=} action
+ * @param {string|undefined} action
* @returns void
*/
- function _bindSequence(combo, keys, callback, action) {
- // start off by adding a sequence level record for this combination
- // and setting the level to 0
- _sequenceLevels[combo] = 0;
-
- /**
- * callback to increase the sequence level for this sequence and reset
- * all other sequences that were active
- *
- * @param {string} nextAction
- * @returns {Function}
- */
- function _increaseSequence(nextAction) {
- return function () {
- _nextExpectedAction = nextAction;
- ++_sequenceLevels[combo];
- _resetSequenceTimer();
- };
- }
-
- /**
- * wraps the specified callback inside of another function in order
- * to reset all sequence counters as soon as this sequence is done
- *
- * @param {Event} e
- * @returns void
- */
- function _callbackAndReset(e) {
- _fireCallback(callback, e, combo);
-
- // we should ignore the next key up if the action is key down
- // or keypress. this is so if you finish a sequence and
- // release the key the final key will not trigger a keyup
- if (action !== "keyup") {
- _ignoreNextKeyup = _characterFromEvent(e);
- }
-
- // weird race condition if a sequence ends with the key
- // another sequence begins with
- setTimeout(_resetSequences, 10);
- }
-
- // loop through keys one at a time and bind the appropriate callback
- // function. for any key leading up to the final one it should
- // increase the sequence. after the final, it should reset all sequences
- //
- // if an action is specified in the original bind call then that will
- // be used throughout. otherwise we will pass the action that the
- // next key in the sequence should match. this allows a sequence
- // to mix and match keypress and keydown events depending on which
- // ones are better suited to the key provided
- for (var i = 0; i < keys.length; ++i) {
- var isFinal = i + 1 === keys.length;
- var wrappedCallback = isFinal
- ? _callbackAndReset
- : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action);
- _bindSingle(keys[i], wrappedCallback, action, combo, i);
+ _bindMultiple(combinations, callback, action, overwrite) {
+ for (let i = 0; i < combinations.length; ++i) {
+ this._bindSingle(
+ combinations[i],
+ callback,
+ action,
+ undefined,
+ undefined,
+ overwrite,
+ );
}
}
@@ -866,39 +612,31 @@ function Mousetrap(targetElement) {
* @param {number=} level - what part of the sequence the command is
* @returns void
*/
- function _bindSingle(
- combination,
- callback,
- action,
- sequenceName,
- level,
- overwrite,
- ) {
+ _bindSingle(combination, callback, action, sequenceName, level, overwrite) {
// store a direct mapped reference for use with Mousetrap.trigger
- self._directMap[combination + ":" + action] = callback;
+ this._directMap[combination + ":" + action] = callback;
// make sure multiple spaces in a row become a single space
combination = combination.replace(/\s+/g, " ");
- var sequence = combination.split(" ");
- var info;
+ const sequence = combination.split(" ");
// if this pattern is a sequence of keys then run through this method
// to reprocess each pattern one key at a time
if (sequence.length > 1) {
- _bindSequence(combination, sequence, callback, action);
+ this._bindSequence(combination, sequence, callback, action);
return;
}
- info = _getKeyInfo(combination, action);
+ const info = _getKeyInfo(combination, action);
// make sure to initialize array if this is the first time
// a callback is added for this key
- self._callbacks[info.key] = self._callbacks[info.key] || [];
+ this._callbacks[info.key] = this._callbacks[info.key] || [];
// remove an existing match if there is one
if (overwrite) {
- _getMatches(
+ this._getMatches(
info.key,
info.modifiers,
{ type: info.action },
@@ -914,7 +652,7 @@ function Mousetrap(targetElement) {
//
// this is important because the way these are processed expects
// the sequence ones to come first
- self._callbacks[info.key][sequenceName ? "unshift" : "push"]({
+ this._callbacks[info.key][sequenceName ? "unshift" : "push"]({
callback: callback,
modifiers: info.modifiers,
action: info.action,
@@ -925,168 +663,218 @@ function Mousetrap(targetElement) {
}
/**
- * binds multiple combinations to the same callback
+ * binds a key sequence to an event
*
- * @param {Array} combinations
+ * @param {string} combo - combo specified in bind call
+ * @param {Array} keys
* @param {Function} callback
- * @param {string|undefined} action
+ * @param {string=} action
* @returns void
*/
- self._bindMultiple = function (combinations, callback, action, overwrite) {
- for (var i = 0; i < combinations.length; ++i) {
- _bindSingle(
- combinations[i],
- callback,
- action,
- undefined,
- undefined,
- overwrite,
- );
- }
- };
+ _bindSequence(combo, keys, callback, action) {
+ // start off by adding a sequence level record for this combination
+ // and setting the level to 0
+ this._sequenceLevels[combo] = 0;
- if (targetElement) {
- _addEvent(targetElement, "keypress", _handleKeyEvent);
- _addEvent(targetElement, "keydown", _handleKeyEvent);
- _addEvent(targetElement, "keyup", _handleKeyEvent);
- }
-}
+ /**
+ * callback to increase the sequence level for this sequence and reset
+ * all other sequences that were active
+ *
+ * @param {string} nextAction
+ * @returns {Function}
+ */
+ const _increaseSequence = (nextAction) => () => {
+ this._nextExpectedAction = nextAction;
+ ++this._sequenceLevels[combo];
+ this._resetSequenceTimer();
+ };
-/**
- * binds an event to mousetrap
- *
- * can be a single key, a combination of keys separated with +,
- * an array of keys, or a sequence of keys separated by spaces
- *
- * be sure to list the modifier keys first to make sure that the
- * correct key ends up getting bound (the last key in the pattern)
- *
- * @param {string|Array} keys
- * @param {Function} callback
- * @param {string=} action - 'keypress', 'keydown', or 'keyup'
- * @returns void
- */
-Mousetrap.prototype.bind = function (keys, callback, action, overwrite) {
- var self = this;
- keys = keys instanceof Array ? keys : [keys];
- self._bindMultiple.call(self, keys, callback, action, overwrite);
- return self;
-};
+ /**
+ * wraps the specified callback inside of another function in order
+ * to reset all sequence counters as soon as this sequence is done
+ *
+ * @param {Event} e
+ * @returns void
+ */
+ const _callbackAndReset = (e) => {
+ this._fireCallback(callback, e, combo);
-/**
- * unbinds an event to mousetrap
- *
- * the unbinding sets the callback function of the specified key combo
- * to an empty function and deletes the corresponding key in the
- * _directMap dict.
- *
- * TODO: actually remove this from the _callbacks dictionary instead
- * of binding an empty function
- *
- * the keycombo+action has to be exactly the same as
- * it was defined in the bind method
- *
- * @param {string|Array} keys
- * @param {string} action
- * @returns void
- */
-Mousetrap.prototype.unbind = function (keys, action) {
- var self = this;
- return self.bind.call(self, keys, function () {}, action);
-};
+ // we should ignore the next key up if the action is keydown.
+ // this is so if you finish a sequence and
+ // release the key the final key will not trigger a keyup
+ if (action !== "keyup") {
+ this._ignoreNextKeyup = this._characterFromEvent(e);
+ }
-/**
- * triggers an event that has already been bound
- *
- * @param {string} keys
- * @param {string=} action
- * @returns void
- */
-Mousetrap.prototype.trigger = function (keys, action) {
- var self = this;
- if (self._directMap[keys + ":" + action]) {
- self._directMap[keys + ":" + action]({}, keys);
- }
- return self;
-};
+ // weird race condition if a sequence ends with the key
+ // another sequence begins with
+ setTimeout(() => this._resetSequences(), 10);
+ };
-/**
- * resets the library back to its initial state. this is useful
- * if you want to clear out the current keyboard shortcuts and bind
- * new ones - for example if you switch to another page
- *
- * @returns void
- */
-Mousetrap.prototype.reset = function () {
- var self = this;
- self._callbacks = {};
- self._directMap = {};
- return self;
-};
-
-/**
- * should we stop this event before firing off callbacks
- *
- * @param {Event} e
- * @param {Element} element
- * @return {boolean}
- */
-Mousetrap.prototype.stopCallback = function (e, element, combo) {
- // if the element has the data attribute "mousetrap-dont-stop" then no need
- // to stop. It should be used like ...
- // or :div {:data-mousetrap-dont-stop true}
- if ("mousetrapDontStop" in element.dataset) {
- return false;
- }
-
- if ((" " + element.className + " ").indexOf(" mousetrap ") > -1) {
- return false;
- }
-
- // Keyup events need to be dispatched always. Otherwise some events can be stuck
- if (e.type == "keyup") {
- return false;
- }
-
- if ("composedPath" in e && typeof e.composedPath === "function") {
- // For open shadow trees, update `element` so that the following check works.
- const initialEventTarget = e.composedPath()[0];
- if (initialEventTarget !== e.target) {
- element = initialEventTarget;
+ // loop through keys one at a time and bind the appropriate callback
+ // function. for any key leading up to the final one it should
+ // increase the sequence. after the final, it should reset all sequences
+ //
+ // if an action is specified in the original bind call then that will
+ // be used throughout. otherwise we will pass the action that the
+ // next key in the sequence should match.
+ for (let i = 0; i < keys.length; ++i) {
+ const isFinal = i + 1 === keys.length;
+ const wrappedCallback = isFinal
+ ? _callbackAndReset
+ : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action);
+ this._bindSingle(keys[i], wrappedCallback, action, combo, i);
}
}
- // stop for input, select, textarea and button
- const shouldStop =
- element.tagName == "INPUT" ||
- element.tagName == "SELECT" ||
- element.tagName == "TEXTAREA" ||
- (element.tagName == "BUTTON" && combo.includes("tab")) ||
- (element.contentEditable &&
- (element.contentEditable == "true" ||
- element.contentEditable === "plaintext-only"));
+ /**
+ * resets all sequence counters except for the ones passed in
+ *
+ * @param {Object} doNotReset
+ * @returns void
+ */
+ _resetSequences(doNotReset) {
+ doNotReset = doNotReset || {};
- return shouldStop;
-};
+ let activeSequences = false;
-/**
- * exposes _handleKey publicly so it can be overwritten by extensions
- */
-Mousetrap.prototype.handleKey = function () {
- var self = this;
- return self._handleKey.apply(self, arguments);
-};
+ for (const key in this._sequenceLevels) {
+ if (doNotReset[key]) {
+ activeSequences = true;
+ continue;
+ }
+ this._sequenceLevels[key] = 0;
+ }
-/**
- * allow custom key mappings
- */
-export function addKeycodes(object) {
- for (var key in object) {
- if (object.hasOwnProperty(key)) {
- _MAP[key] = object[key];
+ if (!activeSequences) {
+ this._nextExpectedAction = false;
}
}
- _REVERSE_MAP = null;
+
+ /**
+ * called to set a 1 second timeout on the specified sequence
+ *
+ * this is so after each key press in the sequence you have 1 second
+ * to press the next key before you have to start over
+ *
+ * @returns void
+ */
+ _resetSequenceTimer() {
+ clearTimeout(this._resetTimer);
+ this._resetTimer = setTimeout(() => this._resetSequences(), 1000);
+ }
+
+ /**
+ * finds all callbacks that match based on the key, modifiers,
+ * and action
+ *
+ * @param {string} character canonical lowercase key
+ * @param {Array} modifiers
+ * @param {Event|Object} e
+ * @param {string=} sequenceName - name of the sequence we are looking for
+ * @param {string=} combination
+ * @param {number=} level
+ * @returns {Array}
+ */
+ _getMatches(character, modifiers, e, sequenceName, combination, level) {
+ const matches = [];
+ const action = e.type;
+
+ // if there are no events related to this key
+ if (!this._callbacks[character]) {
+ return [];
+ }
+
+ // if a modifier key is coming up on its own we should allow it
+ if (action == "keyup" && _isModifier(character)) {
+ modifiers = [character];
+ }
+
+ // loop through all callbacks for the key that was pressed
+ // and see if any of them match
+ for (let i = 0; i < this._callbacks[character].length; ++i) {
+ const callback = this._callbacks[character][i];
+
+ // if a sequence name is not specified, but this is a sequence at
+ // the wrong level then move onto the next match
+ if (
+ !sequenceName &&
+ callback.seq &&
+ this._sequenceLevels[callback.seq] != callback.level
+ ) {
+ continue;
+ }
+
+ // if the action we are looking for doesn't match the action we got
+ // then we should keep going
+ if (action != callback.action) {
+ continue;
+ }
+
+ if (_modifiersMatch(modifiers, callback.modifiers)) {
+ // when you bind a combination or sequence a second time it
+ // should overwrite the first one. if a sequenceName or
+ // combination is specified in this call it does just that
+ //
+ // @todo make deleting its own method?
+ const deleteCombo = !sequenceName && callback.combo == combination;
+ const deleteSequence =
+ sequenceName &&
+ callback.seq == sequenceName &&
+ callback.level == level;
+ if (deleteCombo || deleteSequence) {
+ this._callbacks[character].splice(i, 1);
+ }
+
+ matches.push(callback);
+ }
+ }
+
+ return matches;
+ }
+
+ /**
+ * actually calls the callback function
+ *
+ * if your callback function returns false this will use the jquery
+ * convention - prevent default and stop propogation on the event
+ *
+ * @param {Function} callback
+ * @param {Event} e
+ * @returns void
+ */
+ _fireCallback(callback, e, combo, sequence) {
+ // if this event should not happen stop here
+ if (this.stopCallback(e, e.target || e.srcElement, combo, sequence)) {
+ return;
+ }
+
+ if (callback(e, combo) === false) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }
+
+ /**
+ * handles a key event
+ *
+ * @param {Event} e
+ * @returns void
+ */
+ _handleKeyEvent(e) {
+ const character = this._characterFromEvent(e);
+ // no character found then stop
+ if (!character) {
+ return;
+ }
+
+ if (e.type == "keyup" && this._ignoreNextKeyup === character) {
+ this._ignoreNextKeyup = false;
+ return;
+ }
+
+ this.handleKey(character, _eventModifiers(e), e);
+ }
}
/**
@@ -1095,6 +883,8 @@ export function addKeycodes(object) {
* This method is needed to allow the global mousetrap functions to work
* now that mousetrap is a constructor function.
*/
-var instance = new Mousetrap(globalDocument);
+const instance = new Mousetrap(globalDocument);
+
+export { Mousetrap };
export default instance;
diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json
index d5c2a52548..103baefbe2 100644
--- a/frontend/packages/mousetrap/package.json
+++ b/frontend/packages/mousetrap/package.json
@@ -4,6 +4,9 @@
"description": "Simple library for handling keyboard shortcuts",
"main": "index.js",
"type": "module",
+ "scripts": {
+ "test": "node --test \"test/*.test.js\""
+ },
"packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6",
"author": "Craig Campbell",
"license": "Apache-2.0 WITH LLVM-exception"
diff --git a/frontend/packages/mousetrap/test/bind-dispatch.test.js b/frontend/packages/mousetrap/test/bind-dispatch.test.js
new file mode 100644
index 0000000000..7512151126
--- /dev/null
+++ b/frontend/packages/mousetrap/test/bind-dispatch.test.js
@@ -0,0 +1,183 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { freshInstance, fire, makeKeyEvent, makeSpiedEvent } from "./helpers.js";
+
+describe("bind and dispatch", () => {
+ it("binds a single key", () => {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind("m", () => {
+ calls++;
+ });
+ fire(target, makeKeyEvent({ key: "m", code: "KeyM" }));
+ assert.equal(calls, 1);
+ });
+
+ it("binds an array of combos to the same callback", () => {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ const cb = () => {
+ calls++;
+ };
+ mousetrap.bind(["del", "backspace"], cb);
+ fire(target, makeKeyEvent({ key: "Delete", code: "Delete" }));
+ fire(target, makeKeyEvent({ key: "Backspace", code: "Backspace" }));
+ assert.equal(calls, 2);
+ });
+
+ it("supports the plus forms", () => {
+ const { mousetrap, target } = freshInstance();
+ let plus = 0;
+ let shiftPlus = 0;
+ mousetrap.bind("+", () => {
+ plus++;
+ });
+ mousetrap.bind("shift++", () => {
+ shiftPlus++;
+ });
+ fire(target, makeKeyEvent({ key: "+", code: "Equal" }));
+ fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true }));
+ // "*" normalizes by glyph, so "shift++" (shift + "+" key) does not fire;
+ // this pins the layout-independent behavior for the "+" physical key.
+ assert.equal(plus, 1);
+ assert.equal(shiftPlus, 0);
+ });
+
+ it("resolves bind-string aliases to canonical tokens", () => {
+ const cases = [
+ ["control+z", { key: "z", code: "KeyZ", ctrlKey: true }],
+ ["command+z", { key: "z", code: "KeyZ", metaKey: true }],
+ ["option+q", { key: "q", code: "KeyQ", altKey: true }],
+ ["mod+z", { key: "z", code: "KeyZ", ctrlKey: true }],
+ ["return", { key: "Enter", code: "Enter" }],
+ ["esc", { key: "Escape", code: "Escape" }],
+ ["plus", { key: "+", code: "Equal" }],
+ ];
+ for (const [combo, eventOver] of cases) {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind(combo, () => {
+ calls++;
+ });
+ fire(target, makeKeyEvent(eventOver));
+ assert.equal(calls, 1, combo);
+ }
+ });
+
+ it("defaults to keydown and honors explicit keyup", () => {
+ const { mousetrap, target } = freshInstance();
+ let downs = 0;
+ let ups = 0;
+ mousetrap.bind("x", () => {
+ downs++;
+ });
+ mousetrap.bind(
+ "y",
+ () => {
+ ups++;
+ },
+ "keyup",
+ );
+ fire(target, makeKeyEvent({ key: "x", code: "KeyX", type: "keyup" }));
+ fire(target, makeKeyEvent({ key: "y", code: "KeyY" }));
+ fire(target, makeKeyEvent({ key: "y", code: "KeyY", type: "keyup" }));
+ assert.equal(downs, 0);
+ assert.equal(ups, 1);
+ });
+
+ it("fires a bare modifier on keyup", () => {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind(
+ "shift",
+ () => {
+ calls++;
+ },
+ "keyup",
+ );
+ fire(target, makeKeyEvent({ key: "Shift", code: "ShiftLeft", type: "keyup", shiftKey: true }));
+ assert.equal(calls, 1);
+ });
+
+ it("calls preventDefault and stopPropagation when the callback returns false", () => {
+ const { mousetrap, target } = freshInstance();
+ mousetrap.bind("ctrl+s", () => false);
+ const { event, calls } = makeSpiedEvent({ key: "s", code: "KeyS", ctrlKey: true });
+ fire(target, event);
+ assert.equal(calls.preventDefault, 1);
+ assert.equal(calls.stopPropagation, 1);
+ });
+
+ it("reset() clears every binding", () => {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind("a", () => {
+ calls++;
+ });
+ mousetrap.reset();
+ fire(target, makeKeyEvent({ key: "a", code: "KeyA" }));
+ assert.equal(calls, 0);
+ });
+
+ it("overwrite replaces the previous callback for the combo", () => {
+ const { mousetrap, target } = freshInstance();
+ const seen = [];
+ mousetrap.bind("ctrl+z", () => {
+ seen.push("old");
+ });
+ mousetrap.bind(
+ "ctrl+z",
+ () => {
+ seen.push("new");
+ },
+ undefined,
+ true,
+ );
+ fire(target, makeKeyEvent({ key: "z", code: "KeyZ", ctrlKey: true }));
+ assert.deepEqual(seen, ["new"]);
+ });
+
+ it("unbind pins the current noop behavior (TODO: really remove)", () => {
+ // NOTE: unbind() binds an empty function on top instead of removing
+ // the original, so the original still fires. Pinned as-is; fixing it
+ // is a behavior change out of scope.
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind("a", () => {
+ calls++;
+ });
+ mousetrap.unbind("a");
+ fire(target, makeKeyEvent({ key: "a", code: "KeyA" }));
+ assert.equal(calls, 1);
+ });
+
+ it("trigger dispatches a bind registered with an explicit action", () => {
+ const { mousetrap } = freshInstance();
+ let calls = 0;
+ let seenCombo = null;
+ mousetrap.bind(
+ "ctrl+q",
+ (e, combo) => {
+ calls++;
+ seenCombo = combo;
+ },
+ "keydown",
+ );
+ mousetrap.trigger("ctrl+q", "keydown");
+ assert.equal(calls, 1);
+ assert.equal(seenCombo, "ctrl+q");
+ });
+
+ it("trigger documents the default-action quirk (TODO)", () => {
+ // NOTE: binds without an explicit action are stored under
+ // "combo:undefined", so trigger(combo, "keydown") misses them.
+ // Pinned as-is; fixing it is a behavior change out of scope.
+ const { mousetrap } = freshInstance();
+ let calls = 0;
+ mousetrap.bind("ctrl+q", () => {
+ calls++;
+ });
+ mousetrap.trigger("ctrl+q", "keydown");
+ assert.equal(calls, 0);
+ });
+});
diff --git a/frontend/packages/mousetrap/test/helpers.js b/frontend/packages/mousetrap/test/helpers.js
new file mode 100644
index 0000000000..d6061984a1
--- /dev/null
+++ b/frontend/packages/mousetrap/test/helpers.js
@@ -0,0 +1,89 @@
+import { Mousetrap } from "../index.js";
+
+/**
+ * Fake event target capturing listeners. The Mousetrap class only needs
+ * `addEventListener`, so tests run in plain Node without a DOM.
+ */
+export function makeTarget() {
+ const listeners = {};
+ const target = {
+ listeners,
+ addEventListener(type, callback) {
+ listeners[type] = listeners[type] || [];
+ listeners[type].push(callback);
+ },
+ };
+ return target;
+}
+
+/**
+ * Fake focused element for `stopCallback`. Covers every property the
+ * implementation reads: dataset, className, tagName, contentEditable.
+ */
+export function makeElement(over = {}) {
+ return {
+ dataset: {},
+ className: "",
+ tagName: "DIV",
+ contentEditable: "false",
+ ...over,
+ };
+}
+
+/**
+ * Synthetic keyboard event with sane defaults. Override any field per
+ * case (`key`, `code`, `shiftKey`, ...). The `target` defaults to a plain
+ * DIV so `stopCallback` lets the event through.
+ */
+export function makeKeyEvent(over = {}) {
+ return {
+ type: "keydown",
+ key: "a",
+ code: "KeyA",
+ shiftKey: false,
+ ctrlKey: false,
+ altKey: false,
+ metaKey: false,
+ target: makeElement(),
+ ...over,
+ };
+}
+
+/**
+ * Dispatches a synthetic event through every listener registered for
+ * its type on a fake target.
+ */
+export function fire(target, event) {
+ for (const callback of target.listeners[event.type] || []) {
+ callback(event);
+ }
+}
+
+/**
+ * Event spies recording `preventDefault`/`stopPropagation` calls for the
+ * `callback → false` convention.
+ */
+export function makeSpiedEvent(over = {}) {
+ const calls = { preventDefault: 0, stopPropagation: 0 };
+ const event = makeKeyEvent({
+ preventDefault() {
+ calls.preventDefault++;
+ },
+ stopPropagation() {
+ calls.stopPropagation++;
+ },
+ ...over,
+ });
+ return { event, calls };
+}
+
+/**
+ * Fresh isolated instance plus its fake target. Timers used by sequences
+ * are real by default; tests needing determinism enable `mock.timers`
+ * themselves and restore it afterwards.
+ */
+export function freshInstance() {
+ const target = makeTarget();
+ const mousetrap = new Mousetrap(target);
+ return { mousetrap, target };
+}
diff --git a/frontend/packages/mousetrap/test/layout-map.test.js b/frontend/packages/mousetrap/test/layout-map.test.js
new file mode 100644
index 0000000000..3cb34eb722
--- /dev/null
+++ b/frontend/packages/mousetrap/test/layout-map.test.js
@@ -0,0 +1,95 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { freshInstance, fire, makeKeyEvent } from "./helpers.js";
+
+function firedWithMap(map, bindStr, eventOver) {
+ const { mousetrap, target } = freshInstance();
+ mousetrap.setLayoutMap(map);
+ let calls = 0;
+ mousetrap.bind(bindStr, () => {
+ calls++;
+ });
+ fire(target, makeKeyEvent(eventOver));
+ return calls;
+}
+
+const spanishMap = {
+ get(code) {
+ return code === "BracketRight" ? "+" : undefined;
+ },
+};
+
+describe("layout map", () => {
+ it("resolves punctuation by position, so Shift+* fires a shift++ bind", () => {
+ const event = { key: "*", code: "BracketRight", shiftKey: true };
+ assert.equal(firedWithMap(spanishMap, "shift++", event), 1);
+ assert.equal(firedWithMap(spanishMap, "shift+*", event), 0);
+ });
+
+ it("matches the unshifted primary on the same position", () => {
+ const event = { key: "+", code: "BracketRight" };
+ assert.equal(firedWithMap(spanishMap, "+", event), 1);
+ });
+
+ it("falls back to the glyph when the map misses the code", () => {
+ const miss = { get: () => undefined };
+ const event = { key: "*", code: "BracketRight", shiftKey: true };
+ assert.equal(firedWithMap(miss, "shift+*", event), 1);
+ assert.equal(firedWithMap(miss, "shift++", event), 0);
+ });
+
+ it("falls back to the glyph when the map throws", () => {
+ const broken = {
+ get() {
+ throw new Error("no map");
+ },
+ };
+ assert.equal(
+ firedWithMap(broken, "shift+*", { key: "*", code: "BracketRight", shiftKey: true }),
+ 1,
+ );
+ });
+
+ it("setLayoutMap(null) restores the glyph fallback", () => {
+ const { mousetrap, target } = freshInstance();
+ mousetrap.setLayoutMap(spanishMap);
+ mousetrap.setLayoutMap(null);
+ let calls = 0;
+ mousetrap.bind("shift+*", () => {
+ calls++;
+ });
+ fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true }));
+ assert.equal(calls, 1);
+ });
+
+ it("maps stay per instance", () => {
+ const a = freshInstance();
+ const b = freshInstance();
+ a.mousetrap.setLayoutMap(spanishMap);
+ let callsA = 0;
+ let callsB = 0;
+ a.mousetrap.bind("shift++", () => {
+ callsA++;
+ });
+ b.mousetrap.bind("shift+*", () => {
+ callsB++;
+ });
+ const event = { key: "*", code: "BracketRight", shiftKey: true };
+ fire(a.target, makeKeyEvent(event));
+ fire(b.target, makeKeyEvent(event));
+ assert.equal(callsA, 1);
+ assert.equal(callsB, 1);
+ });
+
+ it("letters still resolve by glyph with a map set", () => {
+ const { mousetrap, target } = freshInstance();
+ mousetrap.setLayoutMap(spanishMap);
+ let calls = 0;
+ mousetrap.bind("a", () => {
+ calls++;
+ });
+ // AZERTY: the "A" label sits on the KeyQ position.
+ fire(target, makeKeyEvent({ key: "a", code: "KeyQ" }));
+ assert.equal(calls, 1);
+ });
+});
diff --git a/frontend/packages/mousetrap/test/modifiers.test.js b/frontend/packages/mousetrap/test/modifiers.test.js
new file mode 100644
index 0000000000..748b71feeb
--- /dev/null
+++ b/frontend/packages/mousetrap/test/modifiers.test.js
@@ -0,0 +1,63 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { freshInstance, fire, makeKeyEvent } from "./helpers.js";
+
+function fired(bindStr, eventOver) {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind(bindStr, () => {
+ calls++;
+ });
+ fire(target, makeKeyEvent(eventOver));
+ return calls;
+}
+
+describe("modifiers", () => {
+ it("matches each flag on its own", () => {
+ assert.equal(fired("shift+x", { key: "x", code: "KeyX", shiftKey: true }), 1);
+ assert.equal(fired("alt+x", { key: "x", code: "KeyX", altKey: true }), 1);
+ assert.equal(fired("ctrl+x", { key: "x", code: "KeyX", ctrlKey: true }), 1);
+ assert.equal(fired("meta+x", { key: "x", code: "KeyX", metaKey: true }), 1);
+ });
+
+ it("does not fire without the bound modifier", () => {
+ assert.equal(fired("shift+x", { key: "x", code: "KeyX" }), 0);
+ assert.equal(fired("ctrl+x", { key: "x", code: "KeyX", altKey: true }), 0);
+ });
+
+ it("combines modifiers regardless of bind order", () => {
+ const event = { key: "q", code: "KeyQ", ctrlKey: true, altKey: true, shiftKey: true };
+ assert.equal(fired("ctrl+alt+shift+q", event), 1);
+ assert.equal(fired("shift+ctrl+alt+q", event), 1);
+ });
+
+ it("drops the phantom ctrl while AltGraph is active", () => {
+ const altgr = {
+ key: "]",
+ code: "BracketRight",
+ ctrlKey: true,
+ altKey: true,
+ getModifierState: (mod) => mod === "AltGraph",
+ };
+ assert.equal(fired("ctrl+alt+]", altgr), 0);
+ assert.equal(fired("alt+]", altgr), 1);
+ });
+
+ it("keeps a real ctrl+alt without AltGraph", () => {
+ const event = {
+ key: "]",
+ code: "BracketRight",
+ ctrlKey: true,
+ altKey: true,
+ getModifierState: () => false,
+ };
+ assert.equal(fired("ctrl+alt+]", event), 1);
+ });
+
+ it("works when the event has no getModifierState at all", () => {
+ assert.equal(
+ fired("ctrl+x", { key: "x", code: "KeyX", ctrlKey: true, getModifierState: undefined }),
+ 1,
+ );
+ });
+});
diff --git a/frontend/packages/mousetrap/test/normalize.test.js b/frontend/packages/mousetrap/test/normalize.test.js
new file mode 100644
index 0000000000..b91b5ff40d
--- /dev/null
+++ b/frontend/packages/mousetrap/test/normalize.test.js
@@ -0,0 +1,109 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { freshInstance, fire, makeKeyEvent } from "./helpers.js";
+
+function fired(bindStr, eventOver) {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind(bindStr, () => {
+ calls++;
+ });
+ fire(target, makeKeyEvent(eventOver));
+ return calls;
+}
+
+describe("canonical key normalization", () => {
+ // [native event.key, code, canonical bind] — every _KEY_NAMES entry.
+ const table = [
+ [" ", "Space", "space"],
+ ["Spacebar", "Space", "space"],
+ ["ArrowUp", "ArrowUp", "up"],
+ ["Up", "ArrowUp", "up"],
+ ["ArrowDown", "ArrowDown", "down"],
+ ["Down", "ArrowDown", "down"],
+ ["ArrowLeft", "ArrowLeft", "left"],
+ ["Left", "ArrowLeft", "left"],
+ ["ArrowRight", "ArrowRight", "right"],
+ ["Right", "ArrowRight", "right"],
+ ["Escape", "Escape", "escape"],
+ ["Esc", "Escape", "escape"],
+ ["Enter", "Enter", "enter"],
+ ["Backspace", "Backspace", "backspace"],
+ ["Delete", "Delete", "del"],
+ ["Del", "Delete", "del"],
+ ["Insert", "Insert", "ins"],
+ ["Ins", "Insert", "ins"],
+ ["Tab", "Tab", "tab"],
+ ["Home", "Home", "home"],
+ ["End", "End", "end"],
+ ["PageUp", "PageUp", "pageup"],
+ ["PageDown", "PageDown", "pagedown"],
+ ["CapsLock", "CapsLock", "capslock"],
+ ];
+
+ for (const [nativeKey, code, canonical] of table) {
+ it(`maps ${nativeKey} to ${canonical}`, () => {
+ assert.equal(fired(canonical, { key: nativeKey, code }), 1);
+ });
+ }
+
+ it("lowercases single characters so Shift+Z matches shift+z", () => {
+ assert.equal(fired("shift+z", { key: "Z", code: "KeyZ", shiftKey: true }), 1);
+ assert.equal(fired("a", { key: "A", code: "KeyA" }), 1);
+ });
+
+ it("resolves a shifted digit row key to the digit itself", () => {
+ // US Shift+0 types ")", AZERTY Shift+0 types "0": shift+0 must mean
+ // the physical digit key on every layout (production keyCode behavior).
+ assert.equal(fired("shift+0", { key: ")", code: "Digit0", shiftKey: true }), 1);
+ assert.equal(fired("shift+1", { key: "!", code: "Digit1", shiftKey: true }), 1);
+ assert.equal(fired("shift+2", { key: "@", code: "Digit2", shiftKey: true }), 1);
+ assert.equal(fired("shift+8", { key: "*", code: "Digit8", shiftKey: true }), 1);
+ // ...so the shifted glyphs are not bindable tokens for digit codes.
+ assert.equal(fired("shift+)", { key: ")", code: "Digit0", shiftKey: true }), 0);
+ assert.equal(fired("shift+*", { key: "*", code: "Digit8", shiftKey: true }), 0);
+ // AZERTY shifted digits already type the digit: unchanged.
+ assert.equal(fired("shift+0", { key: "0", code: "Digit0", shiftKey: true }), 1);
+ // Unshifted digits are untouched.
+ assert.equal(fired("0", { key: "0", code: "Digit0" }), 1);
+ assert.equal(fired("&", { key: "&", code: "Digit1" }), 1);
+ });
+
+ it("without a layout map, punctuation resolves by glyph", () => {
+ // Spanish layout: Shift + the physical "+" key (BracketRight) types "*".
+ assert.equal(fired("shift+*", { key: "*", code: "BracketRight", shiftKey: true }), 1);
+ assert.equal(fired("shift+]", { key: "*", code: "BracketRight", shiftKey: true }), 0);
+ assert.equal(fired("+", { key: "+", code: "BracketRight" }), 1);
+ // US layout: Shift + BracketRight types "}".
+ assert.equal(fired("shift+}", { key: "}", code: "BracketRight", shiftKey: true }), 1);
+ assert.equal(fired("shift+]", { key: "}", code: "BracketRight", shiftKey: true }), 0);
+ assert.equal(fired("?", { key: "?", code: "Slash" }), 1);
+ });
+
+ it("lowercases function keys", () => {
+ assert.equal(fired("f1", { key: "F1", code: "F1" }), 1);
+ assert.equal(fired("f13", { key: "F13", code: "F13" }), 1);
+ });
+
+ it("ignores Dead keys and missing/empty keys without throwing", () => {
+ assert.equal(fired("dead", { key: "Dead", code: "Quote" }), 0);
+ assert.equal(fired("a", { key: undefined, code: "KeyA" }), 0);
+ assert.equal(fired("a", { key: "", code: "KeyA" }), 0);
+ });
+
+ it("keeps numpad digits positional as num0..num9", () => {
+ assert.equal(fired("num0", { key: "0", code: "Numpad0" }), 1);
+ assert.equal(fired("num9", { key: "9", code: "Numpad9" }), 1);
+ // NumLock off: the key says Insert but the position wins.
+ assert.equal(fired("num0", { key: "Insert", code: "Numpad0" }), 1);
+ });
+
+ it("falls back to the glyph for non-digit numpad codes", () => {
+ assert.equal(fired("+", { key: "+", code: "NumpadAdd" }), 1);
+ });
+
+ it("lowercases unknown long keys", () => {
+ assert.equal(fired("foo", { key: "Foo", code: "Foo" }), 1);
+ assert.equal(fired("ñ", { key: "Ñ", code: "Semicolon" }), 1);
+ });
+});
diff --git a/frontend/packages/mousetrap/test/sequences.test.js b/frontend/packages/mousetrap/test/sequences.test.js
new file mode 100644
index 0000000000..69098258d9
--- /dev/null
+++ b/frontend/packages/mousetrap/test/sequences.test.js
@@ -0,0 +1,108 @@
+import { describe, it, beforeEach, afterEach, mock } from "node:test";
+import assert from "node:assert/strict";
+import { freshInstance, fire, makeKeyEvent } from "./helpers.js";
+
+function down(over) {
+ return makeKeyEvent({ type: "keydown", ...over });
+}
+
+function up(over) {
+ return makeKeyEvent({ type: "keyup", ...over });
+}
+
+describe("sequences", () => {
+ beforeEach(() => {
+ mock.timers.enable({ apis: ["setTimeout"] });
+ });
+
+ afterEach(() => {
+ mock.timers.reset();
+ });
+
+ it("fires after the full sequence, not on the prefix alone", () => {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind("g v", () => {
+ calls++;
+ });
+ fire(target, down({ key: "g", code: "KeyG" }));
+ assert.equal(calls, 0);
+ fire(target, down({ key: "v", code: "KeyV" }));
+ assert.equal(calls, 1);
+ });
+
+ it("advances every sequence sharing a prefix", () => {
+ const { mousetrap, target } = freshInstance();
+ const seen = [];
+ mousetrap.bind("g i", () => {
+ seen.push("i");
+ });
+ mousetrap.bind("g t", () => {
+ seen.push("t");
+ });
+ fire(target, down({ key: "g", code: "KeyG" }));
+ fire(target, down({ key: "t", code: "KeyT" }));
+ assert.deepEqual(seen, ["t"]);
+ });
+
+ it("resets on a wrong key mid-sequence", () => {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind("g v", () => {
+ calls++;
+ });
+ fire(target, down({ key: "g", code: "KeyG" }));
+ fire(target, down({ key: "e", code: "KeyE" }));
+ fire(target, down({ key: "v", code: "KeyV" }));
+ assert.equal(calls, 0);
+ // and the sequence works again afterwards
+ fire(target, down({ key: "g", code: "KeyG" }));
+ fire(target, down({ key: "v", code: "KeyV" }));
+ assert.equal(calls, 1);
+ });
+
+ it("expires after one second without the next key", () => {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind("g v", () => {
+ calls++;
+ });
+ fire(target, down({ key: "g", code: "KeyG" }));
+ mock.timers.tick(1001);
+ fire(target, down({ key: "v", code: "KeyV" }));
+ assert.equal(calls, 0);
+ });
+
+ it("supports modifiers inside sequences", () => {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind("ctrl+k u", () => {
+ calls++;
+ });
+ fire(target, down({ key: "k", code: "KeyK", ctrlKey: true }));
+ fire(target, down({ key: "u", code: "KeyU" }));
+ assert.equal(calls, 1);
+ });
+
+ it("supports keyup sequences and ignores the final keyup", () => {
+ const { mousetrap, target } = freshInstance();
+ const seen = [];
+ mousetrap.bind(
+ "g v",
+ () => {
+ seen.push("seq");
+ },
+ "keyup",
+ );
+ mousetrap.bind(
+ "v",
+ () => {
+ seen.push("keyup-v");
+ },
+ "keyup",
+ );
+ fire(target, up({ key: "g", code: "KeyG" }));
+ fire(target, up({ key: "v", code: "KeyV" }));
+ assert.deepEqual(seen, ["seq"]);
+ });
+});
diff --git a/frontend/packages/mousetrap/test/smoke.test.js b/frontend/packages/mousetrap/test/smoke.test.js
new file mode 100644
index 0000000000..ae3498f046
--- /dev/null
+++ b/frontend/packages/mousetrap/test/smoke.test.js
@@ -0,0 +1,31 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { freshInstance, fire, makeKeyEvent } from "./helpers.js";
+
+describe("smoke", () => {
+ it("fires a bound combo and ignores it without the modifier", () => {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind("ctrl+z", () => {
+ calls++;
+ });
+
+ fire(target, makeKeyEvent({ key: "z", code: "KeyZ", ctrlKey: true }));
+ fire(target, makeKeyEvent({ key: "z", code: "KeyZ" }));
+
+ assert.equal(calls, 1);
+ });
+
+ it("reset() leaves the instance deaf", () => {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind("ctrl+z", () => {
+ calls++;
+ });
+ mousetrap.reset();
+
+ fire(target, makeKeyEvent({ key: "z", code: "KeyZ", ctrlKey: true }));
+
+ assert.equal(calls, 0);
+ });
+});
diff --git a/frontend/packages/mousetrap/test/stop-callback.test.js b/frontend/packages/mousetrap/test/stop-callback.test.js
new file mode 100644
index 0000000000..b02303dcec
--- /dev/null
+++ b/frontend/packages/mousetrap/test/stop-callback.test.js
@@ -0,0 +1,102 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { Mousetrap } from "../index.js";
+import {
+ freshInstance,
+ fire,
+ makeKeyEvent,
+ makeElement,
+} from "./helpers.js";
+
+describe("stopCallback", () => {
+ function stopped(elementOver, eventOver = {}, combo = "ctrl+z") {
+ const { mousetrap } = freshInstance();
+ const event = makeKeyEvent({ target: makeElement(elementOver), ...eventOver });
+ return mousetrap.stopCallback(event, event.target, combo);
+ }
+
+ it("stops inside form fields", () => {
+ for (const tagName of ["INPUT", "SELECT", "TEXTAREA"]) {
+ assert.equal(stopped({ tagName }), true, tagName);
+ }
+ });
+
+ it("stops buttons only for tab combos, tolerating a missing combo", () => {
+ assert.equal(stopped({ tagName: "BUTTON" }, {}, "shift+tab"), true);
+ assert.equal(stopped({ tagName: "BUTTON" }, {}, "ctrl+z"), false);
+ assert.equal(
+ stopped({ tagName: "BUTTON" }, {}, undefined),
+ false,
+ );
+ });
+
+ it("stops editable content", () => {
+ assert.equal(stopped({ contentEditable: "true" }), true);
+ assert.equal(stopped({ contentEditable: "plaintext-only" }), true);
+ assert.equal(stopped({ contentEditable: "false" }), false);
+ assert.equal(stopped({ contentEditable: "inherit" }), false);
+ });
+
+ it("lets events through on mousetrap-marked elements", () => {
+ assert.equal(stopped({ className: "mousetrap" }), false);
+ assert.equal(stopped({ className: "foo mousetrap bar" }), false);
+ assert.equal(stopped({ dataset: { mousetrapDontStop: "" } }), false);
+ });
+
+ it("always lets keyup through", () => {
+ assert.equal(stopped({ tagName: "INPUT" }, { type: "keyup" }), false);
+ });
+
+ it("retargets through composedPath in open shadow trees", () => {
+ const inner = makeElement({ tagName: "DIV" });
+ const event = makeKeyEvent({
+ target: makeElement({ tagName: "INPUT" }),
+ composedPath: () => [inner],
+ });
+ const { mousetrap } = freshInstance();
+ assert.equal(mousetrap.stopCallback(event, event.target, "ctrl+z"), false);
+ });
+
+ it("gates dispatch end to end: typing in an input fires nothing", () => {
+ const { mousetrap, target } = freshInstance();
+ let calls = 0;
+ mousetrap.bind("ctrl+z", () => {
+ calls++;
+ });
+ fire(
+ target,
+ makeKeyEvent({
+ key: "z",
+ code: "KeyZ",
+ ctrlKey: true,
+ target: makeElement({ tagName: "INPUT" }),
+ }),
+ );
+ assert.equal(calls, 0);
+ });
+});
+
+describe("exports", () => {
+ it("exposes the shared default instance", async () => {
+ const mod = await import("../index.js");
+ assert.equal(typeof mod.default.bind, "function");
+ assert.equal(typeof mod.default.reset, "function");
+ });
+
+ it("constructs without a target in Node: no listeners, no throw", () => {
+ const mousetrap = new Mousetrap();
+ assert.deepEqual(mousetrap._callbacks, {});
+ });
+
+ it("wires keydown and keyup on the given target", async () => {
+ const { Mousetrap: Klass } = await import("../index.js");
+ const seen = [];
+ const target = {
+ addEventListener(type) {
+ seen.push(type);
+ },
+ };
+ new Klass(target);
+ assert.deepEqual(seen, ["keydown", "keyup"]);
+ });
+});
diff --git a/frontend/scripts/check-translations/words.es.txt b/frontend/scripts/check-translations/words.es.txt
index 009f4c1c4e..ef5db91657 100644
--- a/frontend/scripts/check-translations/words.es.txt
+++ b/frontend/scripts/check-translations/words.es.txt
@@ -280,6 +280,7 @@ lectores
letras
manifiesto
menos
+navegadores
nosotros
otras
pero
diff --git a/frontend/scripts/test b/frontend/scripts/test
index 6d822b9542..c61c28b543 100755
--- a/frontend/scripts/test
+++ b/frontend/scripts/test
@@ -10,3 +10,8 @@ popd
pnpm run lint:scss;
pnpm run test;
+
+# Mousetrap JS unit tests (node:test, zero deps)
+pushd packages/mousetrap;
+pnpm test;
+popd
diff --git a/frontend/src/app/main/data/dashboard/shortcuts.cljs b/frontend/src/app/main/data/dashboard/shortcuts.cljs
index b225a50060..f406151f9b 100644
--- a/frontend/src/app/main/data/dashboard/shortcuts.cljs
+++ b/frontend/src/app/main/data/dashboard/shortcuts.cljs
@@ -50,7 +50,8 @@
(def shortcut-create-new-project
{:create-new-project {:tooltip "+"
:label (fn [] (tr "shortcuts.create-new-project"))
- :command "+"
+ :command ["+" "="]
+ :show-command "+"
:section [:dashboard]
:subsections [:generic]
:fn #(st/emit! (dd/create-element))}})
diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs
index 6ed22e5fb1..7181f5ebb3 100644
--- a/frontend/src/app/main/data/shortcuts.cljs
+++ b/frontend/src/app/main/data/shortcuts.cljs
@@ -17,6 +17,37 @@
(log/set-level! :warn)
+;; Keyboard layout map shared by the shortcut recorder
+;; (`app.main.ui.shortcuts`) and the Mousetrap dispatcher: punctuation
+;; resolves by physical position through this map, so recording and
+;; dispatch use the same token. `nil` until `getLayoutMap` resolves (or
+;; when the browser has no keyboard API); both sides then fall back to
+;; the typed glyph, which keeps them consistent with and without a map.
+(defonce layout-map (atom nil))
+
+(defn layout-map-supported?
+ "Synchronous keyboard-layout API probe: true when the browser exposes
+ navigator.keyboard.getLayoutMap (Chromium). Punctuation then resolves
+ by physical position; without it both sides fall back to the typed
+ glyph, so Shift+punctuation shortcuts may not fire."
+ ([] (layout-map-supported?
+ (when (exists? js/navigator) js/navigator)))
+ ([navigator]
+ (let [keyboard (when (some? navigator) (.-keyboard ^js navigator))]
+ (boolean
+ (and (some? keyboard)
+ (exists? (.-getLayoutMap ^js keyboard)))))))
+
+(let [navigator (when (exists? js/navigator) js/navigator)]
+ (when (layout-map-supported? navigator)
+ (-> (.getLayoutMap ^js (.-keyboard ^js navigator))
+ (.then (fn [m]
+ (cljs.core/reset! layout-map m)
+ (.setLayoutMap mousetrap m))
+ (fn [_]
+ (cljs.core/reset! layout-map nil)
+ (.setLayoutMap mousetrap nil))))))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Helpers
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/frontend/src/app/main/data/viewer/shortcuts.cljs b/frontend/src/app/main/data/viewer/shortcuts.cljs
index 23f71e9b57..83878c9682 100644
--- a/frontend/src/app/main/data/viewer/shortcuts.cljs
+++ b/frontend/src/app/main/data/viewer/shortcuts.cljs
@@ -17,7 +17,8 @@
(def shortcuts
{:increase-zoom {:tooltip "+"
:label (fn [] (tr "shortcuts.increase-zoom"))
- :command "+"
+ :command ["+" "="]
+ :show-command "+"
:subsections [:zoom-viewer]
:section [:viewer]
:fn #(st/emit! dv/increase-zoom)}
diff --git a/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs b/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs
index 1e234dc62d..de5349fca3 100644
--- a/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs
+++ b/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs
@@ -55,7 +55,8 @@
:increase-zoom {:tooltip "+"
:label (fn [] (tr "shortcuts.increase-zoom"))
- :command "+"
+ :command ["+" "="]
+ :show-command "+"
:section [:workspace]
:fn #(st/emit! (dw/increase-zoom nil))}
diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs
index c1d2269383..bd924df5fe 100644
--- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs
+++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs
@@ -59,7 +59,8 @@
:add-node {:tooltip (ds/shift "+")
:label (fn [] (tr "shortcuts.add-node"))
- :command "shift++"
+ :command ["shift++" "shift+="]
+ :show-command "shift++"
:subsections [:path-editor]
:section [:workspace]
:fn #(st/emit! (drp/add-node))}
@@ -117,8 +118,7 @@
:snap-nodes {:tooltip (ds/meta "'")
:label (fn [] (tr "shortcuts.snap-nodes"))
- ;;https://github.com/ccampbell/mousetrap/issues/85
- :command [(ds/c-mod "'") (ds/c-mod "219")]
+ :command (ds/c-mod "'")
:subsections [:path-editor]
:section [:workspace]
:fn #(st/emit! (drp/toggle-snap))}
@@ -207,7 +207,8 @@
:increase-zoom {:tooltip "+"
:label (fn [] (tr "shortcuts.increase-zoom"))
- :command "+"
+ :command ["+" "="]
+ :show-command "+"
:section [:workspace]
:fn #(st/emit! (dw/increase-zoom nil))}
diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs
index f91e51a5ba..bdbd3cedaf 100644
--- a/frontend/src/app/main/data/workspace/shortcuts.cljs
+++ b/frontend/src/app/main/data/workspace/shortcuts.cljs
@@ -567,8 +567,7 @@
:toggle-guides {:tooltip (ds/meta "'")
:label (fn [] (tr "shortcuts.toggle-guides"))
- ;;https://github.com/ccampbell/mousetrap/issues/85
- :command [(ds/c-mod "'") (ds/c-mod "219")]
+ :command (ds/c-mod "'")
:show-command (ds/c-mod "'")
:subsections [:main-menu]
:section [:workspace]
@@ -619,8 +618,7 @@
:toggle-snap-guides {:tooltip (ds/meta-shift "'")
:label (fn [] (tr "shortcuts.toggle-snap-guides"))
- ;;https://github.com/ccampbell/mousetrap/issues/85
- :command [(ds/c-mod "shift+'") (ds/c-mod "shift+219")]
+ :command (ds/c-mod "shift+'")
:show-command (ds/c-mod "shift+'")
:subsections [:main-menu]
:section [:workspace]
@@ -679,7 +677,8 @@
:hide-ui {:tooltip "\\"
:label (fn [] (tr "shortcuts.hide-ui"))
- :command "\\"
+ :command ["\\" "alt+\\" "alt+º"]
+ :show-command "\\"
:subsections [:panels :basics]
:section [:workspace :basics]
:fn #(st/emit! (toggle-layout-flag :hide-ui))}
@@ -716,7 +715,8 @@
:zoom-selected {:tooltip (ds/shift "2")
:label (fn [] (tr "shortcuts.zoom-selected"))
- :command ["shift+2" "shift+num2" "@" "\""]
+ :command ["shift+2" "shift+num2" "shift+'" "shift+\""]
+ :show-command "shift+2"
:subsections [:zoom-workspace]
:section [:workspace]
:fn #(st/emit! dw/zoom-to-selected-shape)}
diff --git a/frontend/src/app/main/ui/settings/shortcuts.cljs b/frontend/src/app/main/ui/settings/shortcuts.cljs
index 7df63314d0..2b246339b2 100644
--- a/frontend/src/app/main/ui/settings/shortcuts.cljs
+++ b/frontend/src/app/main/ui/settings/shortcuts.cljs
@@ -23,6 +23,7 @@
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]]
+ [app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.ds.product.empty-state :refer [empty-state*]]
[app.main.ui.settings.import-shortcuts-diff-modal]
[app.main.ui.settings.restore-shortcuts-modal]
@@ -477,6 +478,10 @@
[:> icon* {:icon-id i/info
:class (stl/css :shortcuts-description-icon)}]
(tr "shortcuts.reload-hint")]
+ (when-not (ds/layout-map-supported?)
+ [:> context-notification* {:level :warning
+ :class (stl/css :layout-map-warning)}
+ (tr "shortcuts.layout-map-missing")])
[:> (mf/provider ctx/shortcuts-ctx) {:value shortcuts-ctx-value}
[:div {:class (stl/css :shortcuts-content)}
diff --git a/frontend/src/app/main/ui/settings/shortcuts.scss b/frontend/src/app/main/ui/settings/shortcuts.scss
index 522147f61e..c86ca879b5 100644
--- a/frontend/src/app/main/ui/settings/shortcuts.scss
+++ b/frontend/src/app/main/ui/settings/shortcuts.scss
@@ -314,3 +314,7 @@
padding: var(--sp-s) 0;
margin-block-end: var(--sp-l);
}
+
+.layout-map-warning {
+ margin-block-end: var(--sp-m);
+}
diff --git a/frontend/src/app/main/ui/shortcuts.cljs b/frontend/src/app/main/ui/shortcuts.cljs
index 74035a119f..1520f3258b 100644
--- a/frontend/src/app/main/ui/shortcuts.cljs
+++ b/frontend/src/app/main/ui/shortcuts.cljs
@@ -34,41 +34,40 @@
(def ^:private modifier-keys
#{"Control" "Shift" "Alt" "Meta"})
+(def ^:private punctuation-codes
+ #{"Minus" "Equal" "BracketLeft" "BracketRight" "Backslash"
+ "Semicolon" "Quote" "Backquote" "Comma" "Period" "Slash"})
+
(def ^:private key-name-map
{"ArrowUp" "up"
+ "Up" "up"
"ArrowDown" "down"
+ "Down" "down"
"ArrowLeft" "left"
+ "Left" "left"
"ArrowRight" "right"
+ "Right" "right"
"Escape" "escape"
+ "Esc" "escape"
"Enter" "enter"
"Backspace" "backspace"
"Delete" "del"
+ "Del" "del"
+ "Insert" "ins"
+ "Ins" "ins"
"Tab" "tab"
+ "Home" "home"
+ "End" "end"
+ "PageUp" "pageup"
+ "PageDown" "pagedown"
+ "CapsLock" "capslock"
+ "Spacebar" "space"
+ "Control" "ctrl"
+ "Shift" "shift"
+ "Alt" "alt"
+ "Meta" "meta"
" " "space"})
-;; Mousetrap (the runtime shortcut matcher, vendored at
-;; frontend/packages/mousetrap) resolves a keydown positionally - via keyCode
-;; - ONLY for this punctuation set (its _KEYCODE_MAP); it falls back to
-;; `event.key.toLowerCase()` (glyph-based) for letters and digits, which
-;; aren't in that map. Recording punctuation from `code` therefore matches
-;; what Mousetrap triggers regardless of layout (e.g. the key next to Right
-;; Shift on a US layout is always "Period", whatever glyph it types on a
-;; given ISO layout). With modifiers, letters and digits come from keyCode
-;; like Mousetrap's keydown match (see `letter-or-digit`); otherwise from the
-;; glyph, like its keypress match.
-(def ^:private code-name-map
- {"Minus" "-"
- "Equal" "="
- "BracketLeft" "["
- "BracketRight" "]"
- "Backslash" "\\"
- "Semicolon" ";"
- "Quote" "'"
- "Backquote" "`"
- "Comma" ","
- "Period" "."
- "Slash" "/"})
-
(defn- event->native
"Unwraps a goog.events.BrowserEvent to the native event, since the
wrapper copies `key` but not `code`."
@@ -77,42 +76,100 @@
(or (.getBrowserEvent event) event)
event))
-(defn- letter-or-digit
- "Letter or digit named by keyCode when a modifier is held, ignoring the glyph
- it makes the key type (Option+Shift+5 types \"fi\" on macOS)."
+(defn- alt-graph?
[^js event]
- (let [key-code (.-keyCode event)]
- (when (and (or (.-ctrlKey event) (.-metaKey event)
- (.-altKey event) (.-shiftKey event))
- (or (<= 48 key-code 57)
- (<= 65 key-code 90)))
- (.toLowerCase (js/String.fromCharCode key-code)))))
+ (boolean
+ (or (when (fn? (.-getModifierState event))
+ (.getModifierState event "AltGraph"))
+ (let [native (event->native event)]
+ (when (and (not (identical? native event))
+ (fn? (.-getModifierState native)))
+ (.getModifierState native "AltGraph"))))))
+
+(defn- numpad-code?
+ [code]
+ (and (string? code)
+ (str/starts-with? code "Numpad")
+ (= 1 (count (subs code 6)))))
+
+(defn- numpad-alias
+ [code]
+ (let [suffix (subs code 6)]
+ (when (re-matches #"[0-9]" suffix)
+ (str "num" suffix))))
+
+(defn- punctuation-alias
+ "Resolves punctuation by physical position through the shared layout
+ map (`app.main.data.shortcuts/layout-map`, also pushed into Mousetrap),
+ so recording and dispatch use the same token. Without a map the typed
+ glyph applies, which keeps both sides consistent."
+ [code key]
+ (let [mapped (when (some? @ds/layout-map)
+ (.get ^js @ds/layout-map code))]
+ (if (string? mapped)
+ (str/lower mapped)
+ (str/lower key))))
+
+(defn- digit-code?
+ [code]
+ (and (string? code)
+ (boolean (re-matches #"Digit[0-9]" code))))
+
+(defn- digit-alias
+ "Shifted digits resolve to the digit itself: with Shift held, Digit0
+ types ) on US layouts but 0 on AZERTY, so shift+0 must mean the
+ physical digit key everywhere, like shift+z means z."
+ [^js event code key]
+ (when (and (.-shiftKey ^js event)
+ (digit-code? code)
+ (= 1 (count key)))
+ (subs code 5)))
(defn- normalize-key
+ "Normalizes `event.key` to the lowercase canonical token mousetrap
+ matches on. Letters and unshifted digits resolve by glyph (`a`, not
+ `q` on AZERTY); a digit row key held with Shift resolves to the digit
+ itself (`shift+0`, never `shift+)`); punctuation resolves by physical
+ position through the layout map, so `Shift+*` on a Spanish layout
+ records `shift++`, the primary key the user sees."
[^js event]
- (let [key (.-key event)]
- (when (and key (not (contains? modifier-keys key)))
- (or (get key-name-map key)
- (get code-name-map (.-code (event->native event)))
- (letter-or-digit event)
- (some-> key .toLowerCase)))))
+ (let [native (event->native event)
+ key (.-key ^js event)
+ code (.-code ^js native)]
+ (when (and (string? key) (not (contains? modifier-keys key)))
+ (cond
+ ;; Numpad digits keep their own `numN` bindings, like mousetrap.
+ (numpad-code? code)
+ (numpad-alias code)
+
+ ;; Punctuation: primary key of the physical position.
+ (contains? punctuation-codes code)
+ (punctuation-alias code key)
+
+ :else
+ (or (get key-name-map key)
+ ;; Shifted digit row: the digit itself, on every layout.
+ (digit-alias event code key)
+ (str/lower key))))))
(defn- event->modifier-parts
[^js event]
- (cond-> []
- (and (.-ctrlKey event)
- (not (cf/check-platform? :macos)))
- (conj "ctrl")
+ (let [alt-graph? (alt-graph? event)]
+ (cond-> []
+ (and (.-ctrlKey event)
+ (not (cf/check-platform? :macos))
+ (not alt-graph?))
+ (conj "ctrl")
- (and (.-metaKey event)
- (cf/check-platform? :macos))
- (conj "command")
+ (and (.-metaKey event)
+ (cf/check-platform? :macos))
+ (conj "command")
- (.-altKey event)
- (conj "alt")
+ (.-altKey event)
+ (conj "alt")
- (.-shiftKey event)
- (conj "shift")))
+ (.-shiftKey event)
+ (conj "shift"))))
(defn- keyboard-event->mousetrap
[^js event]
diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs
index e33ae65f8f..3a3639433f 100644
--- a/frontend/test/frontend_tests/runner.cljs
+++ b/frontend/test/frontend_tests/runner.cljs
@@ -123,7 +123,6 @@
[frontend-tests.ui.settings-shortcuts-test]
[frontend-tests.ui.shape-filters-test]
[frontend-tests.ui.shortcuts-labels-test]
- [frontend-tests.ui.shortcuts-recording-test]
[frontend-tests.ui.sidebar-scroll-test]
[frontend-tests.ui.stroke-menu-test]
[frontend-tests.ui.workspace-libraries-test]
@@ -270,7 +269,6 @@
'frontend-tests.ui.settings-shortcuts-test
'frontend-tests.ui.shape-filters-test
'frontend-tests.ui.shortcuts-labels-test
- 'frontend-tests.ui.shortcuts-recording-test
'frontend-tests.ui.sidebar-scroll-test
'frontend-tests.ui.stroke-menu-test
'frontend-tests.ui.workspace-libraries-test
diff --git a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs
index e9913c9b26..fd82bfe17d 100644
--- a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs
+++ b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs
@@ -2,6 +2,7 @@
(:require
[app.config :as cf]
[app.main.data.profile :as du]
+ [app.main.data.shortcuts :as ds]
[app.main.ui.settings.import-shortcuts-diff-modal :as diff-modal]
[app.main.ui.settings.restore-shortcuts-modal :as restore-modal]
[app.main.ui.settings.shortcuts :as sut]
@@ -293,9 +294,8 @@
;; --- keyboard-event->mousetrap across keyboard layouts -------------------
;; `keyboard-event->mousetrap` (private, exercised via #') turns a recorded
-;; keydown into a Mousetrap-style combo string, reading `event.code`
-;; (layout-independent) rather than `event.key` (layout- and Shift-dependent)
-;; so the recorded combo matches what Mousetrap resolves positionally. Real
+;; keydown into a Mousetrap-style combo string from `event.key` (the typed
+;; glyph), so recording and dispatch always use the same token. Real
;; keydown events are wrapped by goog.events in a goog.events.BrowserEvent,
;; which forwards `key` but not `code`; the fixtures below simulate that
;; wrapper via `getBrowserEvent`.
@@ -326,35 +326,47 @@
:metaKey (boolean meta)})
(t/deftest mousetrap-shift-period-on-us-layout
- (t/testing "US layout: Shift + Period key types \">\""
+ (t/testing "US layout without a layout map: Shift + Period types \">\" and
+ records the glyph, the same token Mousetrap dispatches"
(with-redefs [cf/check-platform? (constantly false)]
- (t/is (= "shift+."
+ (t/is (= "shift+>"
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key ">" :code "Period" :shift true})))))))
-(t/deftest mousetrap-shift-period-on-spanish-iso-layout
- (t/testing "Spanish (ISO) layout: Shift + Period key types \":\" instead of
- \">\", but the physical key (code \"Period\") is the same as on
- a US layout, so the recorded combo must match"
+(t/deftest mousetrap-shift-period-with-layout-map-records-primary
+ (t/testing "with a layout map, Shift + Period records the primary \".\"
+ even when the typed glyph differs"
(with-redefs [cf/check-platform? (constantly false)]
- (t/is (= "shift+."
+ (try
+ (reset! ds/layout-map #js {:get (fn [code] (when (= code "Period") "."))})
+ (t/is (= "shift+."
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key ">" :code "Period" :shift true}))))
+ (t/is (= "shift+."
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key ":" :code "Period" :shift true}))))
+ (finally
+ (reset! ds/layout-map nil))))))
+
+(t/deftest mousetrap-shift-period-on-spanish-iso-layout
+ (t/testing "Spanish (ISO) layout without a layout map: Shift + Period
+ types \":\" and records the glyph"
+ (with-redefs [cf/check-platform? (constantly false)]
+ (t/is (= "shift+:"
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key ":" :code "Period" :shift true})))))))
(t/deftest mousetrap-shift-period-on-arbitrary-layout
- (t/testing "an arbitrary layout where Shift + Period produces a character
- unrelated to \".\" or \":\" still normalizes to \"shift+.\",
- since matching is driven by the physical key, not the glyph"
+ (t/testing "without a layout map the typed glyph wins over the physical
+ position: \"?\" on the Period position records \"shift+?\""
(with-redefs [cf/check-platform? (constantly false)]
- (t/is (= "shift+."
+ (t/is (= "shift+?"
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key "?" :code "Period" :shift true})))))))
(t/deftest mousetrap-letter-key-on-azerty-layout
(t/testing "French AZERTY: the key printed \"A\" sits where \"Q\" is on a US
- layout (code \"KeyQ\") and types \"a\". Mousetrap doesn't
- resolve letters positionally (absent from its
- _MAP/_KEYCODE_MAP), so recording must stay glyph-based
+ layout (code \"KeyQ\") and types \"a\". Letters resolve by glyph
(\"a\", not \"q\") to match what that physical key triggers"
(with-redefs [cf/check-platform? (constantly false)]
(t/is (= "a"
@@ -364,19 +376,17 @@
(t/deftest mousetrap-digit-key-on-azerty-layout
(t/testing "French AZERTY: the unshifted top-row key at the US \"1\"
position (code \"Digit1\") types \"&\". Digits, like letters,
- are absent from Mousetrap's _MAP/_KEYCODE_MAP, so recording
- must stay glyph-based (\"&\") rather than resolve positionally
- to \"1\""
+ resolve by glyph (\"&\") rather than positionally to \"1\""
(with-redefs [cf/check-platform? (constantly false)]
(t/is (= "&"
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key "&" :code "Digit1"})))))))
(t/deftest mousetrap-reads-code-from-unwrapped-native-event
- (t/testing "falls back to reading `code` straight off the event when it is
- not a goog.events.BrowserEvent wrapper (no getBrowserEvent)"
+ (t/testing "recording works on a plain native event too (glyph wins
+ without a layout map)"
(with-redefs [cf/check-platform? (constantly false)]
- (t/is (= "shift+."
+ (t/is (= "shift+:"
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-native-keydown {:key ":" :code "Period" :shift true})))))))
@@ -417,3 +427,116 @@
(#'ui-shortcuts/keyboard-event->mousetrap
(mock-wrapped-keydown {:key "q" :code "KeyQ"
:ctrl true :alt true :shift true})))))))
+
+(t/deftest mousetrap-shifted-letter-lowercases
+ (t/testing "Ctrl+Shift+Z types \"Z\" but records \"ctrl+shift+z\" so the
+ bind and the event use the same token"
+ (with-redefs [cf/check-platform? (constantly false)]
+ (t/is (= "ctrl+shift+z"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key "Z" :code "KeyZ"
+ :ctrl true :shift true})))))))
+
+(t/deftest mousetrap-shifted-digit-resolves-to-digit
+ (t/testing "a digit row key held with Shift records the digit itself: US
+ Shift+0 types \")\" but records \"shift+0\", so the physical
+ digit key means the same on every layout (production keyCode
+ behavior)"
+ (with-redefs [cf/check-platform? (constantly false)]
+ (t/is (= "shift+0"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key ")" :code "Digit0" :shift true}))))
+ (t/is (= "shift+1"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key "!" :code "Digit1" :shift true}))))
+ (t/is (= "shift+2"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key "@" :code "Digit2" :shift true}))))
+ (t/is (= "shift+8"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key "*" :code "Digit8" :shift true})))))))
+
+(t/deftest mousetrap-shifted-digit-already-digit-unchanged
+ (t/testing "AZERTY Shift+Digit0 already types \"0\": unchanged"
+ (with-redefs [cf/check-platform? (constantly false)]
+ (t/is (= "shift+0"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key "0" :code "Digit0" :shift true})))))))
+
+(t/deftest mousetrap-space-and-named-keys
+ (t/testing "\" \" records as space and named keys stay lowercase"
+ (with-redefs [cf/check-platform? (constantly false)]
+ (t/is (= "space"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key " " :code "Space"}))))
+ (t/is (= "escape"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key "Escape" :code "Escape"}))))
+ (t/is (= "shift+tab"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key "Tab" :code "Tab" :shift true}))))
+ (t/is (= "del"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key "Delete" :code "Delete"})))))))
+
+(t/deftest mousetrap-altgr-drops-phantom-ctrl
+ (t/testing "Spanish AltGr++] types \"]\" with ctrl+alt flags, but the
+ phantom ctrl is dropped so no ctrl+alt shortcut fires"
+ (with-redefs [cf/check-platform? (constantly false)]
+ (let [event #js {:key "]"
+ :shiftKey false :ctrlKey true :altKey true :metaKey false
+ :getModifierState (fn [m] (= m "AltGraph"))
+ :getBrowserEvent (fn [] nil)}]
+ (t/is (= "alt+]"
+ (#'ui-shortcuts/keyboard-event->mousetrap event)))))))
+
+(t/deftest mousetrap-real-ctrl-alt-keeps-both
+ (t/testing "a real Ctrl+Alt without AltGraph still records both modifiers"
+ (with-redefs [cf/check-platform? (constantly false)]
+ (let [event #js {:key "]"
+ :shiftKey false :ctrlKey true :altKey true :metaKey false
+ :getModifierState (fn [_] false)
+ :getBrowserEvent (fn [] nil)}]
+ (t/is (= "ctrl+alt+]"
+ (#'ui-shortcuts/keyboard-event->mousetrap event)))))))
+
+(t/deftest mousetrap-spanish-shift-plus-with-layout-map-records-primary
+ (t/testing "Spanish layout with a layout map: Shift + the physical \"+\"
+ key types \"*\" but records the primary \"shift++\", which is
+ what the user sees and what Mousetrap matches"
+ (with-redefs [cf/check-platform? (constantly false)]
+ (try
+ (reset! ds/layout-map #js {:get (fn [code] (when (= code "BracketRight") "+"))})
+ (t/is (= "shift++"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key "*" :code "BracketRight" :shift true}))))
+ (t/is (= "+"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key "+" :code "BracketRight"}))))
+ (finally
+ (reset! ds/layout-map nil))))))
+
+(t/deftest mousetrap-spanish-shift-plus-without-layout-map-records-glyph
+ (t/testing "Spanish layout without a layout map: Shift + the physical
+ \"+\" key records the typed glyph \"shift+*\", the same token
+ Mousetrap dispatches"
+ (with-redefs [cf/check-platform? (constantly false)]
+ (t/is (= "shift+*"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key "*" :code "BracketRight" :shift true})))))))
+
+(t/deftest mousetrap-spanish-unshifted-plus-records-plus
+ (t/testing "Spanish layout: the unshifted physical \"+\" key records \"+\""
+ (with-redefs [cf/check-platform? (constantly false)]
+ (t/is (= "+"
+ (#'ui-shortcuts/keyboard-event->mousetrap
+ (mock-wrapped-keydown {:key "+" :code "BracketRight"})))))))
+
+(t/deftest layout-map-supported-matches-keyboard-api
+ (t/testing "false without a navigator or without a keyboard API"
+ (t/is (false? (ds/layout-map-supported? nil)))
+ (t/is (false? (ds/layout-map-supported? #js {})))
+ (t/is (false? (ds/layout-map-supported? #js {:keyboard #js {}}))))
+ (t/testing "true when getLayoutMap exists (Chromium)"
+ (t/is (true? (ds/layout-map-supported?
+ #js {:keyboard #js {:getLayoutMap (fn [] nil)}})))))
diff --git a/frontend/test/frontend_tests/ui/shortcuts_recording_test.cljs b/frontend/test/frontend_tests/ui/shortcuts_recording_test.cljs
deleted file mode 100644
index 9bb4479fbe..0000000000
--- a/frontend/test/frontend_tests/ui/shortcuts_recording_test.cljs
+++ /dev/null
@@ -1,60 +0,0 @@
-;; This Source Code Form is subject to the terms of the Mozilla Public
-;; License, v. 2.0. If a copy of the MPL was not distributed with this
-;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
-;;
-;; Copyright (c) KALEIDOS SUBSIDIARY SL
-
-(ns frontend-tests.ui.shortcuts-recording-test
- (:require
- [app.main.ui.shortcuts :as ss]
- [cljs.test :as t :include-macros true]))
-
-(def ^:private keyboard-event->mousetrap
- @#'ss/keyboard-event->mousetrap)
-
-(defn- key-event
- [props]
- (clj->js (merge {:altKey false :shiftKey false :ctrlKey false :metaKey false}
- props)))
-
-(t/deftest records-alt-digit-from-key-code
- ;; macOS Option+Shift+5 types "fi"; Mousetrap matches it as "5"
- (t/is (= "alt+shift+5"
- (keyboard-event->mousetrap
- (key-event {:key "fi" :code "Digit5" :keyCode 53 :altKey true :shiftKey true})))))
-
-(t/deftest records-alt-letter-from-key-code
- ;; macOS Option+A types "å"; Mousetrap matches it as "a"
- (t/is (= "alt+a"
- (keyboard-event->mousetrap
- (key-event {:key "å" :code "KeyA" :keyCode 65 :altKey true})))))
-
-(t/deftest records-shift-digit-from-key-code
- ;; US Shift+0 types ")"; Mousetrap matches it as "0"
- (t/is (= "shift+0"
- (keyboard-event->mousetrap
- (key-event {:key ")" :code "Digit0" :keyCode 48 :shiftKey true})))))
-
-(t/deftest records-modified-letter-from-key-code
- ;; Russian layout V types "м"; Mousetrap matches Alt+V as "v"
- (t/is (= "alt+v"
- (keyboard-event->mousetrap
- (key-event {:key "м" :code "KeyV" :keyCode 86 :altKey true})))))
-
-(t/deftest records-unmodified-key-from-glyph
- ;; without modifiers Mousetrap matches the typed glyph on keypress
- (t/is (= "&"
- (keyboard-event->mousetrap
- (key-event {:key "&" :code "Digit1" :keyCode 49}))))
- (t/is (= "ñ"
- (keyboard-event->mousetrap
- (key-event {:key "ñ" :code "" :keyCode 0})))))
-
-(t/deftest records-shift-punctuation-from-code
- ;; Shift+. types ":" and Shift+= types "+" (US); the key is recorded
- (t/is (= "shift+."
- (keyboard-event->mousetrap
- (key-event {:key ":" :code "Period" :keyCode 190 :shiftKey true}))))
- (t/is (= "shift+="
- (keyboard-event->mousetrap
- (key-event {:key "+" :code "Equal" :keyCode 187 :shiftKey true})))))
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index 2c7108dbe4..525bb3589d 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -5021,6 +5021,13 @@ msgstr "Join nodes"
msgid "shortcuts.key-combo"
msgstr "Press the new keys"
+#: src/app/main/ui/settings/shortcuts.cljs:484
+msgid "shortcuts.layout-map-missing"
+msgstr ""
+"This browser does not expose the keyboard layout, so shortcuts that "
+"combine Shift with punctuation (for example Shift+,) may not work here. "
+"They keep working in Chromium browsers."
+
#: src/app/main/data/workspace/text/shortcuts.cljs:249
msgid "shortcuts.line-through"
msgstr "Toggle line through"
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index a069f620eb..82081caf82 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -5067,6 +5067,13 @@ msgstr "Unir nodos"
msgid "shortcuts.key-combo"
msgstr "Pulsa las nuevas teclas"
+#: src/app/main/ui/settings/shortcuts.cljs:484
+msgid "shortcuts.layout-map-missing"
+msgstr ""
+"Este navegador no expone el mapa de teclado, así que los atajos que "
+"combinan Shift con puntuación (por ejemplo Shift+,) pueden no funcionar "
+"aquí. Siguen funcionando en navegadores Chromium."
+
#: src/app/main/data/workspace/text/shortcuts.cljs:249
msgid "shortcuts.line-through"
msgstr "Alternar tachado"
@@ -6344,10 +6351,6 @@ msgstr "Tipografías"
msgid "workspace.assets.typography.add-typography"
msgstr "Añadir tipografía"
-#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:461
-msgid "workspace.assets.typography.multiple-texts-selected"
-msgstr "Selecciona un solo texto para crear un estilo de tipografía"
-
#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:855
msgid "workspace.assets.typography.font-size"
msgstr "Tamaño"
@@ -6368,6 +6371,10 @@ msgstr "Interletrado"
msgid "workspace.assets.typography.line-height"
msgstr "Interlineado"
+#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:461
+msgid "workspace.assets.typography.multiple-texts-selected"
+msgstr "Selecciona un solo texto para crear un estilo de tipografía"
+
#: src/app/main/ui/dashboard/grid.cljs:226, src/app/main/ui/workspace/libraries.cljs:1070, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:802, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:838, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:957, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:976
msgid "workspace.assets.typography.sample"
msgstr "Ag"