penpot/frontend/packages/mousetrap/test/normalize.test.js
Eva Marco 68ecfb1234
🐛 Fix default shortcuts that fail on some layouts and browsers (#12506)
Fixes the problems found after #12326 and listed in #12327.

Mousetrap now tries several names for each key press, in order, and
fires the first one with a binding: letters and digits by keyCode when
a modifier is held, punctuation by position (layout map, or a US table
when the browser has none), the typed glyph, and the typed glyph
without the Shift it needed. With AltGr the typed glyph comes before
the position. The shortcut recorder asks Mousetrap for the key it
stores, so a recorded shortcut always fires.

This makes "?" and "+" work on every layout, Shift and Ctrl+Shift with
punctuation work in Firefox and Safari, Option+letter work on macOS,
and AltGr+º hide the UI on Linux with a Spanish layout.

Chrome on Linux returns the map of the first configured layout, not
the active one. Mousetrap now drops the map when an unmodified
punctuation key types something else.

The layout twins are no longer needed and are removed: "shift+'" made
"?" zoom to the selection on Spanish layouts. The Add node shortcut is
removed too: on US layouts its key is the "+" zoom key, and on Firefox
with a Spanish layout it cannot fire. The Firefox and Safari warning
in the shortcuts settings is gone, since those shortcuts now work
there. The user guide and translations follow these changes.

AI-assisted-by: claude-opus-5-5
2026-10-26 11:56:09 +01:00

191 lines
8.1 KiB
JavaScript

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, named by keyCode.
assert.equal(fired("shift+0", { key: ")", code: "Digit0", keyCode: 48, shiftKey: true }), 1);
assert.equal(fired("shift+1", { key: "!", code: "Digit1", keyCode: 49, shiftKey: true }), 1);
assert.equal(fired("shift+2", { key: "@", code: "Digit2", keyCode: 50, shiftKey: true }), 1);
assert.equal(fired("shift+8", { key: "*", code: "Digit8", keyCode: 56, shiftKey: true }), 1);
// AZERTY shifted digits already type the digit: unchanged.
assert.equal(fired("shift+0", { key: "0", code: "Digit0", keyCode: 48, shiftKey: true }), 1);
// Unshifted digits are untouched.
assert.equal(fired("0", { key: "0", code: "Digit0", keyCode: 48 }), 1);
});
it("prefers the digit over the shifted glyph when both are bound", () => {
const { mousetrap, target } = freshInstance();
const calls = [];
mousetrap.bind("shift+0", () => calls.push("shift+0"));
mousetrap.bind("shift+)", () => calls.push("shift+)"));
fire(target, makeKeyEvent({ key: ")", code: "Digit0", keyCode: 48, shiftKey: true }));
assert.deepEqual(calls, ["shift+0"]);
});
it("without a layout map, punctuation resolves by US position or 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 }), 1);
assert.equal(fired("+", { key: "+", code: "BracketRight" }), 1);
// US layout: Shift + BracketRight types "}"; the glyph still matches.
assert.equal(fired("shift+}", { key: "}", code: "BracketRight", shiftKey: true }), 1);
assert.equal(fired("?", { key: "?", code: "Slash" }), 1);
// US layout: Shift + Comma types "<".
assert.equal(fired("shift+,", { key: "<", code: "Comma", shiftKey: true }), 1);
assert.equal(fired("ctrl+shift+.", { key: ">", code: "Period", ctrlKey: true, shiftKey: true }), 1);
});
it("matches a glyph binding without the Shift the glyph needs", () => {
// US layout: "?" is Shift+Slash and "+" is Shift+Equal.
assert.equal(fired("?", { key: "?", code: "Slash", shiftKey: true }), 1);
assert.equal(fired("+", { key: "+", code: "Equal", shiftKey: true }), 1);
// Spanish layout: "?" is Shift+Minus.
assert.equal(fired("?", { key: "?", code: "Minus", shiftKey: true }), 1);
});
it("dispatches only the most specific match", () => {
const { mousetrap, target } = freshInstance();
const calls = [];
mousetrap.bind("shift+/", () => calls.push("shift+/"));
mousetrap.bind("?", () => calls.push("?"));
fire(target, makeKeyEvent({ key: "?", code: "Slash", shiftKey: true }));
assert.deepEqual(calls, ["shift+/"]);
});
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);
});
});
describe("keyForRecording", () => {
const record = (eventOver, layoutMap = null) => {
const { mousetrap } = freshInstance();
mousetrap.setLayoutMap(layoutMap);
return mousetrap.keyForRecording(makeKeyEvent(eventOver));
};
const spanishMap = { get: (code) => ({ BracketRight: "+", Minus: "'" })[code] };
it("records letters and digits with a modifier by keyCode", () => {
assert.equal(record({ key: "!", code: "Digit1", keyCode: 49, shiftKey: true }), "1");
assert.equal(record({ key: "µ", code: "KeyM", keyCode: 77, altKey: true }), "m");
assert.equal(record({ key: "Dead", code: "KeyE", keyCode: 69, altKey: true }), "e");
});
it("records punctuation by position when there is a layout map", () => {
assert.equal(record({ key: "*", code: "BracketRight", shiftKey: true }, spanishMap), "+");
assert.equal(record({ key: "+", code: "BracketRight" }, spanishMap), "+");
});
it("records the typed glyph when there is no layout map", () => {
assert.equal(record({ key: "+", code: "BracketRight" }), "+");
assert.equal(record({ key: "*", code: "BracketRight", shiftKey: true }), "*");
});
it("falls back to the US position for a dead key without a map", () => {
assert.equal(record({ key: "Dead", code: "Quote" }), "'");
});
it("returns null for a modifier pressed alone", () => {
assert.equal(record({ key: "Shift", code: "ShiftLeft", shiftKey: true }), null);
});
it("records a key the dispatcher then matches", () => {
const cases = [
[{ key: "!", code: "Digit1", keyCode: 49, shiftKey: true }, null],
[{ key: "µ", code: "KeyM", keyCode: 77, altKey: true }, null],
[{ key: "*", code: "BracketRight", shiftKey: true }, null],
[{ key: "*", code: "BracketRight", shiftKey: true }, spanishMap],
[{ key: "?", code: "Minus", shiftKey: true, ctrlKey: true }, null],
[{ key: "?", code: "Minus", shiftKey: true, ctrlKey: true }, spanishMap],
];
for (const [eventOver, map] of cases) {
const { mousetrap, target } = freshInstance();
mousetrap.setLayoutMap(map);
const event = makeKeyEvent(eventOver);
const mods = ["ctrl", "alt", "shift"].filter((m) => event[m + "Key"]);
const combo = [...mods, mousetrap.keyForRecording(event)].join("+");
let calls = 0;
mousetrap.bind(combo.replace(/\+\+$/, "++"), () => {
calls++;
});
fire(target, event);
assert.equal(calls, 1, `${combo} should fire`);
}
});
});