mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
✨ 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:
parent
c7f16d3236
commit
38a6961ff1
@ -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>
|
||||
|
||||
19
frontend/packages/mousetrap/README.md
Normal file
19
frontend/packages/mousetrap/README.md
Normal 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
@ -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"
|
||||
|
||||
183
frontend/packages/mousetrap/test/bind-dispatch.test.js
Normal file
183
frontend/packages/mousetrap/test/bind-dispatch.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
89
frontend/packages/mousetrap/test/helpers.js
Normal file
89
frontend/packages/mousetrap/test/helpers.js
Normal 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 };
|
||||
}
|
||||
95
frontend/packages/mousetrap/test/layout-map.test.js
Normal file
95
frontend/packages/mousetrap/test/layout-map.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
63
frontend/packages/mousetrap/test/modifiers.test.js
Normal file
63
frontend/packages/mousetrap/test/modifiers.test.js
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
109
frontend/packages/mousetrap/test/normalize.test.js
Normal file
109
frontend/packages/mousetrap/test/normalize.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
108
frontend/packages/mousetrap/test/sequences.test.js
Normal file
108
frontend/packages/mousetrap/test/sequences.test.js
Normal 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"]);
|
||||
});
|
||||
});
|
||||
31
frontend/packages/mousetrap/test/smoke.test.js
Normal file
31
frontend/packages/mousetrap/test/smoke.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
102
frontend/packages/mousetrap/test/stop-callback.test.js
Normal file
102
frontend/packages/mousetrap/test/stop-callback.test.js
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@ -280,6 +280,7 @@ lectores
|
||||
letras
|
||||
manifiesto
|
||||
menos
|
||||
navegadores
|
||||
nosotros
|
||||
otras
|
||||
pero
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))}})
|
||||
|
||||
@ -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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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))}
|
||||
|
||||
|
||||
@ -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))}
|
||||
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -314,3 +314,7 @@
|
||||
padding: var(--sp-s) 0;
|
||||
margin-block-end: var(--sp-l);
|
||||
}
|
||||
|
||||
.layout-map-warning {
|
||||
margin-block-end: var(--sp-m);
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}})))))
|
||||
|
||||
@ -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})))))
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user