Skip to content

Commit 64bcca0

Browse files
authored
fix(editor): Fix incorrect shortcut resolution for letter keys on non-QWERTY (#25875)
1 parent 076d7b2 commit 64bcca0

File tree

2 files changed

+73
-11
lines changed

2 files changed

+73
-11
lines changed

packages/frontend/editor-ui/src/app/composables/useKeybindings.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ describe('useKeybindings', () => {
153153
expect(iHandler).not.toHaveBeenCalled();
154154
});
155155

156-
it("should fallback to 'code' for non-ansi layouts", () => {
156+
it("should fallback to 'code' for non-Latin layouts with Ctrl/Cmd shortcuts", () => {
157157
const handler = vi.fn();
158158
const keymap = ref({ 'ctrl+c': handler });
159159

@@ -164,6 +164,23 @@ describe('useKeybindings', () => {
164164
expect(handler).toHaveBeenCalled();
165165
});
166166

167+
it('should NOT fallback to code for non-Latin letters without Ctrl/Cmd', () => {
168+
const cHandler = vi.fn();
169+
const hebrewHandler = vi.fn();
170+
const keymap = ref({
171+
c: cHandler,
172+
ב: hebrewHandler,
173+
});
174+
175+
useKeybindings(keymap);
176+
177+
const event = new KeyboardEvent('keydown', { key: 'ב', code: 'KeyC' });
178+
document.dispatchEvent(event);
179+
180+
expect(hebrewHandler).toHaveBeenCalled();
181+
expect(cHandler).not.toHaveBeenCalled();
182+
});
183+
167184
it('should resolve alt shortcuts via keyboard layout map for Colemak', async () => {
168185
// Simulate the Keyboard Layout Map API (Colemak: physical KeyL → logical 'i')
169186
const mockLayoutMap = new Map([['KeyL', 'i']]) as unknown as KeyboardLayoutMap;
@@ -205,4 +222,31 @@ describe('useKeybindings', () => {
205222
writable: true,
206223
});
207224
});
225+
226+
it('should NOT trigger ctrl+s when pressing ctrl+r on Colemak (letter key should not fallback to byCode)', () => {
227+
const rHandler = vi.fn();
228+
const sHandler = vi.fn();
229+
const keymap = ref({ 'ctrl+s': sHandler, 'ctrl+r': rHandler });
230+
231+
useKeybindings(keymap);
232+
233+
// On Colemak: to type 'r', user presses the physical 'S' key (KeyS)
234+
const event = new KeyboardEvent('keydown', { key: 'r', code: 'KeyS', ctrlKey: true });
235+
document.dispatchEvent(event);
236+
237+
expect(rHandler).toHaveBeenCalled();
238+
expect(sHandler).not.toHaveBeenCalled();
239+
});
240+
241+
it('should fallback to byCode for non-letter keys (arrows, function keys)', () => {
242+
const handler = vi.fn();
243+
const keymap = ref({ ArrowUp: handler });
244+
245+
useKeybindings(keymap);
246+
247+
const event = new KeyboardEvent('keydown', { key: 'ArrowUp', code: 'ArrowUp' });
248+
document.dispatchEvent(event);
249+
250+
expect(handler).toHaveBeenCalled();
251+
});
208252
});

packages/frontend/editor-ui/src/app/composables/useKeybindings.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -180,21 +180,39 @@ export const useKeybindings = (
180180
};
181181
}
182182

183+
// Resolution strategy:
184+
// 1. Prefer `byKey` (event.key) so shortcuts follow the logical character
185+
// produced by the active keyboard layout (works correctly for QWERTY,
186+
// Colemak/Dvorak, and most layouts).
187+
// 2. Use `byLayout` (Keyboard Layout Map API) as a layout-aware fallback when
188+
// available. This helps in cases where `event.key` is unreliable (e.g.
189+
// certain macOS modifier combinations or dead keys).
190+
// 3. Avoid falling back to `byCode` (physical key position) for letter keys
191+
// when the active layout produces Latin letters (a–z). Physical fallback
192+
// can remap one letter to another on alternative layouts (e.g. Colemak),
193+
// causing unintended matches such as CMD+R triggering a CMD+S handler.
194+
// 4. For non-Latin layouts (Hebrew, Russian, etc.), allow `byCode` fallback for
195+
// Ctrl/Cmd-modified letter shortcuts so common shortcuts like Ctrl/Cmd+C
196+
// still work even when the key produces a non-Latin character.
197+
// 5. For non-letter keys (arrows, function keys, etc.), allow `byCode` as a
198+
// last resort to ensure consistent physical-key behavior across layouts.
183199
function onKeyDown(event: KeyboardEvent) {
184200
if (ignoreKeyPresses.value || isDisabled.value) return;
185201

186202
const { byKey, byCode, byLayout } = toShortcutString(event);
187203

188-
// Prefer `byKey` over `byCode` so that:
189-
// - ANSI layouts work correctly
190-
// - Dvorak works correctly
191-
// - Non-ansi layouts work correctly
192-
// Fall back to `byLayout` (Keyboard Layout Map API) for layouts like
193-
// Colemak where macOS Alt+key produces dead keys
194-
const handler =
195-
normalizedKeymap.value[byKey] ??
196-
normalizedKeymap.value[byCode] ??
197-
(byLayout ? normalizedKeymap.value[byLayout] : undefined);
204+
const isLetterKey = event.code.startsWith('Key'); // KeyA..KeyZ
205+
const isLatinLetter = /^[a-z]$/i.test(event.key);
206+
const isNonLatinLetter = /^\p{L}$/u.test(event.key) && !isLatinLetter;
207+
const ctrlOrCmd = isCtrlKeyPressed(event);
208+
209+
const useCodeFallback = !isLetterKey || (ctrlOrCmd && isNonLatinLetter);
210+
211+
const handlerFromKey = normalizedKeymap.value[byKey];
212+
const handlerFromLayout = byLayout ? normalizedKeymap.value[byLayout] : undefined;
213+
const handlerFromCode = useCodeFallback ? normalizedKeymap.value[byCode] : undefined;
214+
215+
const handler = handlerFromKey ?? handlerFromLayout ?? handlerFromCode;
198216
const run =
199217
typeof handler === 'function' ? handler : handler?.disabled() ? undefined : handler?.run;
200218

0 commit comments

Comments
 (0)