diff --git a/docs/user-guide/first-steps/shortcuts.njk b/docs/user-guide/first-steps/shortcuts.njk index ab8279486a..e80d59f025 100644 --- a/docs/user-guide/first-steps/shortcuts.njk +++ b/docs/user-guide/first-steps/shortcuts.njk @@ -35,9 +35,9 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface

Paste cannot be customized.

Keyboard layouts

-

Letters are recorded by the character they type, so they follow your layout (on AZERTY, the A key records a). With Shift, digit keys record the digit itself (Shift0, not the shifted symbol), on every layout and in every browser.

-

Punctuation keys (such as -, [, / or ') 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, Shift with the + key records Shift+. In browsers without that support, such as Firefox or Safari, punctuation falls back to the typed character (Shift* instead): those shortcuts still work in the browser where you recorded them, but Shift combinations with punctuation may stop working if you switch browsers. If that affects you, record those shortcuts in a Chromium browser.

-

Two known limitations: number keys set opacity (0–9) only where digits are typed without Shift, so AZERTY layouts need the numeric keypad for them; and \ needs AltGr on some layouts (Spanish), where it is recorded with Alt and its exact form depends on the browser.

+

Letters are recorded by the character they type, so they follow your layout (on AZERTY, the A key records a). With Shift, Alt or another modifier, letter and digit keys record the key itself, not the character the modifier makes (Shift0, not Shift); OptionM on macOS, not µ).

+

Punctuation keys (such as -, [, / or ') are recorded by their physical position on the keyboard, so Shift with the + key records Shift+ on a Spanish layout. In browsers that do not expose the keyboard layout, such as Firefox or Safari, Penpot records the character you type instead; the shortcut still works there. Keys typed with AltGr record the character they type (\ on a Spanish layout).

+

One known limitation: number keys set opacity (0–9) only where digits are typed without Shift, so AZERTY layouts need the numeric keypad for them.

Reset to default

When you have custom shortcuts, a Reset all button appears next to the search field. It lists every shortcut that will go back to its default before you confirm.

@@ -617,11 +617,6 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface - - Add node - Shift+ - ⇧+ - Delete node, segment or handle Delete or Backspace diff --git a/frontend/packages/mousetrap/index.js b/frontend/packages/mousetrap/index.js index e3864396a4..3166b24404 100644 --- a/frontend/packages/mousetrap/index.js +++ b/frontend/packages/mousetrap/index.js @@ -94,8 +94,8 @@ const _SPECIAL_ALIASES = { /** * Physical codes treated positionally. Letters and digits resolve by * glyph (`event.key`), but punctuation resolves by position (`event.code`) - * through the layout map, so the same physical key matches on every - * layout with and without `Shift`. Without a map the typed glyph applies. + * through the layout map, or `_US_PUNCTUATION` without one, so the same + * physical key matches with and without `Shift`. See `_candidates`. * * @type {Set} */ @@ -113,6 +113,45 @@ const _PUNCTUATION_CODES = new Set([ "Slash", ]); +/** + * Primary character of each punctuation key on a US layout. Used when + * the browser has no layout map (Firefox, Safari), so punctuation still + * resolves by position and every browser names a key the same way. + * + * @type {Object} + */ +const _US_PUNCTUATION = { + Minus: "-", + Equal: "=", + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + Semicolon: ";", + Quote: "'", + Backquote: "`", + Comma: ",", + Period: ".", + Slash: "/", +}; + +/** + * Letter or digit named by `keyCode`. With a modifier held, `event.key` + * carries the glyph the modifier makes (`!` for Shift+1, `µ` for + * Option+M on macOS, or `Dead`), while `keyCode` still names the key. + * Unlike `event.code` it follows the layout, so Ctrl+Z stays `z` on + * AZERTY. + * + * @param {Event} e + * @return {string|null} + */ +function _letterOrDigitFromKeyCode(e) { + const keyCode = e.keyCode; + if ((keyCode >= 48 && keyCode <= 57) || (keyCode >= 65 && keyCode <= 90)) { + return String.fromCharCode(keyCode).toLowerCase(); + } + return null; +} + /** * Resolves an event to its canonical key. Implemented as * `Mousetrap#_characterFromEvent` so punctuation can consult the @@ -130,27 +169,38 @@ function _modifiersMatch(modifiers1, modifiers2) { return modifiers1.sort().join(",") === modifiers2.sort().join(","); } +/** + * whether the `AltGraph` modifier is active for the event + * + * @param {Event} e + * @returns {boolean} + */ +function _isAltGraph(e) { + return ( + typeof e.getModifierState === "function" && e.getModifierState("AltGraph") + ); +} + /** * takes a key event and figures out what the modifiers are * * On Windows `AltGr` reports `ctrlKey + altKey` as well. When the - * `AltGraph` modifier is active the phantom `ctrl` is dropped so typing - * (e.g. `]` on Spanish keyboards) does not fire `ctrl+alt` shortcuts. + * `AltGraph` modifier is active both phantom modifiers are dropped, so + * typing (e.g. `]` or `\` on Spanish keyboards) neither fires `ctrl+alt` + * shortcuts nor hides the plain glyph binding. * * @param {Event} e * @returns {Array} */ function _eventModifiers(e) { const modifiers = []; - const altGraph = - typeof e.getModifierState === "function" && - e.getModifierState("AltGraph"); + const altGraph = _isAltGraph(e); if (e.shiftKey) { modifiers.push("shift"); } - if (e.altKey) { + if (e.altKey && !altGraph) { modifiers.push("alt"); } @@ -383,22 +433,209 @@ class Mousetrap { } /** - * takes the event and returns the canonical key + * drops the layout map when it does not describe the active layout * - * Letters, digits and named keys resolve by glyph: single characters - * lowercase so `Shift+Z` matches a `ctrl+shift+z` bind, `" "` maps to - * `space`, and named keys map through `_KEY_NAMES`. The exception is a - * digit row key held with `Shift`, which resolves to the digit itself - * (`Digit0` types ")" on US layouts but "0" on AZERTY, so `shift+0` - * must mean the physical digit key everywhere). Punctuation - * resolves positionally via the layout map (see `setLayoutMap`), - * falling back to the glyph when no map is set. Numpad digits stay - * positional (`num0`..`num9`) so they keep their own bindings. + * Chrome on Linux returns the map of the first configured layout, not + * the active one: with Spanish first and US active, the map names the + * `=` key `¡`. A punctuation key pressed without modifiers types the + * primary character of its position, so when that differs from the + * map, punctuation goes back to the glyph and US-table fallback for the + * rest of the session. + * + * @param {Event} e + * @returns void + */ + _checkLayoutMap(e) { + if ( + !this._layoutMap || + e.type !== "keydown" || + !e.code || + !_PUNCTUATION_CODES.has(e.code) || + typeof e.key !== "string" || + e.key.length !== 1 || + e.shiftKey || + e.ctrlKey || + e.altKey || + e.metaKey || + _isAltGraph(e) + ) { + return; + } + + let mapped = null; + try { + mapped = this._layoutMap.get(e.code); + } catch { + mapped = null; + } + + if ( + typeof mapped === "string" && + mapped !== "" && + mapped.toLowerCase() !== e.key.toLowerCase() + ) { + this._layoutMap = null; + } + } + + /** + * takes the event and returns the canonical key: the first candidate + * of `_candidates`, which is also what the shortcut recorder stores + * (see `keyForRecording`). * * @param {Event} e * @return {string|null} */ _characterFromEvent(e) { + const candidates = this._candidates(e); + return candidates.length ? candidates[0][0] : null; + } + + /** + * public entry point for the shortcut recorder: the key token to store + * for this event, always one of `_candidates`, so this instance matches + * the recorded shortcut. Returns `null` for modifiers alone. + * + * Letters and digits with a modifier use `keyCode`. Punctuation uses + * its position when there is a layout map, since the map names the + * printed key. Without a map (Firefox, Safari) the typed glyph is a + * better label than the US key at that position, so the Spanish `+` + * records `+`, not `]`. With AltGr the typed glyph wins too: AltGr+º + * on a Spanish layout records `\`, the character the user typed. + * + * @param {Event} e + * @return {string|null} + */ + keyForRecording(e) { + this._checkLayoutMap(e); + + const modifiers = _eventModifiers(e); + const numpad = e.code && e.code.indexOf("Numpad") === 0; + const keyCodeKey = + modifiers.length && !numpad ? _letterOrDigitFromKeyCode(e) : null; + const position = this._positionFromEvent(e); + const glyph = this._glyphFromEvent(e); + + let key; + if (keyCodeKey) { + key = keyCodeKey; + } else if (this._layoutMap && !_isAltGraph(e)) { + key = position || glyph; + } else { + key = glyph || position; + } + + return key && !_isModifier(key) ? key : null; + } + + /** + * the `[key, modifiers]` pairs an event may match, most specific + * first. `_handleKeyEvent` dispatches the first pair that has a + * binding: + * + * 1. With a modifier held, letters and digits by `keyCode`, so + * `Shift+1` is `shift+1` and `Option+M` is `alt+m`. + * 2. Punctuation by position: the primary character from the layout + * map (see `setLayoutMap`), or from a US table when the browser has + * no map, so `Shift+*` on a Spanish layout is `shift++`. + * 3. The typed glyph (`event.key`), so `+` on a US layout (typed with + * Shift) matches a `shift++` binding. + * 4. The typed glyph without the Shift it needed, so `?` and `+` + * bindings fire. + * + * With AltGr, 3 comes before 2 (see `keyForRecording`). + * + * @param {Event} e + * @return {Array} + */ + _candidates(e) { + const modifiers = _eventModifiers(e); + const candidates = []; + const add = (key, mods) => { + if (key) { + candidates.push([key, mods]); + } + }; + + const numpad = e.code && e.code.indexOf("Numpad") === 0; + if (modifiers.length && !numpad) { + add(_letterOrDigitFromKeyCode(e), modifiers); + } + + // With AltGr the typed glyph comes before the position: AltGr+º + // types `\` on a Spanish layout, and the key at that position is `º`. + const altGraph = _isAltGraph(e); + const position = this._positionFromEvent(e); + const glyph = this._glyphFromEvent(e); + if (altGraph) { + add(glyph, modifiers); + add(position, modifiers); + } else { + add(position, modifiers); + add(glyph, modifiers); + } + + if ( + glyph && + glyph.length === 1 && + modifiers.includes("shift") && + !/[a-z0-9]/.test(glyph) + ) { + add( + glyph, + modifiers.filter((m) => m !== "shift"), + ); + } + + // On Windows Ctrl+Alt is AltGr: Ctrl+Alt+E types `€` on a Spanish + // layout. When nothing is bound to the glyph, fall back to the + // `ctrl+alt` binding of the key. + if (altGraph && e.ctrlKey && e.altKey && !numpad) { + add(_letterOrDigitFromKeyCode(e), [...modifiers, "alt", "ctrl"]); + } + + return candidates; + } + + /** + * primary character of a punctuation key by its position, or `null` + * + * @param {Event} e + * @return {string|null} + */ + _positionFromEvent(e) { + if (!e.code || !_PUNCTUATION_CODES.has(e.code)) { + return null; + } + + let mapped = null; + if (this._layoutMap) { + try { + mapped = this._layoutMap.get(e.code); + } catch { + mapped = null; + } + } + + if (typeof mapped !== "string" || mapped === "") { + mapped = _US_PUNCTUATION[e.code]; + } + + return mapped ? mapped.toLowerCase() : null; + } + + /** + * the typed glyph of the event as a canonical key + * + * Single characters lowercase so `Shift+Z` matches a `ctrl+shift+z` + * bind, `" "` maps to `space`, and named keys map through + * `_KEY_NAMES`. Numpad digits stay positional (`num0`..`num9`) so they + * keep their own bindings. + * + * @param {Event} e + * @return {string|null} + */ + _glyphFromEvent(e) { // Numpad digits as "num0".."num9" — keeps them separate from main-row bindings across NumLock states and event types. if (e.code && e.code.indexOf("Numpad") === 0) { const suffix = e.code.substring(6); @@ -407,35 +644,11 @@ class Mousetrap { } } - // Punctuation is positional: with a layout map the primary - // (unshifted) character of the physical key wins, so `Shift+*` on a - // Spanish layout (code `BracketRight`) resolves to `+` like the - // recorder stores. Without a map the typed glyph applies. - if (e.code && _PUNCTUATION_CODES.has(e.code) && this._layoutMap) { - let mapped = null; - try { - mapped = this._layoutMap.get(e.code); - } catch { - mapped = null; - } - if (typeof mapped === "string" && mapped !== "") { - return mapped.toLowerCase(); - } - } - const key = e.key; if (typeof key !== "string" || key === "") { return null; } - // Shifted digits resolve to the digit itself: with `Shift` held, - // `Digit0` types ")" on US layouts but "0" on AZERTY, so the glyph - // can't be the token. `shift+0` means the physical digit key - // everywhere, like `shift+z` means `z` regardless of `Shift`. - if (e.shiftKey && e.code && /^Digit[0-9]$/.test(e.code) && key.length === 1) { - return e.code.charAt(5); - } - if (_KEY_NAMES[key]) { return _KEY_NAMES[key]; } @@ -862,18 +1075,35 @@ class Mousetrap { * @returns void */ _handleKeyEvent(e) { - const character = this._characterFromEvent(e); + this._checkLayoutMap(e); + + const candidates = this._candidates(e); // no character found then stop - if (!character) { + if (!candidates.length) { return; } + // dispatch the first candidate that has a binding, or the first one + // so sequences still reset on unbound keys + let picked = candidates[0]; + for (const candidate of candidates) { + if ( + this._callbacks[candidate[0]] && + this._getMatches(candidate[0], candidate[1], e).length + ) { + picked = candidate; + break; + } + } + + const [character, modifiers] = picked; + if (e.type == "keyup" && this._ignoreNextKeyup === character) { this._ignoreNextKeyup = false; return; } - this.handleKey(character, _eventModifiers(e), e); + this.handleKey(character, modifiers, e); } } diff --git a/frontend/packages/mousetrap/test/bind-dispatch.test.js b/frontend/packages/mousetrap/test/bind-dispatch.test.js index 7512151126..6ae0eb45a2 100644 --- a/frontend/packages/mousetrap/test/bind-dispatch.test.js +++ b/frontend/packages/mousetrap/test/bind-dispatch.test.js @@ -36,11 +36,15 @@ describe("bind and dispatch", () => { shiftPlus++; }); fire(target, makeKeyEvent({ key: "+", code: "Equal" })); + // Without a layout map Shift + the Spanish "+" key types "*" and + // nothing says the key is "+", so "shift++" does not fire. fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); - // "*" 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); + // A layout map names the key, so it does. + mousetrap.setLayoutMap({ get: (code) => (code === "BracketRight" ? "+" : undefined) }); + fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); + assert.equal(shiftPlus, 1); }); it("resolves bind-string aliases to canonical tokens", () => { diff --git a/frontend/packages/mousetrap/test/layout-map.test.js b/frontend/packages/mousetrap/test/layout-map.test.js index 3cb34eb722..e3b914932d 100644 --- a/frontend/packages/mousetrap/test/layout-map.test.js +++ b/frontend/packages/mousetrap/test/layout-map.test.js @@ -23,7 +23,16 @@ 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("prefers the position over the glyph when both are bound", () => { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(spanishMap); + const calls = []; + mousetrap.bind("shift++", () => calls.push("shift++")); + mousetrap.bind("shift+*", () => calls.push("shift+*")); + fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); + assert.deepEqual(calls, ["shift++"]); }); it("matches the unshifted primary on the same position", () => { @@ -92,4 +101,74 @@ describe("layout map", () => { fire(target, makeKeyEvent({ key: "a", code: "KeyQ" })); assert.equal(calls, 1); }); + + // Spanish layout as Chrome reports it: the "'" key sits on Minus and + // "º" on Backquote. + const fullSpanishMap = { + get(code) { + return { BracketRight: "+", Minus: "'", Backquote: "º", Equal: "¡" }[code]; + }, + }; + + const altGraph = (over) => ({ + getModifierState: (name) => name === "AltGraph", + ...over, + }); + + it("fires ? on Spanish, where it is Shift + the ' key", () => { + const event = { key: "?", code: "Minus", shiftKey: true }; + assert.equal(firedWithMap(fullSpanishMap, "?", event), 1); + }); + + it("matches the AltGr glyph before the key position (Linux)", () => { + // AltGr+º types "\" with no ctrl or alt on Linux. + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + const calls = []; + mousetrap.bind("\\", () => calls.push("\\")); + mousetrap.bind("º", () => calls.push("º")); + fire(target, makeKeyEvent(altGraph({ key: "\\", code: "Backquote" }))); + assert.deepEqual(calls, ["\\"]); + }); + + it("matches the AltGr glyph when AltGr reports ctrl+alt (Windows)", () => { + const event = altGraph({ key: "\\", code: "Backquote", ctrlKey: true, altKey: true }); + assert.equal(firedWithMap(fullSpanishMap, "\\", event), 1); + }); + + it("records the AltGr glyph, not the key position", () => { + const { mousetrap } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + const event = makeKeyEvent(altGraph({ key: "\\", code: "Backquote" })); + assert.equal(mousetrap.keyForRecording(event), "\\"); + }); + + it("drops a map that disagrees with an unmodified punctuation key", () => { + // Chrome on Linux keeps the map of the first layout (Spanish) while + // US is active: the "=" key types "=" but the map says "¡". + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + const calls = []; + mousetrap.bind("=", () => calls.push("=")); + mousetrap.bind("-", () => calls.push("-")); + fire(target, makeKeyEvent({ key: "=", code: "Equal" })); + fire(target, makeKeyEvent({ key: "-", code: "Minus" })); + assert.deepEqual(calls, ["=", "-"]); + assert.equal(mousetrap._layoutMap, null); + }); + + it("drops a disagreeing map before recording", () => { + const { mousetrap } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + assert.equal(mousetrap.keyForRecording(makeKeyEvent({ key: "=", code: "Equal" })), "="); + }); + + it("keeps a map that agrees with the typed key", () => { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(fullSpanishMap); + fire(target, makeKeyEvent({ key: "+", code: "BracketRight" })); + fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); + fire(target, makeKeyEvent({ key: "Dead", code: "Quote" })); + assert.equal(mousetrap._layoutMap, fullSpanishMap); + }); }); diff --git a/frontend/packages/mousetrap/test/modifiers.test.js b/frontend/packages/mousetrap/test/modifiers.test.js index 748b71feeb..a12453821c 100644 --- a/frontend/packages/mousetrap/test/modifiers.test.js +++ b/frontend/packages/mousetrap/test/modifiers.test.js @@ -31,7 +31,7 @@ describe("modifiers", () => { assert.equal(fired("shift+ctrl+alt+q", event), 1); }); - it("drops the phantom ctrl while AltGraph is active", () => { + it("drops the phantom ctrl and alt while AltGraph is active", () => { const altgr = { key: "]", code: "BracketRight", @@ -40,7 +40,38 @@ describe("modifiers", () => { getModifierState: (mod) => mod === "AltGraph", }; assert.equal(fired("ctrl+alt+]", altgr), 0); - assert.equal(fired("alt+]", altgr), 1); + assert.equal(fired("alt+]", altgr), 0); + assert.equal(fired("]", altgr), 1); + }); + + it("falls back to ctrl+alt for a letter that AltGr turns into a glyph", () => { + // Spanish layout on Windows: Ctrl+Alt+E types "€". + const event = { + key: "€", + code: "KeyE", + keyCode: 69, + ctrlKey: true, + altKey: true, + getModifierState: (mod) => mod === "AltGraph", + }; + assert.equal(fired("ctrl+alt+e", event), 1); + assert.equal(fired("€", event), 1); + }); + + it("matches letters and digits by keyCode when a modifier changes the glyph", () => { + // Shift+1 types "!" on US and Spanish layouts. + assert.equal(fired("shift+1", { key: "!", code: "Digit1", keyCode: 49, shiftKey: true }), 1); + // Spanish layout: Shift+0 types "=". + assert.equal(fired("shift+0", { key: "=", code: "Digit0", keyCode: 48, shiftKey: true }), 1); + // macOS: Option+M types "µ" and Option+E is a dead key. + assert.equal(fired("alt+m", { key: "µ", code: "KeyM", keyCode: 77, altKey: true }), 1); + assert.equal(fired("alt+e", { key: "Dead", code: "KeyE", keyCode: 69, altKey: true }), 1); + assert.equal(fired("alt+shift+5", { key: "%", code: "Digit5", keyCode: 53, altKey: true, shiftKey: true }), 1); + }); + + it("keeps AZERTY letters by layout, not by position", () => { + // AZERTY: the key at the US "W" position types "z" and has keyCode 90. + assert.equal(fired("ctrl+z", { key: "z", code: "KeyW", keyCode: 90, ctrlKey: true }), 1); }); it("keeps a real ctrl+alt without AltGraph", () => { diff --git a/frontend/packages/mousetrap/test/normalize.test.js b/frontend/packages/mousetrap/test/normalize.test.js index b91b5ff40d..3219a08458 100644 --- a/frontend/packages/mousetrap/test/normalize.test.js +++ b/frontend/packages/mousetrap/test/normalize.test.js @@ -54,30 +54,54 @@ describe("canonical key normalization", () => { 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); + // the physical digit key on every layout, named by keyCode. + assert.equal(fired("shift+0", { key: ")", code: "Digit0", keyCode: 48, shiftKey: true }), 1); + assert.equal(fired("shift+1", { key: "!", code: "Digit1", keyCode: 49, shiftKey: true }), 1); + assert.equal(fired("shift+2", { key: "@", code: "Digit2", keyCode: 50, shiftKey: true }), 1); + assert.equal(fired("shift+8", { key: "*", code: "Digit8", keyCode: 56, shiftKey: true }), 1); // AZERTY shifted digits already type the digit: unchanged. - assert.equal(fired("shift+0", { key: "0", code: "Digit0", shiftKey: true }), 1); + assert.equal(fired("shift+0", { key: "0", code: "Digit0", keyCode: 48, shiftKey: true }), 1); // Unshifted digits are untouched. - assert.equal(fired("0", { key: "0", code: "Digit0" }), 1); - assert.equal(fired("&", { key: "&", code: "Digit1" }), 1); + assert.equal(fired("0", { key: "0", code: "Digit0", keyCode: 48 }), 1); }); - it("without a layout map, punctuation resolves by glyph", () => { + it("prefers the digit over the shifted glyph when both are bound", () => { + const { mousetrap, target } = freshInstance(); + const calls = []; + mousetrap.bind("shift+0", () => calls.push("shift+0")); + mousetrap.bind("shift+)", () => calls.push("shift+)")); + fire(target, makeKeyEvent({ key: ")", code: "Digit0", keyCode: 48, shiftKey: true })); + assert.deepEqual(calls, ["shift+0"]); + }); + + it("without a layout map, punctuation resolves by US position or by glyph", () => { // Spanish layout: Shift + the physical "+" key (BracketRight) types "*". + assert.equal(fired("shift+]", { key: "*", code: "BracketRight", shiftKey: true }), 1); assert.equal(fired("shift+*", { key: "*", code: "BracketRight", shiftKey: true }), 1); - assert.equal(fired("shift+]", { key: "*", code: "BracketRight", shiftKey: true }), 0); assert.equal(fired("+", { key: "+", code: "BracketRight" }), 1); - // US layout: Shift + BracketRight types "}". + // US layout: Shift + BracketRight types "}"; the glyph still matches. 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); + // US layout: Shift + Comma types "<". + assert.equal(fired("shift+,", { key: "<", code: "Comma", shiftKey: true }), 1); + assert.equal(fired("ctrl+shift+.", { key: ">", code: "Period", ctrlKey: true, shiftKey: true }), 1); + }); + + it("matches a glyph binding without the Shift the glyph needs", () => { + // US layout: "?" is Shift+Slash and "+" is Shift+Equal. + assert.equal(fired("?", { key: "?", code: "Slash", shiftKey: true }), 1); + assert.equal(fired("+", { key: "+", code: "Equal", shiftKey: true }), 1); + // Spanish layout: "?" is Shift+Minus. + assert.equal(fired("?", { key: "?", code: "Minus", shiftKey: true }), 1); + }); + + it("dispatches only the most specific match", () => { + const { mousetrap, target } = freshInstance(); + const calls = []; + mousetrap.bind("shift+/", () => calls.push("shift+/")); + mousetrap.bind("?", () => calls.push("?")); + fire(target, makeKeyEvent({ key: "?", code: "Slash", shiftKey: true })); + assert.deepEqual(calls, ["shift+/"]); }); it("lowercases function keys", () => { @@ -107,3 +131,60 @@ describe("canonical key normalization", () => { assert.equal(fired("ñ", { key: "Ñ", code: "Semicolon" }), 1); }); }); + +describe("keyForRecording", () => { + const record = (eventOver, layoutMap = null) => { + const { mousetrap } = freshInstance(); + mousetrap.setLayoutMap(layoutMap); + return mousetrap.keyForRecording(makeKeyEvent(eventOver)); + }; + const spanishMap = { get: (code) => ({ BracketRight: "+", Minus: "'" })[code] }; + + it("records letters and digits with a modifier by keyCode", () => { + assert.equal(record({ key: "!", code: "Digit1", keyCode: 49, shiftKey: true }), "1"); + assert.equal(record({ key: "µ", code: "KeyM", keyCode: 77, altKey: true }), "m"); + assert.equal(record({ key: "Dead", code: "KeyE", keyCode: 69, altKey: true }), "e"); + }); + + it("records punctuation by position when there is a layout map", () => { + assert.equal(record({ key: "*", code: "BracketRight", shiftKey: true }, spanishMap), "+"); + assert.equal(record({ key: "+", code: "BracketRight" }, spanishMap), "+"); + }); + + it("records the typed glyph when there is no layout map", () => { + assert.equal(record({ key: "+", code: "BracketRight" }), "+"); + assert.equal(record({ key: "*", code: "BracketRight", shiftKey: true }), "*"); + }); + + it("falls back to the US position for a dead key without a map", () => { + assert.equal(record({ key: "Dead", code: "Quote" }), "'"); + }); + + it("returns null for a modifier pressed alone", () => { + assert.equal(record({ key: "Shift", code: "ShiftLeft", shiftKey: true }), null); + }); + + it("records a key the dispatcher then matches", () => { + const cases = [ + [{ key: "!", code: "Digit1", keyCode: 49, shiftKey: true }, null], + [{ key: "µ", code: "KeyM", keyCode: 77, altKey: true }, null], + [{ key: "*", code: "BracketRight", shiftKey: true }, null], + [{ key: "*", code: "BracketRight", shiftKey: true }, spanishMap], + [{ key: "?", code: "Minus", shiftKey: true, ctrlKey: true }, null], + [{ key: "?", code: "Minus", shiftKey: true, ctrlKey: true }, spanishMap], + ]; + for (const [eventOver, map] of cases) { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(map); + const event = makeKeyEvent(eventOver); + const mods = ["ctrl", "alt", "shift"].filter((m) => event[m + "Key"]); + const combo = [...mods, mousetrap.keyForRecording(event)].join("+"); + let calls = 0; + mousetrap.bind(combo.replace(/\+\+$/, "++"), () => { + calls++; + }); + fire(target, event); + assert.equal(calls, 1, `${combo} should fire`); + } + }); +}); diff --git a/frontend/scripts/check-translations/words.es.txt b/frontend/scripts/check-translations/words.es.txt index ef5db91657..009f4c1c4e 100644 --- a/frontend/scripts/check-translations/words.es.txt +++ b/frontend/scripts/check-translations/words.es.txt @@ -280,7 +280,6 @@ lectores letras manifiesto menos -navegadores nosotros otras pero diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index 9a045d4995..be6d97068b 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -17,36 +17,18 @@ (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)))))) +;; Keyboard layout map for Mousetrap: punctuation resolves by physical +;; position through it. The shortcut recorder (`app.main.ui.shortcuts`) +;; asks Mousetrap for the key it stores, so both use the same map. Absent +;; until `getLayoutMap` resolves, and in browsers without the keyboard API +;; (Firefox, Safari). +(let [navigator (when (exists? js/navigator) js/navigator) + keyboard (when (some? navigator) (.-keyboard ^js navigator))] + (when (and (some? keyboard) + (exists? (.-getLayoutMap ^js keyboard))) + (-> (.getLayoutMap ^js keyboard) + (.then (fn [m] (.setLayoutMap mousetrap m)) + (fn [_] (.setLayoutMap mousetrap nil)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Helpers diff --git a/frontend/src/app/main/data/workspace/path.cljs b/frontend/src/app/main/data/workspace/path.cljs index de8decc1ba..636d253a2f 100644 --- a/frontend/src/app/main/data/workspace/path.cljs +++ b/frontend/src/app/main/data/workspace/path.cljs @@ -55,7 +55,6 @@ ;; Path tools (dm/export tools/make-curve) (dm/export tools/make-corner) -(dm/export tools/add-node) (dm/export tools/remove-node) (dm/export tools/delete-selected) (dm/export tools/delete-selected-with-segments) diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs index 21f1c87960..fbe1483de6 100644 --- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs @@ -57,14 +57,6 @@ :overwrite true :fn #(st/emit! (drp/change-edit-mode :draw))} - :add-node {:tooltip (ds/shift "+") - :label (fn [] (tr "shortcuts.add-node")) - :command ["shift++" "shift+="] - :show-command "shift++" - :subsections [:path-editor] - :section [:workspace] - :fn #(st/emit! (drp/add-node))} - :delete-node {:tooltip (ds/supr) :label (fn [] (tr "shortcuts.delete-node")) :command ["del" "backspace"] diff --git a/frontend/src/app/main/data/workspace/path/tools.cljs b/frontend/src/app/main/data/workspace/path/tools.cljs index fe25a9e13e..54fa954410 100644 --- a/frontend/src/app/main/data/workspace/path/tools.cljs +++ b/frontend/src/app/main/data/workspace/path/tools.cljs @@ -121,9 +121,6 @@ (fn [ht] (reduce #(assoc %1 %2 type) (or ht {}) nodes))))) state))))) -(defn add-node [] - (process-path-tool (fn [content points] (path/split-segments content points 0.5)))) - (defn remove-node "Removes nodes and heals the gap with a fitted curve." ([] diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 17246e5889..230a242a93 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -679,8 +679,7 @@ :hide-ui {:tooltip "\\" :label (fn [] (tr "shortcuts.hide-ui")) - :command ["\\" "alt+\\" "alt+º"] - :show-command "\\" + :command "\\" :subsections [:panels :basics] :section [:workspace :basics] :fn #(st/emit! (toggle-layout-flag :hide-ui))} @@ -717,8 +716,7 @@ :zoom-selected {:tooltip (ds/shift "2") :label (fn [] (tr "shortcuts.zoom-selected")) - :command ["shift+2" "shift+num2" "shift+'" "shift+\""] - :show-command "shift+2" + :command ["shift+2" "shift+num2"] :subsections [:zoom-workspace] :section [:workspace] :fn #(st/emit! dw/zoom-to-selected-shape)} diff --git a/frontend/src/app/main/ui/settings/shortcuts.cljs b/frontend/src/app/main/ui/settings/shortcuts.cljs index 841841ac5b..e1e9b884c0 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.cljs +++ b/frontend/src/app/main/ui/settings/shortcuts.cljs @@ -23,7 +23,6 @@ [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] @@ -529,10 +528,6 @@ [:> 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)} diff --git a/frontend/src/app/main/ui/settings/shortcuts.scss b/frontend/src/app/main/ui/settings/shortcuts.scss index c86ca879b5..522147f61e 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.scss +++ b/frontend/src/app/main/ui/settings/shortcuts.scss @@ -314,7 +314,3 @@ padding: var(--sp-s) 0; margin-block-end: var(--sp-l); } - -.layout-map-warning { - margin-block-end: var(--sp-m); -} diff --git a/frontend/src/app/main/ui/shortcuts.cljs b/frontend/src/app/main/ui/shortcuts.cljs index 30aed17a2c..4166314a37 100644 --- a/frontend/src/app/main/ui/shortcuts.cljs +++ b/frontend/src/app/main/ui/shortcuts.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.shortcuts (:require-macros [app.main.style :as stl]) (:require + ["@penpot/mousetrap$default" :as mousetrap] [app.common.data :as d] [app.common.data.macros :as dm] [app.config :as cf] @@ -34,10 +35,6 @@ (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" @@ -202,71 +199,16 @@ (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." + "Returns the key token Mousetrap matches for `event`, asking Mousetrap + itself (`keyForRecording`) so a recorded shortcut always fires: letters + and digits with a modifier by keyCode (`shift+1`, `alt+m`), punctuation + by position when the browser has a layout map, the typed glyph + otherwise." [^js event] - (let [native (event->native event) - key (.-key ^js event) - code (.-code ^js native)] + (let [key (.-key event)] (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)))))) + (.keyForRecording ^js mousetrap (event->native event))))) (defn- event->modifier-parts [^js event] @@ -281,7 +223,8 @@ (cf/check-platform? :macos)) (conj "command") - (.-altKey event) + (and (.-altKey event) + (not alt-graph?)) (conj "alt") (.-shiftKey event) diff --git a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs index 3ed7e035c9..f2df889ef0 100644 --- a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs +++ b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs @@ -1,5 +1,6 @@ (ns frontend-tests.ui.settings-shortcuts-test (:require + ["@penpot/mousetrap$default" :as mousetrap] [app.config :as cf] [app.main.data.dashboard.shortcuts :as dsc] [app.main.data.profile :as du] @@ -495,17 +496,25 @@ (defn- mock-wrapped-keydown "Simulates a goog.events.BrowserEvent-wrapped keydown: `key` and modifiers - are forwarded directly, and `code` is only reachable via `getBrowserEvent`. - `key` is passed through as-is (not defaulted) so callers can simulate a - missing `event.key` with `:key nil` - substituting \"\" would defeat that, - since an empty string is truthy in ClojureScript." - [{:keys [key code shift ctrl alt meta]}] - #js {:key key - :shiftKey (boolean shift) - :ctrlKey (boolean ctrl) - :altKey (boolean alt) - :metaKey (boolean meta) - :getBrowserEvent (fn [] #js {:code code})}) + are forwarded directly, and the native event behind `getBrowserEvent` + carries every field (`code` and `keyCode` only live there). `key` is + passed through as-is (not defaulted) so callers can simulate a missing + `event.key` with `:key nil` - substituting \"\" would defeat that, since + an empty string is truthy in ClojureScript." + [{:keys [key code key-code shift ctrl alt meta]}] + (let [native #js {:key key + :code code + :keyCode key-code + :shiftKey (boolean shift) + :ctrlKey (boolean ctrl) + :altKey (boolean alt) + :metaKey (boolean meta)}] + #js {:key key + :shiftKey (boolean shift) + :ctrlKey (boolean ctrl) + :altKey (boolean alt) + :metaKey (boolean meta) + :getBrowserEvent (fn [] native)})) (defn- mock-native-keydown "Simulates a plain, unwrapped native KeyboardEvent (no getBrowserEvent), @@ -531,7 +540,7 @@ even when the typed glyph differs" (with-redefs [cf/check-platform? (constantly false)] (try - (reset! ds/layout-map #js {:get (fn [code] (when (= code "Period") "."))}) + (.setLayoutMap mousetrap #js {:get (fn [code] (when (= code "Period") "."))}) (t/is (= "shift+." (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key ">" :code "Period" :shift true})))) @@ -539,7 +548,7 @@ (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key ":" :code "Period" :shift true})))) (finally - (reset! ds/layout-map nil)))))) + (.setLayoutMap mousetrap nil)))))) (t/deftest mousetrap-shift-period-on-spanish-iso-layout (t/testing "Spanish (ISO) layout without a layout map: Shift + Period @@ -633,28 +642,27 @@ (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)" + digit key means the same on every layout (named by keyCode)" (with-redefs [cf/check-platform? (constantly false)] (t/is (= "shift+0" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key ")" :code "Digit0" :shift true})))) + (mock-wrapped-keydown {:key ")" :code "Digit0" :key-code 48 :shift true})))) (t/is (= "shift+1" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key "!" :code "Digit1" :shift true})))) + (mock-wrapped-keydown {:key "!" :code "Digit1" :key-code 49 :shift true})))) (t/is (= "shift+2" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key "@" :code "Digit2" :shift true})))) + (mock-wrapped-keydown {:key "@" :code "Digit2" :key-code 50 :shift true})))) (t/is (= "shift+8" (#'ui-shortcuts/keyboard-event->mousetrap - (mock-wrapped-keydown {:key "*" :code "Digit8" :shift true}))))))) + (mock-wrapped-keydown {:key "*" :code "Digit8" :key-code 56 :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}))))))) + (mock-wrapped-keydown {:key "0" :code "Digit0" :key-code 48 :shift true}))))))) (t/deftest mousetrap-space-and-named-keys (t/testing "\" \" records as space and named keys stay lowercase" @@ -672,15 +680,15 @@ (#'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" +(t/deftest mousetrap-altgr-drops-phantom-modifiers + (t/testing "Spanish AltGr+] types \"]\" with ctrl+alt flags, but both + phantom modifiers are dropped and it records the glyph" (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+]" + (t/is (= "]" (#'ui-shortcuts/keyboard-event->mousetrap event))))))) (t/deftest mousetrap-real-ctrl-alt-keeps-both @@ -699,7 +707,7 @@ 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") "+"))}) + (.setLayoutMap mousetrap #js {:get (fn [code] (when (= code "BracketRight") "+"))}) (t/is (= "shift++" (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "*" :code "BracketRight" :shift true})))) @@ -707,7 +715,7 @@ (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "+" :code "BracketRight"})))) (finally - (reset! ds/layout-map nil)))))) + (.setLayoutMap mousetrap nil)))))) (t/deftest mousetrap-spanish-shift-plus-without-layout-map-records-glyph (t/testing "Spanish layout without a layout map: Shift + the physical @@ -725,11 +733,24 @@ (#'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)}}))))) +(t/deftest mousetrap-shift-digit-records-the-digit + (t/testing "Shift+1 types \"!\" but records \"shift+1\", the key the user + sees and the default zoom shortcuts use" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "shift+1" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "!" :code "Digit1" :key-code 49 :shift true})))) + (t/is (= "shift+0" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "=" :code "Digit0" :key-code 48 :shift true}))))))) + +(t/deftest mousetrap-option-letter-records-the-letter + (t/testing "macOS Option+M types \"µ\" and Option+E is a dead key, but + both record the letter" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "alt+m" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "µ" :code "KeyM" :key-code 77 :alt true})))) + (t/is (= "alt+e" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "Dead" :code "KeyE" :key-code 69 :alt true}))))))) diff --git a/frontend/translations/ar.po b/frontend/translations/ar.po index a027c7f3dd..0b07c80ca7 100644 --- a/frontend/translations/ar.po +++ b/frontend/translations/ar.po @@ -1727,10 +1727,6 @@ msgstr "مُختلط" msgid "shortcuts.add-comment" msgstr "تعليقات" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "إضافة عقدة" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "محاذاة لأسفل" diff --git a/frontend/translations/ca.po b/frontend/translations/ca.po index 900e2048d1..005dc3b908 100644 --- a/frontend/translations/ca.po +++ b/frontend/translations/ca.po @@ -4745,10 +4745,6 @@ msgstr "Selecciona els elements que utilitzen aquest estil" msgid "shortcuts.add-comment" msgstr "Comentaris" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Afegeix node" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alinea a baix" diff --git a/frontend/translations/cs.po b/frontend/translations/cs.po index 6807d634eb..5760d638f1 100644 --- a/frontend/translations/cs.po +++ b/frontend/translations/cs.po @@ -2597,10 +2597,6 @@ msgstr "Vyberte položky pomocí tohoto stylu" msgid "shortcuts.add-comment" msgstr "Komentáře" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Přidat uzel" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Zarovnat dolů" diff --git a/frontend/translations/de.po b/frontend/translations/de.po index b535d2d859..b04b506455 100644 --- a/frontend/translations/de.po +++ b/frontend/translations/de.po @@ -3507,10 +3507,6 @@ msgstr "Elemente mit diesem Stil auswählen" msgid "shortcuts.add-comment" msgstr "Kommentare" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Punkt hinzufügen" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Unten ausrichten" diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 2f4810c536..861862dcc3 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -4761,10 +4761,6 @@ msgstr "Select items using this style" msgid "shortcuts.add-comment" msgstr "Comments" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Add node" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Align bottom" @@ -5041,13 +5037,6 @@ 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" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 137798f51f..5ebc56901b 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -4815,10 +4815,6 @@ msgstr "Seleccionar elementos que usan este estilo" msgid "shortcuts.add-comment" msgstr "Añadir comentarios" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Añadir nodo" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alinear abajo" @@ -5087,13 +5083,6 @@ 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" diff --git a/frontend/translations/eu.po b/frontend/translations/eu.po index 78a7089a5e..4594f82431 100644 --- a/frontend/translations/eu.po +++ b/frontend/translations/eu.po @@ -1862,10 +1862,6 @@ msgstr "Batzuk" msgid "shortcuts.add-comment" msgstr "Gehitu iruzkinak" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Gehitu nodoa" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Lerrokatu behean" diff --git a/frontend/translations/fa.po b/frontend/translations/fa.po index bf01868ef6..a8c6460fba 100644 --- a/frontend/translations/fa.po +++ b/frontend/translations/fa.po @@ -4678,10 +4678,6 @@ msgstr "انتخاب موارد با استفاده از این سبک" msgid "shortcuts.add-comment" msgstr "نظرات" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "افزودن گره" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "تراز به پایین" diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index 5686f05331..63c56a4fc0 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -4785,10 +4785,6 @@ msgstr "Sélectionnez les éléments qui utilisent ce style" msgid "shortcuts.add-comment" msgstr "Commentaires" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Ajouter un nœud" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Aligner en bas" diff --git a/frontend/translations/fr_CA.po b/frontend/translations/fr_CA.po index 79fb1a0323..e5b34259e6 100644 --- a/frontend/translations/fr_CA.po +++ b/frontend/translations/fr_CA.po @@ -4509,10 +4509,6 @@ msgstr "Sélectionner les éléments avec ce style" msgid "shortcuts.add-comment" msgstr "Commentaires" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Ajouter un nœud" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Aligner en bas" diff --git a/frontend/translations/ha.po b/frontend/translations/ha.po index dbc38704ca..6382d0fa45 100644 --- a/frontend/translations/ha.po +++ b/frontend/translations/ha.po @@ -1957,10 +1957,6 @@ msgstr "gauraya" msgid "shortcuts.add-comment" msgstr "Bayyana Ra'ayi" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "kara Girma" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "tsarin kasa" diff --git a/frontend/translations/he.po b/frontend/translations/he.po index 0e9eabca97..38e7e83532 100644 --- a/frontend/translations/he.po +++ b/frontend/translations/he.po @@ -3967,10 +3967,6 @@ msgstr "בחירת פריטים שמשתמשים בסגנון הזה" msgid "shortcuts.add-comment" msgstr "הערות" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "הוספת מפרק" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "יישור לתחתית" diff --git a/frontend/translations/hi.po b/frontend/translations/hi.po index b55d4239cd..7d0d01d99d 100644 --- a/frontend/translations/hi.po +++ b/frontend/translations/hi.po @@ -3362,10 +3362,6 @@ msgstr "इस शैली का उपयोग करके आइटम च msgid "shortcuts.add-comment" msgstr "टिप्पणियाँ" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "नोड जोड़ें" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "नीचे संरेखित करें" diff --git a/frontend/translations/hr.po b/frontend/translations/hr.po index b3c94b2d6a..93081b3c64 100644 --- a/frontend/translations/hr.po +++ b/frontend/translations/hr.po @@ -2596,10 +2596,6 @@ msgstr "Odaberite stavke koristeći ovaj stil" msgid "shortcuts.add-comment" msgstr "Komentari" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Dodaj čvor" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Poravnaj dno" diff --git a/frontend/translations/id.po b/frontend/translations/id.po index 03058d1683..250ff26b5f 100644 --- a/frontend/translations/id.po +++ b/frontend/translations/id.po @@ -2805,10 +2805,6 @@ msgstr "Pilih item menggunakan gaya ini" msgid "shortcuts.add-comment" msgstr "Komentar" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Tambahkan simpul" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Sesuaikan ke bawah" diff --git a/frontend/translations/it.po b/frontend/translations/it.po index 3512c5d007..3fa5b6d7b9 100644 --- a/frontend/translations/it.po +++ b/frontend/translations/it.po @@ -3875,10 +3875,6 @@ msgstr "Seleziona gli elementi che utilizzano questo stile" msgid "shortcuts.add-comment" msgstr "Commenti" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Aggiungi nodo" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Allinea in basso" diff --git a/frontend/translations/jpn_JP.po b/frontend/translations/jpn_JP.po index 7b0a3d78b9..65ee25f35b 100644 --- a/frontend/translations/jpn_JP.po +++ b/frontend/translations/jpn_JP.po @@ -4521,10 +4521,6 @@ msgstr "このスタイルを使用している要素を選択" msgid "shortcuts.add-comment" msgstr "コメント" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "ノードを追加" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "下揃え" diff --git a/frontend/translations/ko.po b/frontend/translations/ko.po index fdb131f37d..e3beca6dcc 100644 --- a/frontend/translations/ko.po +++ b/frontend/translations/ko.po @@ -3200,10 +3200,6 @@ msgstr "이 스타일을 사용하는 항목 선택" msgid "shortcuts.add-comment" msgstr "댓글" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "노드 추가" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "아래 정렬" diff --git a/frontend/translations/lv.po b/frontend/translations/lv.po index 6f2f7b95a9..33ab93be32 100644 --- a/frontend/translations/lv.po +++ b/frontend/translations/lv.po @@ -3106,10 +3106,6 @@ msgstr "Atlasīt vienumus, kas izmanto šo stilu" msgid "shortcuts.add-comment" msgstr "Komentāri" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Pievienot mezglu" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Līdzināt pie apakšas" diff --git a/frontend/translations/ms.po b/frontend/translations/ms.po index 4a858ea689..42d2623a31 100644 --- a/frontend/translations/ms.po +++ b/frontend/translations/ms.po @@ -2019,10 +2019,6 @@ msgstr "Bercampur" msgid "shortcuts.add-comment" msgstr "Komen" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Tambah nota" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Sejajarkan bahagian bawah" diff --git a/frontend/translations/nl.po b/frontend/translations/nl.po index 3d55474017..678e93c364 100644 --- a/frontend/translations/nl.po +++ b/frontend/translations/nl.po @@ -4755,10 +4755,6 @@ msgstr "Selecteer items in deze stijl" msgid "shortcuts.add-comment" msgstr "Commentaar" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Knooppunt toevoegen" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Onderaan uitlijnen" diff --git a/frontend/translations/pl.po b/frontend/translations/pl.po index c3afe44ee7..352c7e9eec 100644 --- a/frontend/translations/pl.po +++ b/frontend/translations/pl.po @@ -1856,10 +1856,6 @@ msgstr "Mieszane" msgid "shortcuts.add-comment" msgstr "Komentarze" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Dodaj węzeł" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Wyrównaj do dołu" diff --git a/frontend/translations/pt_BR.po b/frontend/translations/pt_BR.po index 3c27a3205f..0847bf55bf 100644 --- a/frontend/translations/pt_BR.po +++ b/frontend/translations/pt_BR.po @@ -2511,10 +2511,6 @@ msgstr "Misto" msgid "shortcuts.add-comment" msgstr "Comentários" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Adicionar ponto" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alinhar a base" diff --git a/frontend/translations/pt_PT.po b/frontend/translations/pt_PT.po index 3140b7a1a6..57f7cbd423 100644 --- a/frontend/translations/pt_PT.po +++ b/frontend/translations/pt_PT.po @@ -2657,10 +2657,6 @@ msgstr "Selecionar itens que usem este estilo" msgid "shortcuts.add-comment" msgstr "Comentários" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Adicionar nó" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alinhar abaixo" diff --git a/frontend/translations/ro.po b/frontend/translations/ro.po index d4b4fc6751..2581de553f 100644 --- a/frontend/translations/ro.po +++ b/frontend/translations/ro.po @@ -3189,10 +3189,6 @@ msgstr "Alege elemente folosind acest stil" msgid "shortcuts.add-comment" msgstr "Comentarii" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Adaugă nod" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Aliniază jos" diff --git a/frontend/translations/ru.po b/frontend/translations/ru.po index 72cd5b5d4b..24fcebf98a 100644 --- a/frontend/translations/ru.po +++ b/frontend/translations/ru.po @@ -3227,10 +3227,6 @@ msgstr "Выбрать элементы, использующие этот ст msgid "shortcuts.add-comment" msgstr "Комментарии" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Добавить узел" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Выровнять по нижнему краю" diff --git a/frontend/translations/sr.po b/frontend/translations/sr.po index 589b4d6c88..75ebabcdde 100644 --- a/frontend/translations/sr.po +++ b/frontend/translations/sr.po @@ -2200,10 +2200,6 @@ msgstr "Изаберите ставке користећи овај стил" msgid "shortcuts.add-comment" msgstr "Коментари" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Додај чвор" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Поравнај на дно" diff --git a/frontend/translations/sv.po b/frontend/translations/sv.po index 293b654895..c749f67233 100644 --- a/frontend/translations/sv.po +++ b/frontend/translations/sv.po @@ -4706,10 +4706,6 @@ msgstr "Välj objekt med denna stil" msgid "shortcuts.add-comment" msgstr "Kommentarer" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Lägg till nod" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Justera botten" diff --git a/frontend/translations/tr.po b/frontend/translations/tr.po index adbc51754c..853e76ab7a 100644 --- a/frontend/translations/tr.po +++ b/frontend/translations/tr.po @@ -4702,10 +4702,6 @@ msgstr "Bu biçimi kullanan ögeleri seç" msgid "shortcuts.add-comment" msgstr "Yorumlar" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Düğüm ekle" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Alta hizala" diff --git a/frontend/translations/ukr_UA.po b/frontend/translations/ukr_UA.po index e716947918..519ce16bf8 100644 --- a/frontend/translations/ukr_UA.po +++ b/frontend/translations/ukr_UA.po @@ -3581,10 +3581,6 @@ msgstr "Оберіть елементи, використовуючи цей с msgid "shortcuts.add-comment" msgstr "Коментарі" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Додати вузол" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Вирівняти по низу" diff --git a/frontend/translations/yo.po b/frontend/translations/yo.po index 1f8e5ab1b4..23d4d7dfc4 100644 --- a/frontend/translations/yo.po +++ b/frontend/translations/yo.po @@ -1844,10 +1844,6 @@ msgstr "Dàárú" msgid "shortcuts.add-comment" msgstr "Ọ́rọ̀íwòye" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "Fí ípàdé kùn" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "Sópọ sí ísàle" diff --git a/frontend/translations/zh_CN.po b/frontend/translations/zh_CN.po index 94376eaabc..f17366656f 100644 --- a/frontend/translations/zh_CN.po +++ b/frontend/translations/zh_CN.po @@ -3948,10 +3948,6 @@ msgstr "选择使用此样式的项目" msgid "shortcuts.add-comment" msgstr "评论" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "添加节点" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "底部对齐" diff --git a/frontend/translations/zh_Hant.po b/frontend/translations/zh_Hant.po index ebb8577137..56f41a99d3 100644 --- a/frontend/translations/zh_Hant.po +++ b/frontend/translations/zh_Hant.po @@ -4443,10 +4443,6 @@ msgstr "選取使用此樣式的項目" msgid "shortcuts.add-comment" msgstr "留言" -#: src/app/main/data/workspace/path/shortcuts.cljs:61 -msgid "shortcuts.add-node" -msgstr "新增節點" - #: src/app/main/data/workspace/shortcuts.cljs:532 msgid "shortcuts.align-bottom" msgstr "靠下對齊"