✨ Make keyboard shortcuts layout-independent (#12326)

* Revert "🐛 Fix shortcuts with modifiers on letters and digits (#12292)"

This reverts commit 21ecfb67b70ac2d6f4defb05a543f0fc18d692a2.

* 🐛 Migrate mousetrap to key with sane modifiers

Finish the half-done mousetrap migration from code/keyCode to
key: canonical lowercase tokens on both bind and event sides so
modifiers match again, US-only shift remap and keypress legacy
removed, and AltGr phantom ctrl filtered on Windows.

Record punctuation positionally through getLayoutMap (American
fallback) so Shift+Period stores shift+. on every layout, while
letters and digits stay glyph-based for AZERTY. Drop dead 219
aliases and revert the stray CamelCase escape command.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Rewrite mousetrap as ESM class without behavior changes

Turn the 2012 constructor-plus-prototype shape into a class
with underscore-private methods, const tables and arrow
listeners. Drop dead _belongsTo, fold one-line prevent/stop
helpers into _fireCallback, and let handleKey absorb the old
_handleKey indirection. Public API and matching logic stay
identical; verified by existing tests plus a runtime smoke.

AI-assisted-by: muse-spark-1.3-contributor

* 🎉 Add node:test suite for mousetrap shortcuts

Cover canonical key normalization, modifiers with AltGr,
bind arrays plus aliases keyup reset overwrite, sequences
with fake timers, and the full stopCallback matrix, all
through isolated instances and synthetic events. Hook the
zero-dependency suite into frontend/scripts/test for CI.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Record shortcuts by glyph instead of position

Drop the getLayoutMap positional layer and the
American fallback: punctuation now records event.key
lowercased, the same token Mousetrap dispatches, so
Shift combos match with and without a layout map.
Keeps the Numpad numN branch both sides share.
Updates the pinned positional tests and adds the
Spanish Shift-plus regression.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Resolve shifted digits to the digit itself

Shift+DigitN now resolves to the digit on every layout, so
shift+0 fires on QWERTY ()) as well as AZERTY (0), like the
old keyCode behavior. Applies in the recorder and Mousetrap;
unshifted digits and numpad are untouched. Also stages the
layout-map suite left untracked.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Cover punctuation defaults with layout twins

Some defaults only fired where their glyph is unshifted (+ dead
on US layouts, shift++ dead outside ES/DE, \ dead where it
needs AltGr). Bind the twin token per layout family and freeze
the display with :show-command. No tooltip or translation
changes.

AI-assisted-by: muse-spark-1.3-contributor

* ✨ Warn when the keyboard layout API is missing

Shows a warning on the shortcuts settings page in browsers
without navigator.keyboard.getLayoutMap, where Shift with
punctuation may not fire. Uses a synchronous probe, English
and Spanish strings, and documents the layout limitations in
the user guide.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Fix hide-ui twins and qualify layout docs

The ctrl+alt+\ twin could never fire (the AltGr filter drops
Ctrl), so it goes away; alt+º covers Chrome on Spanish
layouts where AltGr+º resolves to º. The user guide now
states which claims need the layout map and which hold
everywhere.

AI-assisted-by: muse-spark-1.3-contributor

---------

Signed-off-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
This commit is contained in:
Andrey Antukh 2026-10-19 16:08:47 +02:00 committed by GitHub
parent c7f16d3236
commit 38a6961ff1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1714 additions and 938 deletions

View File

@ -35,7 +35,9 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
<p class="advice"><strong>Paste</strong> cannot be customized.</p>
<h3 id="customize-shortcuts-layouts">Keyboard layouts</h3>
<p>Punctuation keys (such as <kbd>-</kbd>, <kbd>[</kbd>, <kbd>/</kbd> or <kbd>'</kbd>) 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 <kbd>Shift</kbd>, the base key is recorded, not the shifted character.</p>
<p>Letters are recorded by the character they type, so they follow your layout (on AZERTY, the <kbd>A</kbd> key records <kbd>a</kbd>). With <kbd>Shift</kbd>, digit keys record the digit itself (<kbd>Shift</kbd><kbd>0</kbd>, not the shifted symbol), on every layout and in every browser.</p>
<p>Punctuation keys (such as <kbd>-</kbd>, <kbd>[</kbd>, <kbd>/</kbd> or <kbd>'</kbd>) 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, <kbd>Shift</kbd> with the <kbd>+</kbd> key records <kbd>Shift</kbd><kbd>+</kbd>. In browsers without that support, such as Firefox or Safari, punctuation falls back to the typed character (<kbd>Shift</kbd><kbd>*</kbd> instead): those shortcuts still work in the browser where you recorded them, but <kbd>Shift</kbd> combinations with punctuation may stop working if you switch browsers. If that affects you, record those shortcuts in a Chromium browser.</p>
<p>Two known limitations: number keys set opacity (<kbd>0</kbd>–<kbd>9</kbd>) only where digits are typed without <kbd>Shift</kbd>, so AZERTY layouts need the numeric keypad for them; and <kbd>\</kbd> needs <kbd>AltGr</kbd> on some layouts (Spanish), where it is recorded with <kbd>Alt</kbd> and its exact form depends on the browser.</p>
<h3 id="customize-shortcuts-reset">Reset to default</h3>
<p>When you have custom shortcuts, a <strong>Reset all</strong> button appears next to the search field. It lists every shortcut that will go back to its default before you confirm.</p>

View File

@ -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).

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -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);
});
});

View File

@ -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 };
}

View File

@ -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);
});
});

View File

@ -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,
);
});
});

View File

@ -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);
});
});

View File

@ -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"]);
});
});

View File

@ -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);
});
});

View File

@ -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"]);
});
});

View File

@ -280,6 +280,7 @@ lectores
letras
manifiesto
menos
navegadores
nosotros
otras
pero

View File

@ -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

View File

@ -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))}})

View File

@ -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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -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)}

View File

@ -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))}

View File

@ -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))}

View File

@ -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)}

View File

@ -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)}

View File

@ -314,3 +314,7 @@
padding: var(--sp-s) 0;
margin-block-end: var(--sp-l);
}
.layout-map-warning {
margin-block-end: var(--sp-m);
}

View File

@ -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]

View File

@ -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

View File

@ -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)}})))))

View File

@ -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})))))

View File

@ -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"

View File

@ -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"