Skip to content
7 changes: 5 additions & 2 deletions apps/web/src/components/settings/KeybindingsSettings.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,8 +404,9 @@ function titleCaseCommandSegment(segment: string): string {

function recordedLayoutShortcutKey(
event: Pick<KeyboardEvent, "key" | "code" | "shiftKey" | "altKey">,
platform: string,
): string {
const physicalKey = shortcutKeyFromEvent(event);
const physicalKey = shortcutKeyFromEvent(event, platform);
const layoutKey = event.key.toLowerCase();
if (event.altKey || layoutKey.length !== 1) return physicalKey;
// Normalize only US shifted glyphs at their US position. Other layouts can
Expand Down Expand Up @@ -452,7 +453,9 @@ export function keybindingFromKeyboardEvent(
},
): string | null {
const keyToken = normalizeShortcutKeyToken(
options?.layoutCharacter ? recordedLayoutShortcutKey(event) : shortcutKeyFromEvent(event),
options?.layoutCharacter
? recordedLayoutShortcutKey(event, platform)
: shortcutKeyFromEvent(event, platform),
);
if (!keyToken) return null;

Expand Down
71 changes: 71 additions & 0 deletions apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,77 @@ describe("resolveShortcutCommand", () => {
);
});

it("matches Option symbols against the active Latin layout rather than the US position", () => {
const keybindings = compile([
{ shortcut: modShortcut("a", { altKey: true, shiftKey: true }), command: "appearance.cycle" },
{ shortcut: modShortcut("q", { altKey: true, shiftKey: true }), command: "sidebar.toggle" },
]);
assert.strictEqual(
resolveShortcutCommand(
event({ key: "Æ", code: "KeyQ", metaKey: true, altKey: true, shiftKey: true }),
keybindings,
{ platform: "MacIntel", keyboardLayoutMap: new Map([["KeyQ", "a"]]) },
),
"appearance.cycle",
);
});

it("preserves physical punctuation alongside an Option-modified mapped letter", () => {
for (const key of [";", "m"]) {
const keybindings = compile([
{ shortcut: modShortcut(key, { altKey: true }), command: "diff.toggle" },
]);
assert.strictEqual(
resolveShortcutCommand(
event({ key: "µ", code: "Semicolon", metaKey: true, altKey: true }),
keybindings,
{ platform: "MacIntel", keyboardLayoutMap: new Map([["Semicolon", "m"]]) },
),
"diff.toggle",
);
}
});

it("keeps Latin event keys and unsupported layout fallbacks", () => {
const keybindings = compile([
{ shortcut: modShortcut("a", { altKey: true }), command: "appearance.cycle" },
{ shortcut: modShortcut("q", { altKey: true }), command: "sidebar.toggle" },
]);
const options = { platform: "MacIntel", keyboardLayoutMap: new Map([["KeyQ", "a"]]) };
assert.strictEqual(
resolveShortcutCommand(
event({ key: "q", code: "KeyQ", metaKey: true, altKey: true }),
keybindings,
options,
),
"sidebar.toggle",
);
for (const keyboardLayoutMap of [new Map<string, string>(), new Map([["KeyQ", "й"]])]) {
assert.strictEqual(
resolveShortcutCommand(
event({ key: "Æ", code: "KeyQ", metaKey: true, altKey: true }),
keybindings,
{ ...options, keyboardLayoutMap },
),
"sidebar.toggle",
);
}
assert.strictEqual(
resolveShortcutCommand(
event({
key: "Æ",
code: "KeyQ",
ctrlKey: true,
altKey: true,
getModifierState: () => true,
}),
keybindings,
{ ...options, platform: "Win32" },
),
null,
);
});

it("matches non-Latin layout letters using the physical key code", () => {
const keybindings = compile([{ shortcut: modShortcut("d"), command: "diff.toggle" }]);

Expand Down
24 changes: 22 additions & 2 deletions apps/web/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,24 @@ import {
resolveEventKeys,
type ShortcutEventLike as SharedShortcutEventLike,
type ShortcutModifierStateLike,
type ShortcutKeyboardLayoutMap,
shortcutKeyFromEvent as sharedShortcutKeyFromEvent,
} from "@t3tools/shared/keybindings";
import { isElectron } from "./env";
import { isEditableFocused } from "./lib/editableFocus";
import { projectScriptIdFromCommand } from "./projectScripts";
import { getKeyboardLayoutMap } from "./keyboardLayout";

export type { ShortcutModifierStateLike } from "@t3tools/shared/keybindings";
export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings";
export function shortcutKeyFromEvent(
event: Pick<ShortcutEventLike, "key" | "code" | "shiftKey" | "altKey">,
platform: string,
) {
return sharedShortcutKeyFromEvent(
event,
isMacPlatform(platform) && event.altKey ? getKeyboardLayoutMap() : undefined,
);
}

export interface ShortcutEventLike extends SharedShortcutEventLike {
target?: EventTarget | null;
Expand All @@ -42,6 +53,7 @@ export interface ShortcutMatchContext {

interface ShortcutMatchOptions {
platform?: string;
keyboardLayoutMap?: ShortcutKeyboardLayoutMap;
context?: Partial<ShortcutMatchContext>;
}

Expand Down Expand Up @@ -191,7 +203,15 @@ export function resolveShortcutCommand(
const binding = keybindings[index];
if (!binding) continue;
if (!matchesWhenClause(binding.whenAst, context)) continue;
if (!matchesKeybindingShortcut(event, shortcutForContext(binding, context), platform)) continue;
if (
!matchesKeybindingShortcut(
event,
shortcutForContext(binding, context),
platform,
options?.keyboardLayoutMap ?? getKeyboardLayoutMap(),
)
)
continue;
return binding.command;
}
return null;
Expand Down
85 changes: 85 additions & 0 deletions apps/web/src/keyboardLayout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { afterEach, assert, it } from "vite-plus/test";
import { getKeyboardLayoutMap, trackKeyboardLayout } from "./keyboardLayout";
import { keybindingFromKeyboardEvent } from "./components/settings/KeybindingsSettings.logic";

let stop: (() => void) | undefined;
afterEach(() => stop?.());

function pendingLayout() {
let resolve!: (layout: Map<string, string>) => void;
const promise = new Promise<Map<string, string>>((done) => {
resolve = done;
});
return { promise, resolve };
}

it.each([true, false])(
"records Option symbols and refreshes the current layout (keyboard events: %s)",
async (supportsEvents) => {
let layout = Promise.resolve(new Map([["KeyQ", "a"]]));
const keyboard = Object.assign(supportsEvents ? new EventTarget() : {}, {
getLayoutMap: () => layout,
});
const focusTarget = new EventTarget();
stop = trackKeyboardLayout(keyboard, focusTarget);
await keyboard.getLayoutMap();
const event = {
key: "Æ",
code: "KeyQ",
metaKey: true,
ctrlKey: false,
altKey: true,
shiftKey: true,
};
assert.strictEqual(keybindingFromKeyboardEvent(event, "MacIntel"), "mod+alt+shift+a");
assert.strictEqual(
keybindingFromKeyboardEvent(event, "MacIntel", { layoutCharacter: true }),
"mod+alt+shift+a",
);
const pending = pendingLayout();
layout = pending.promise;
focusTarget.dispatchEvent(new Event("focus"));
assert.strictEqual(keybindingFromKeyboardEvent(event, "MacIntel"), "mod+alt+shift+a");
pending.resolve(new Map([["KeyQ", "q"]]));
await layout;
assert.strictEqual(keybindingFromKeyboardEvent(event, "MacIntel"), "mod+alt+shift+q");
layout = Promise.reject(new Error("Permission denied"));
focusTarget.dispatchEvent(new Event("focus"));
assert.strictEqual(getKeyboardLayoutMap()?.get("KeyQ"), "q");
await layout.catch(() => undefined);
assert.strictEqual(getKeyboardLayoutMap(), undefined);
layout = Promise.resolve(new Map([["KeyQ", "a"]]));
stop();
focusTarget.dispatchEvent(new Event("focus"));
await keyboard.getLayoutMap();
assert.strictEqual(getKeyboardLayoutMap(), undefined);
},
);

it("ignores an obsolete layout result and clears the map when access fails", async () => {
const pending = pendingLayout();
let result = pending.promise;
const keyboard = Object.assign(new EventTarget(), { getLayoutMap: () => result });
stop = trackKeyboardLayout(keyboard);
result = Promise.resolve(new Map([["KeyQ", "q"]]));
keyboard.dispatchEvent(new Event("layoutchange"));
assert.strictEqual(getKeyboardLayoutMap(), undefined);
await result;
pending.resolve(new Map([["KeyQ", "a"]]));
await pending.promise;
assert.strictEqual(getKeyboardLayoutMap()?.get("KeyQ"), "q");
result = Promise.reject(new Error("Permission denied"));
keyboard.dispatchEvent(new Event("layoutchange"));
await result.catch(() => undefined);
assert.strictEqual(getKeyboardLayoutMap(), undefined);
});

it("does not publish a pending result after disposal", async () => {
const pending = pendingLayout();
const keyboard = Object.assign(new EventTarget(), { getLayoutMap: () => pending.promise });
stop = trackKeyboardLayout(keyboard);
stop();
pending.resolve(new Map([["KeyQ", "a"]]));
await pending.promise;
assert.strictEqual(getKeyboardLayoutMap(), undefined);
});
44 changes: 44 additions & 0 deletions apps/web/src/keyboardLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { ShortcutKeyboardLayoutMap } from "@t3tools/shared/keybindings";

interface KeyboardLayoutSource extends Partial<
Pick<EventTarget, "addEventListener" | "removeEventListener">
> {
getLayoutMap(): Promise<ShortcutKeyboardLayoutMap>;
}

let keyboardLayoutMap: ShortcutKeyboardLayoutMap | undefined;

export function getKeyboardLayoutMap() {
return keyboardLayoutMap;
}

// Option changes event.key to a symbol on macOS; the layout map recovers the
// letter printed on that key without assuming a US physical layout.
export function trackKeyboardLayout(
keyboard = (navigator as Navigator & { keyboard?: KeyboardLayoutSource }).keyboard,
focusTarget: EventTarget | undefined = typeof window === "undefined" ? undefined : window,
) {
let revision = 0;
const refresh = async (clearMap: boolean) => {
const request = ++revision;
if (clearMap) keyboardLayoutMap = undefined;
try {
const layout = await keyboard?.getLayoutMap();
if (request === revision) keyboardLayoutMap = layout;
} catch {
// Unsupported or permission-blocked browsers keep the existing fallback.
if (request === revision) keyboardLayoutMap = undefined;
}
};
const onLayoutChange = () => void refresh(true);
const onFocus = () => void refresh(false);
void refresh(true);
keyboard?.addEventListener?.("layoutchange", onLayoutChange);
focusTarget?.addEventListener("focus", onFocus);
return () => {
revision++;
keyboardLayoutMap = undefined;
keyboard?.removeEventListener?.("layoutchange", onLayoutChange);
focusTarget?.removeEventListener("focus", onFocus);
};
}
4 changes: 4 additions & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import ReactDOM from "react-dom/client";
import "@fontsource-variable/inter";
import "./index.css";

import { trackKeyboardLayout } from "./keyboardLayout";
import { hasCloudPublicConfig } from "./cloud/publicConfig";
import { isElectron } from "./env";
import { startAppServices } from "./appInit";
import { AppRoot } from "./AppRoot";
import { whenLocaleReady } from "./i18n/runtime";
import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard";

const stopTrackingKeyboardLayout = trackKeyboardLayout();
import.meta.hot?.dispose(stopTrackingKeyboardLayout);

const router = startAppServices();

const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
Expand Down
21 changes: 18 additions & 3 deletions packages/shared/src/keybindingMatching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,25 +56,36 @@ export function normalizeEventKey(key: string): string {
return normalized;
}

export type ShortcutKeyboardLayoutMap = Pick<ReadonlyMap<string, string>, "get">;

export function shortcutKeyFromEvent(
event: Pick<ShortcutEventLike, "key" | "code" | "shiftKey" | "altKey">,
keyboardLayoutMap?: ShortcutKeyboardLayoutMap,
): string {
const layoutKey = normalizeEventKey(event.key);
if (/^[a-z]$/.test(layoutKey)) return layoutKey;
const mappedKey = event.code ? keyboardLayoutMap?.get(event.code)?.toLowerCase() : undefined;
if (mappedKey && /^[a-z]$/.test(mappedKey)) return mappedKey;
// An unshifted "+" is a dedicated zoom key (German, Spanish, Nordic); Czech types it on Digit1.
const isZoomPlus = layoutKey === "+" && !event.shiftKey && !event.altKey;
if (isZoomPlus && !event.code?.startsWith("Digit")) return layoutKey;
const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined;
return physicalKey ?? layoutKey;
}

export function resolveEventKeys(event: ShortcutEventLike): Set<string> {
export function resolveEventKeys(
event: ShortcutEventLike,
keyboardLayoutMap?: ShortcutKeyboardLayoutMap,
): Set<string> {
const layoutKey = normalizeEventKey(event.key);
const keys = new Set([layoutKey]);
const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1];
if (letterCode && !/^[a-z]$/.test(layoutKey)) {
const shortcutKey = shortcutKeyFromEvent(event, keyboardLayoutMap);
if (letterCode && !/^[a-z]$/.test(shortcutKey)) {
keys.add(letterCode.toLowerCase());
}
keys.add(shortcutKey);
// Layout letters supplement, rather than replace, physical punctuation shortcuts.
keys.add(shortcutKeyFromEvent(event));
return keys;
}
Expand All @@ -99,6 +110,7 @@ export function matchesKeybindingShortcut(
event: ShortcutEventLike,
shortcut: KeybindingShortcut,
platform: string,
keyboardLayoutMap?: ShortcutKeyboardLayoutMap,
): boolean {
if (
!isMacPlatform(platform) &&
Expand All @@ -107,5 +119,8 @@ export function matchesKeybindingShortcut(
)
return false;
if (!matchesKeybindingShortcutModifiers(event, shortcut, platform)) return false;
return resolveEventKeys(event).has(shortcut.key);
return resolveEventKeys(
event,
isMacPlatform(platform) && event.altKey ? keyboardLayoutMap : undefined,
).has(shortcut.key);
}
1 change: 1 addition & 0 deletions packages/shared/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export {
shortcutKeyFromEvent,
type ShortcutEventLike,
type ShortcutModifierStateLike,
type ShortcutKeyboardLayoutMap,
} from "./keybindingMatching.ts";

export const US_SHIFTED_TO_UNSHIFTED_KEYS: ReadonlyMap<string, string> = new Map([
Expand Down
Loading