From 4c8193fedc2e3967c9a06c0652483128df509653 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Fri, 17 Jan 2025 16:40:06 +0200 Subject: [PATCH] fix(core): Fix keyboard shortcuts for non-ansi layouts (#12672) --- .../src/components/canvas/Canvas.vue | 5 +- .../src/composables/useKeybindings.test.ts | 27 ++++++++ .../src/composables/useKeybindings.ts | 63 ++++++++++++++++--- 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/packages/editor-ui/src/components/canvas/Canvas.vue b/packages/editor-ui/src/components/canvas/Canvas.vue index f9697b864513b..be51e1f81fced 100644 --- a/packages/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/editor-ui/src/components/canvas/Canvas.vue @@ -218,8 +218,9 @@ const keyMap = computed(() => ({ ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)), enter: emitWithLastSelectedNode((id) => onSetNodeActive(id)), ctrl_a: () => addSelectedNodes(graphNodes.value), - 'shift_+|+|=': async () => await onZoomIn(), - 'shift+_|-|_': async () => await onZoomOut(), + // Support both key and code for zooming in and out + 'shift_+|+|=|shift_Equal|Equal': async () => await onZoomIn(), + 'shift+_|-|_|shift_Minus|Minus': async () => await onZoomOut(), 0: async () => await onResetZoom(), 1: async () => await onFitView(), ArrowUp: emitWithLastSelectedNode(selectUpperSiblingNode), diff --git a/packages/editor-ui/src/composables/useKeybindings.test.ts b/packages/editor-ui/src/composables/useKeybindings.test.ts index 6f4ef4f179015..9a3965412c043 100644 --- a/packages/editor-ui/src/composables/useKeybindings.test.ts +++ b/packages/editor-ui/src/composables/useKeybindings.test.ts @@ -136,4 +136,31 @@ describe('useKeybindings', () => { document.dispatchEvent(eventB); expect(handler).toHaveBeenCalledTimes(2); }); + + it("should prefer the 'key' over 'code' for dvorak to work correctly", () => { + const cHandler = vi.fn(); + const iHandler = vi.fn(); + const keymap = ref({ + 'ctrl+c': cHandler, + 'ctrl+i': iHandler, + }); + + useKeybindings(keymap); + + const event = new KeyboardEvent('keydown', { key: 'c', code: 'KeyI', ctrlKey: true }); + document.dispatchEvent(event); + expect(cHandler).toHaveBeenCalled(); + expect(iHandler).not.toHaveBeenCalled(); + }); + + it("should fallback to 'code' for non-ansi layouts", () => { + const handler = vi.fn(); + const keymap = ref({ 'ctrl+c': handler }); + + useKeybindings(keymap); + + const event = new KeyboardEvent('keydown', { key: 'ב', code: 'KeyC', ctrlKey: true }); + document.dispatchEvent(event); + expect(handler).toHaveBeenCalled(); + }); }); diff --git a/packages/editor-ui/src/composables/useKeybindings.ts b/packages/editor-ui/src/composables/useKeybindings.ts index 990cc0b03dcd5..65d61886a9d40 100644 --- a/packages/editor-ui/src/composables/useKeybindings.ts +++ b/packages/editor-ui/src/composables/useKeybindings.ts @@ -5,6 +5,20 @@ import { computed, unref } from 'vue'; type KeyMap = Record void>; +/** + * Binds a `keydown` event to `document` and calls the approriate + * handlers based on the given `keymap`. The keymap is a map from + * shortcut strings to handlers. The shortcut strings can contain + * multiple shortcuts separated by `|`. + * + * @example + * ```ts + * { + * 'ctrl+a': () => console.log('ctrl+a'), + * 'ctrl+b|ctrl+c': () => console.log('ctrl+b or ctrl+c'), + * } + * ``` + */ export const useKeybindings = ( keymap: Ref, options?: { @@ -29,12 +43,10 @@ export const useKeybindings = ( const normalizedKeymap = computed(() => Object.fromEntries( - Object.entries(keymap.value) - .map(([shortcut, handler]) => { - const shortcuts = shortcut.split('|'); - return shortcuts.map((s) => [normalizeShortcutString(s), handler]); - }) - .flat(), + Object.entries(keymap.value).flatMap(([shortcut, handler]) => { + const shortcuts = shortcut.split('|'); + return shortcuts.map((s) => [normalizeShortcutString(s), handler]); + }), ), ); @@ -62,10 +74,36 @@ export const useKeybindings = ( return shortcutPartsToString(shortcut.split(new RegExp(`[${splitCharsRegEx}]`))); } + /** + * Converts a keyboard event code to a key string. + * + * @example + * keyboardEventCodeToKey('Digit0') -> '0' + * keyboardEventCodeToKey('KeyA') -> 'a' + */ + function keyboardEventCodeToKey(code: string) { + if (code.startsWith('Digit')) { + return code.replace('Digit', '').toLowerCase(); + } else if (code.startsWith('Key')) { + return code.replace('Key', '').toLowerCase(); + } + + return code.toLowerCase(); + } + + /** + * Converts a keyboard event to a shortcut string for both + * `key` and `code`. + * + * @example + * keyboardEventToShortcutString({ key: 'a', code: 'KeyA', ctrlKey: true }) + * // --> { byKey: 'ctrl+a', byCode: 'ctrl+a' } + */ function toShortcutString(event: KeyboardEvent) { const { shiftKey, altKey } = event; const ctrlKey = isCtrlKeyPressed(event); const keys = [event.key]; + const codes = [keyboardEventCodeToKey(event.code)]; const modifiers: string[] = []; if (shiftKey) { @@ -80,15 +118,22 @@ export const useKeybindings = ( modifiers.push('alt'); } - return shortcutPartsToString([...modifiers, ...keys]); + return { + byKey: shortcutPartsToString([...modifiers, ...keys]), + byCode: shortcutPartsToString([...modifiers, ...codes]), + }; } function onKeyDown(event: KeyboardEvent) { if (ignoreKeyPresses.value || isDisabled.value) return; - const shortcutString = toShortcutString(event); + const { byKey, byCode } = toShortcutString(event); - const handler = normalizedKeymap.value[shortcutString]; + // Prefer `byKey` over `byCode` so that: + // - ANSI layouts work correctly + // - Dvorak works correctly + // - Non-ansi layouts work correctly + const handler = normalizedKeymap.value[byKey] ?? normalizedKeymap.value[byCode]; if (handler) { event.preventDefault();