fix(core): Fix keyboard shortcuts for non-ansi layouts (#12672)

This commit is contained in:
Tomi Turtiainen 2025-01-17 16:40:06 +02:00 committed by GitHub
parent 395f2ad0dc
commit 4c8193fedc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 84 additions and 11 deletions

View file

@ -218,8 +218,9 @@ const keyMap = computed(() => ({
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)), ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
enter: emitWithLastSelectedNode((id) => onSetNodeActive(id)), enter: emitWithLastSelectedNode((id) => onSetNodeActive(id)),
ctrl_a: () => addSelectedNodes(graphNodes.value), ctrl_a: () => addSelectedNodes(graphNodes.value),
'shift_+|+|=': async () => await onZoomIn(), // Support both key and code for zooming in and out
'shift+_|-|_': async () => await onZoomOut(), 'shift_+|+|=|shift_Equal|Equal': async () => await onZoomIn(),
'shift+_|-|_|shift_Minus|Minus': async () => await onZoomOut(),
0: async () => await onResetZoom(), 0: async () => await onResetZoom(),
1: async () => await onFitView(), 1: async () => await onFitView(),
ArrowUp: emitWithLastSelectedNode(selectUpperSiblingNode), ArrowUp: emitWithLastSelectedNode(selectUpperSiblingNode),

View file

@ -136,4 +136,31 @@ describe('useKeybindings', () => {
document.dispatchEvent(eventB); document.dispatchEvent(eventB);
expect(handler).toHaveBeenCalledTimes(2); 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();
});
}); });

View file

@ -5,6 +5,20 @@ import { computed, unref } from 'vue';
type KeyMap = Record<string, (event: KeyboardEvent) => void>; type KeyMap = Record<string, (event: KeyboardEvent) => 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 = ( export const useKeybindings = (
keymap: Ref<KeyMap>, keymap: Ref<KeyMap>,
options?: { options?: {
@ -29,12 +43,10 @@ export const useKeybindings = (
const normalizedKeymap = computed(() => const normalizedKeymap = computed(() =>
Object.fromEntries( Object.fromEntries(
Object.entries(keymap.value) Object.entries(keymap.value).flatMap(([shortcut, handler]) => {
.map(([shortcut, handler]) => { const shortcuts = shortcut.split('|');
const shortcuts = shortcut.split('|'); return shortcuts.map((s) => [normalizeShortcutString(s), handler]);
return shortcuts.map((s) => [normalizeShortcutString(s), handler]); }),
})
.flat(),
), ),
); );
@ -62,10 +74,36 @@ export const useKeybindings = (
return shortcutPartsToString(shortcut.split(new RegExp(`[${splitCharsRegEx}]`))); 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) { function toShortcutString(event: KeyboardEvent) {
const { shiftKey, altKey } = event; const { shiftKey, altKey } = event;
const ctrlKey = isCtrlKeyPressed(event); const ctrlKey = isCtrlKeyPressed(event);
const keys = [event.key]; const keys = [event.key];
const codes = [keyboardEventCodeToKey(event.code)];
const modifiers: string[] = []; const modifiers: string[] = [];
if (shiftKey) { if (shiftKey) {
@ -80,15 +118,22 @@ export const useKeybindings = (
modifiers.push('alt'); modifiers.push('alt');
} }
return shortcutPartsToString([...modifiers, ...keys]); return {
byKey: shortcutPartsToString([...modifiers, ...keys]),
byCode: shortcutPartsToString([...modifiers, ...codes]),
};
} }
function onKeyDown(event: KeyboardEvent) { function onKeyDown(event: KeyboardEvent) {
if (ignoreKeyPresses.value || isDisabled.value) return; 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) { if (handler) {
event.preventDefault(); event.preventDefault();