mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
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
188 lines
5.8 KiB
JavaScript
188 lines
5.8 KiB
JavaScript
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" }));
|
|
// Without a layout map Shift + the Spanish "+" key types "*" and
|
|
// nothing says the key is "+", so "shift++" does not fire.
|
|
fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true }));
|
|
assert.equal(plus, 1);
|
|
assert.equal(shiftPlus, 0);
|
|
// A layout map names the key, so it does.
|
|
mousetrap.setLayoutMap({ get: (code) => (code === "BracketRight" ? "+" : undefined) });
|
|
fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true }));
|
|
assert.equal(shiftPlus, 1);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|