penpot/frontend/packages/mousetrap/test/layout-map.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

175 lines
6.0 KiB
JavaScript

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);
});
it("prefers the position over the glyph when both are bound", () => {
const { mousetrap, target } = freshInstance();
mousetrap.setLayoutMap(spanishMap);
const calls = [];
mousetrap.bind("shift++", () => calls.push("shift++"));
mousetrap.bind("shift+*", () => calls.push("shift+*"));
fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true }));
assert.deepEqual(calls, ["shift++"]);
});
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);
});
// Spanish layout as Chrome reports it: the "'" key sits on Minus and
// "º" on Backquote.
const fullSpanishMap = {
get(code) {
return { BracketRight: "+", Minus: "'", Backquote: "º", Equal: "¡" }[code];
},
};
const altGraph = (over) => ({
getModifierState: (name) => name === "AltGraph",
...over,
});
it("fires ? on Spanish, where it is Shift + the ' key", () => {
const event = { key: "?", code: "Minus", shiftKey: true };
assert.equal(firedWithMap(fullSpanishMap, "?", event), 1);
});
it("matches the AltGr glyph before the key position (Linux)", () => {
// AltGr+º types "\" with no ctrl or alt on Linux.
const { mousetrap, target } = freshInstance();
mousetrap.setLayoutMap(fullSpanishMap);
const calls = [];
mousetrap.bind("\\", () => calls.push("\\"));
mousetrap.bind("º", () => calls.push("º"));
fire(target, makeKeyEvent(altGraph({ key: "\\", code: "Backquote" })));
assert.deepEqual(calls, ["\\"]);
});
it("matches the AltGr glyph when AltGr reports ctrl+alt (Windows)", () => {
const event = altGraph({ key: "\\", code: "Backquote", ctrlKey: true, altKey: true });
assert.equal(firedWithMap(fullSpanishMap, "\\", event), 1);
});
it("records the AltGr glyph, not the key position", () => {
const { mousetrap } = freshInstance();
mousetrap.setLayoutMap(fullSpanishMap);
const event = makeKeyEvent(altGraph({ key: "\\", code: "Backquote" }));
assert.equal(mousetrap.keyForRecording(event), "\\");
});
it("drops a map that disagrees with an unmodified punctuation key", () => {
// Chrome on Linux keeps the map of the first layout (Spanish) while
// US is active: the "=" key types "=" but the map says "¡".
const { mousetrap, target } = freshInstance();
mousetrap.setLayoutMap(fullSpanishMap);
const calls = [];
mousetrap.bind("=", () => calls.push("="));
mousetrap.bind("-", () => calls.push("-"));
fire(target, makeKeyEvent({ key: "=", code: "Equal" }));
fire(target, makeKeyEvent({ key: "-", code: "Minus" }));
assert.deepEqual(calls, ["=", "-"]);
assert.equal(mousetrap._layoutMap, null);
});
it("drops a disagreeing map before recording", () => {
const { mousetrap } = freshInstance();
mousetrap.setLayoutMap(fullSpanishMap);
assert.equal(mousetrap.keyForRecording(makeKeyEvent({ key: "=", code: "Equal" })), "=");
});
it("keeps a map that agrees with the typed key", () => {
const { mousetrap, target } = freshInstance();
mousetrap.setLayoutMap(fullSpanishMap);
fire(target, makeKeyEvent({ key: "+", code: "BracketRight" }));
fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true }));
fire(target, makeKeyEvent({ key: "Dead", code: "Quote" }));
assert.equal(mousetrap._layoutMap, fullSpanishMap);
});
});