Skip to content
34 changes: 30 additions & 4 deletions packages/core/src/components/hotkeys/hotkeyParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,18 +191,31 @@ function maybeGetKeyFromEventCode(e: KeyboardEvent) {

/**
* Determines the key combo object from the given keyboard event. A key combo includes zero or more modifiers
* (represented by a bitmask) and one physical key. For most keys, we prefer dealing with the `code` property of the
* event, since this is not altered by keyboard layout or the state of modifier keys. Fall back to using the `key`
* property.
* (represented by a bitmask) and one key. We prefer using the `key` property to respect the user's keyboard layout,
* but fall back to `code` for Alt-modified characters to avoid issues with Alt producing special characters.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
* @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code
*/
export const getKeyCombo = (e: KeyboardEvent): KeyCombo => {
let key: string | undefined;
if (MODIFIER_KEYS.has(e.key)) {
// Leave local variable `key` undefined
} else {
key = maybeGetKeyFromEventCode(e) ?? e.key?.toLowerCase();
const codeKey = maybeGetKeyFromEventCode(e);

// Special cases where we must use code instead of key
if (e.code === "Space" || e.code === "Delete") {
// Space: event.key is " " but we need "space" to match parseKeyCombo
// Delete: need lowercase code name
key = codeKey;
} else if (e.altKey && isAltModifiedCharacter(e.key) && codeKey !== undefined) {
// Alt on macOS produces special characters (e.g., Alt+c → ç), use code for those cases
key = codeKey;
} else {
// Prefer event.key to respect keyboard layout, fall back to code
key = e.key?.toLowerCase() ?? codeKey;
}
}

let modifiers = 0;
Expand All @@ -225,6 +238,19 @@ export const getKeyCombo = (e: KeyboardEvent): KeyCombo => {
return { modifiers, key };
};

/**
* Checks if a character is likely the result of Alt modification on macOS.
* Alt produces characters like: ç, ñ, ø, ∫, etc. which are outside normal ASCII printable range.
*/
function isAltModifiedCharacter(key: string): boolean {
if (key == null || key.length !== 1) {
return false;
}
const code = key.charCodeAt(0);
// Check if it's outside the normal ASCII printable range (32-127), excluding space and delete (32 & 127)
return code > 126 || code < 33;
}

/**
* Splits a key combo string into its constituent key values and looks up
* aliases, such as `return` -> `enter`.
Expand Down
7 changes: 7 additions & 0 deletions packages/core/test/hotkeys/hotkeysParserTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ describe("HotkeysParser", () => {
tests.push(makeComboTest("alt + a", { altKey: true, code: "KeyA", key: "a" }));
verifyCombos(tests);
});

it("handles alt modifier with special characters (macOS)", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Do you have additional tests in mind for having an alternate layout and checking keys?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some extra tests

// On macOS, Alt+C produces "ç" - we should fall back to code-based matching
const tests = [] as ComboTest[];
tests.push(makeComboTest("alt + c", { altKey: true, code: "KeyC", key: "ç" }));
verifyCombos(tests, false); // don't verify string combos since key is "ç" not "c"
});
});

describe("parseKeyCombo", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,30 @@ export class HotkeysTargetExample extends PureComponent<ExampleProps, HotkeysTar
label: "Focus the piano",
onKeyDown: this.focusPiano,
},
{
combo: "ctrl + B",
group: "Modifier Tests",
label: "Test Ctrl+B",
onKeyDown: () => console.log("Ctrl+B pressed"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: I don't think this is sufficient for the documentation - there should be something in the UI denoting the keys pressed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed it and add more docs + an example

},
{
combo: "alt + B",
group: "Modifier Tests",
label: "Test Alt+B",
onKeyDown: () => console.log("Alt+B pressed"),
},
{
combo: "shift + B",
group: "Modifier Tests",
label: "Test Shift+B",
onKeyDown: () => console.log("Shift+B pressed"),
},
{
combo: "meta + B",
group: "Modifier Tests",
label: "Test Cmd/Meta+B",
onKeyDown: () => console.log("Meta+B pressed"),
},
{
combo: "Q",
group: "HotkeysTarget Example",
Expand Down