fix: undo/redo action for international keyboard layouts (#8649)

Co-authored-by: Marcel Mraz <marcel@excalidraw.com>
pull/8533/merge
Denis Mishankov 3 months ago committed by GitHub
parent 61623bbeba
commit eb09b48ae6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -5,7 +5,7 @@ import { t } from "../i18n";
import type { History } from "../history";
import { HistoryChangedEvent } from "../history";
import type { AppClassProperties, AppState } from "../types";
import { KEYS } from "../keys";
import { KEYS, matchKey } from "../keys";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
import type { SceneElementsMap } from "../element/types";
@ -63,9 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
),
),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter,
@ -104,10 +102,8 @@ export const createRedoAction: ActionCreator = (history, store) => ({
),
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,

@ -0,0 +1,271 @@
import { KEYS, matchKey } from "./keys";
describe("key matcher", async () => {
it("should not match unexpected key", async () => {
expect(
matchKey(new KeyboardEvent("keydown", { key: "N" }), KEYS.Y),
).toBeFalsy();
expect(
matchKey(new KeyboardEvent("keydown", { key: "Unidentified" }), KEYS.Z),
).toBeFalsy();
expect(
matchKey(new KeyboardEvent("keydown", { key: "z" }), KEYS.Y),
).toBeFalsy();
expect(
matchKey(new KeyboardEvent("keydown", { key: "y" }), KEYS.Z),
).toBeFalsy();
expect(
matchKey(new KeyboardEvent("keydown", { key: "Z" }), KEYS.Y),
).toBeFalsy();
expect(
matchKey(new KeyboardEvent("keydown", { key: "Y" }), KEYS.Z),
).toBeFalsy();
});
it("should match key (case insensitive) when key is latin", async () => {
expect(
matchKey(new KeyboardEvent("keydown", { key: "z" }), KEYS.Z),
).toBeTruthy();
expect(
matchKey(new KeyboardEvent("keydown", { key: "y" }), KEYS.Y),
).toBeTruthy();
expect(
matchKey(new KeyboardEvent("keydown", { key: "Z" }), KEYS.Z),
).toBeTruthy();
expect(
matchKey(new KeyboardEvent("keydown", { key: "Y" }), KEYS.Y),
).toBeTruthy();
});
it("should match key on QWERTY, QWERTZ, AZERTY", async () => {
// QWERTY
expect(
matchKey(
new KeyboardEvent("keydown", { key: "z", code: "KeyZ" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "y", code: "KeyY" }),
KEYS.Y,
),
).toBeTruthy();
// QWERTZ
expect(
matchKey(
new KeyboardEvent("keydown", { key: "z", code: "KeyY" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "y", code: "KeyZ" }),
KEYS.Y,
),
).toBeTruthy();
// AZERTY
expect(
matchKey(
new KeyboardEvent("keydown", { key: "z", code: "KeyW" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "y", code: "KeyY" }),
KEYS.Y,
),
).toBeTruthy();
});
it("should match key on DVORAK, COLEMAK", async () => {
// DVORAK
expect(
matchKey(
new KeyboardEvent("keydown", { key: "z", code: "KeySemicolon" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "y", code: "KeyF" }),
KEYS.Y,
),
).toBeTruthy();
// COLEMAK
expect(
matchKey(
new KeyboardEvent("keydown", { key: "z", code: "KeyZ" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "y", code: "KeyJ" }),
KEYS.Y,
),
).toBeTruthy();
});
it("should match key on Turkish-Q", async () => {
// Turkish-Q
expect(
matchKey(
new KeyboardEvent("keydown", { key: "z", code: "KeyN" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "Y", code: "KeyY" }),
KEYS.Y,
),
).toBeTruthy();
});
it("should not fallback when code is not defined", async () => {
expect(
matchKey(new KeyboardEvent("keydown", { key: "я" }), KEYS.Z),
).toBeFalsy();
expect(
matchKey(new KeyboardEvent("keydown", { key: "卜" }), KEYS.Y),
).toBeFalsy();
});
it("should not fallback when code is incorrect", async () => {
expect(
matchKey(
new KeyboardEvent("keydown", { key: "z", code: "KeyY" }),
KEYS.Y,
),
).toBeFalsy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "Y", code: "KeyZ" }),
KEYS.Z,
),
).toBeFalsy();
});
it("should fallback to code when key is non-latin", async () => {
// Macedonian
expect(
matchKey(
new KeyboardEvent("keydown", { key: "з", code: "KeyZ" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "ѕ", code: "KeyY" }),
KEYS.Y,
),
).toBeTruthy();
// Russian
expect(
matchKey(
new KeyboardEvent("keydown", { key: "я", code: "KeyZ" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "н", code: "KeyY" }),
KEYS.Y,
),
).toBeTruthy();
// Serbian
expect(
matchKey(
new KeyboardEvent("keydown", { key: "ѕ", code: "KeyZ" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "з", code: "KeyY" }),
KEYS.Y,
),
).toBeTruthy();
// Greek
expect(
matchKey(
new KeyboardEvent("keydown", { key: "ζ", code: "KeyZ" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "υ", code: "KeyY" }),
KEYS.Y,
),
).toBeTruthy();
// Hebrew
expect(
matchKey(
new KeyboardEvent("keydown", { key: "ז", code: "KeyZ" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "ט", code: "KeyY" }),
KEYS.Y,
),
).toBeTruthy();
// Cangjie - Traditional
expect(
matchKey(
new KeyboardEvent("keydown", { key: "重", code: "KeyZ" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "卜", code: "KeyY" }),
KEYS.Y,
),
).toBeTruthy();
// Japanese
expect(
matchKey(
new KeyboardEvent("keydown", { key: "つ", code: "KeyZ" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "ん", code: "KeyY" }),
KEYS.Y,
),
).toBeTruthy();
// 2-Set Korean
expect(
matchKey(
new KeyboardEvent("keydown", { key: "ㅋ", code: "KeyZ" }),
KEYS.Z,
),
).toBeTruthy();
expect(
matchKey(
new KeyboardEvent("keydown", { key: "ㅛ", code: "KeyY" }),
KEYS.Y,
),
).toBeTruthy();
});
});

