Bläddra i källkod

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

Co-authored-by: Marcel Mraz <[email protected]>
Denis Mishankov 9 månader sedan
förälder
incheckning
eb09b48ae6

+ 4 - 8
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<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,

+ 271 - 0
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();
+  });
+});

+ 50 - 0
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<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 ||