🐛 Fix default shortcuts that fail on some layouts and browsers (#12506)

Fixes the problems found after #12326 and listed in #12327.

Mousetrap now tries several names for each key press, in order, and
fires the first one with a binding: letters and digits by keyCode when
a modifier is held, punctuation by position (layout map, or a US table
when the browser has none), the typed glyph, and the typed glyph
without the Shift it needed. With AltGr the typed glyph comes before
the position. The shortcut recorder asks Mousetrap for the key it
stores, so a recorded shortcut always fires.

This makes "?" and "+" work on every layout, Shift and Ctrl+Shift with
punctuation work in Firefox and Safari, Option+letter work on macOS,
and AltGr+º hide the UI on Linux with a Spanish layout.

Chrome on Linux returns the map of the first configured layout, not
the active one. Mousetrap now drops the map when an unmodified
punctuation key types something else.

The layout twins are no longer needed and are removed: "shift+'" made
"?" zoom to the selection on Spanish layouts. The Add node shortcut is
removed too: on US layouts its key is the "+" zoom key, and on Firefox
with a Spanish layout it cannot fire. The Firefox and Safari warning
in the shortcuts settings is gone, since those shortcuts now work
there. The user guide and translations follow these changes.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
Eva Marco 2026-10-26 11:56:09 +01:00 committed by GitHub
parent 3cd073fc6a
commit 68ecfb1234
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 572 additions and 376 deletions

View File

@ -35,9 +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>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>
<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>, <kbd>Alt</kbd> or another modifier, letter and digit keys record the key itself, not the character the modifier makes (<kbd>Shift</kbd><kbd>0</kbd>, not <kbd>Shift</kbd><kbd>)</kbd>; <kbd>Option</kbd><kbd>M</kbd> on macOS, not <kbd>µ</kbd>).</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 <kbd>Shift</kbd> with the <kbd>+</kbd> key records <kbd>Shift</kbd><kbd>+</kbd> 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 <kbd>AltGr</kbd> record the character they type (<kbd>\</kbd> on a Spanish layout).</p>
<p>One known limitation: 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.</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>
@ -617,11 +617,6 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
</tr>
</thead>
<tbody>
<tr>
<td>Add node</td>
<td style="text-align: center;"><kbd>Shift</kbd><kbd>+</kbd></td>
<td style="text-align: center;"><kbd>⇧</kbd><kbd>+</kbd></td>
</tr>
<tr>
<td>Delete node, segment or handle</td>
<td style="text-align: center;"><kbd>Delete</kbd> or <kbd>Backspace</kbd></td>

View File

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

View File

@ -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", () => {

View File

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

View File

@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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."
([]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 "محاذاة لأسفل"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 "تراز به پایین"

View File

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

View File

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

View File

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

View File

@ -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 "יישור לתחתית"

View File

@ -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 "नीचे संरेखित करें"

View File

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

View File

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

View File

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

View File

@ -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 "下揃え"

View File

@ -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 "아래 정렬"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 "Выровнять по нижнему краю"

View File

@ -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 "Поравнај на дно"

View File

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

View File

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

View File

@ -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 "Вирівняти по низу"

View File

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

View File

@ -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 "底部对齐"

View File

@ -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 "靠下對齊"