@ -1,4 +1,5 @@
import { isDarwin } from "./constants";
import type { ValueOf } from "./utility-types";
export const CODES = {
EQUAL: "Equal",
@ -20,6 +21,7 @@ export const CODES = {
H: "KeyH",
V: "KeyV",
Z: "KeyZ",
Y: "KeyY",
R: "KeyR",
S: "KeyS",
} as const;
@ -83,6 +85,54 @@ export const KEYS = {
export type Key = keyof typeof KEYS;
// defines key code mapping for matching codes as fallback to respective keys on non-latin keyboard layouts
export const KeyCodeMap = new Map<ValueOf<typeof KEYS>, ValueOf<typeof CODES>>([
[KEYS.Z, CODES.Z],
[KEYS.Y, CODES.Y],
]);
export const isLatinChar = (key: string) => /^[a-z]$/.test(key.toLowerCase());
/**
* Used to match key events for any keyboard layout, especially on Windows and Linux,
* where non-latin character with modified (CMD) is not substituted with latin-based alternative.
*
* Uses `event.key` when it's latin, otherwise fallbacks to `event.code` (if mapping exists).
*
* Example of pressing "z" on different layouts, with the chosen key or code highlighted in []:
*
* Layout | Code | Key | Comment
* --------------------- | ----- | --- | -------
* U.S. | KeyZ | [z] |
* Czech | KeyY | [z] |
* Turkish | KeyN | [z] |
* French | KeyW | [z] |
* Macedonian | [KeyZ] | з | z with cmd; з is Cyrillic equivalent of z
* Russian | [KeyZ] | я | z with cmd
* Serbian | [KeyZ] | ѕ | z with cmd
* Greek | [KeyZ] | ζ | z with cmd; also ζ is Greek equivalent of z
* Hebrew | [KeyZ] | ז | z with cmd; also ז is Hebrew equivalent of z
* Pinyin - Simplified | KeyZ | [z] | due to IME
* Cangije - Traditional | [KeyZ] | | z with cmd
* Japanese | [KeyZ] | | z with cmd
* 2-Set Korean | [KeyZ] | | z with cmd
*
* More details in https://github.com/excalidraw/excalidraw/pull/5944
*/
export const matchKey = (
event: KeyboardEvent | React.KeyboardEvent<Element>,
key: ValueOf<typeof KEYS>,
): boolean => {
// for latin layouts use key
if (key === event.key.toLowerCase()) {
return true;
}
// non-latin layouts fallback to code
const code = KeyCodeMap.get(key);
return Boolean(code && !isLatinChar(event.key) && event.code === code);
};
export const isArrowKey = (key: string) =>
key === KEYS.ARROW_LEFT ||
key === KEYS.ARROW_RIGHT ||

Loading…
Cancel
Save