mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
fix: undo/redo action for international keyboard layouts (#8649)
Co-authored-by: Marcel Mraz <marcel@excalidraw.com>
This commit is contained in:
parent
61623bbeba
commit
eb09b48ae6
3 changed files with 325 additions and 8 deletions
|
@ -5,7 +5,7 @@ import { t } from "../i18n";
|
||||||
import type { History } from "../history";
|
import type { History } from "../history";
|
||||||
import { HistoryChangedEvent } from "../history";
|
import { HistoryChangedEvent } from "../history";
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS, matchKey } from "../keys";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { isWindows } from "../constants";
|
import { isWindows } from "../constants";
|
||||||
import type { SceneElementsMap } from "../element/types";
|
import type { SceneElementsMap } from "../element/types";
|
||||||
|
@ -63,9 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
||||||
event.key.toLowerCase() === KEYS.Z &&
|
|
||||||
!event.shiftKey,
|
|
||||||
PanelComponent: ({ updateData, data }) => {
|
PanelComponent: ({ updateData, data }) => {
|
||||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
|
@ -104,10 +102,8 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event[KEYS.CTRL_OR_CMD] &&
|
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||||
event.shiftKey &&
|
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
|
||||||
event.key.toLowerCase() === KEYS.Z) ||
|
|
||||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
|
||||||
PanelComponent: ({ updateData, data }) => {
|
PanelComponent: ({ updateData, data }) => {
|
||||||
const { isRedoStackEmpty } = useEmitter(
|
const { isRedoStackEmpty } = useEmitter(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
|
|
271
packages/excalidraw/keys.test.ts
Normal file
271
packages/excalidraw/keys.test.ts
Normal file
|
@ -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 { isDarwin } from "./constants";
|
||||||
|
import type { ValueOf } from "./utility-types";
|
||||||
|
|
||||||
export const CODES = {
|
export const CODES = {
|
||||||
EQUAL: "Equal",
|
EQUAL: "Equal",
|
||||||
|
@ -20,6 +21,7 @@ export const CODES = {
|
||||||
H: "KeyH",
|
H: "KeyH",
|
||||||
V: "KeyV",
|
V: "KeyV",
|
||||||
Z: "KeyZ",
|
Z: "KeyZ",
|
||||||
|
Y: "KeyY",
|
||||||
R: "KeyR",
|
R: "KeyR",
|
||||||
S: "KeyS",
|
S: "KeyS",
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -83,6 +85,54 @@ export const KEYS = {
|
||||||
|
|
||||||
export type Key = keyof typeof 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) =>
|
export const isArrowKey = (key: string) =>
|
||||||
key === KEYS.ARROW_LEFT ||
|
key === KEYS.ARROW_LEFT ||
|
||||||
key === KEYS.ARROW_RIGHT ||
|
key === KEYS.ARROW_RIGHT ||
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue