From eb09b48ae63ef2e9025dc3905a45c969eafa1720 Mon Sep 17 00:00:00 2001 From: Denis Mishankov Date: Mon, 21 Oct 2024 18:08:39 +0300 Subject: [PATCH] fix: undo/redo action for international keyboard layouts (#8649) Co-authored-by: Marcel Mraz --- packages/excalidraw/actions/actionHistory.tsx | 12 +- packages/excalidraw/keys.test.ts | 271 ++++++++++++++++++ packages/excalidraw/keys.ts | 50 ++++ 3 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 packages/excalidraw/keys.test.ts diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index c1e35674a..eb52381ce 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -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( 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, diff --git a/packages/excalidraw/keys.test.ts b/packages/excalidraw/keys.test.ts new file mode 100644 index 000000000..df2a36746 --- /dev/null +++ b/packages/excalidraw/keys.test.ts @@ -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(); + }); +}); diff --git a/packages/excalidraw/keys.ts b/packages/excalidraw/keys.ts index 755ce3a84..2088f89d6 100644 --- a/packages/excalidraw/keys.ts +++ b/packages/excalidraw/keys.ts @@ -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>([ + [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, + key: ValueOf, +): 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 ||