Files
multica/packages/core/shortcuts/definitions.ts
YYClaw 71141559c2 fix(shortcuts): ignore synthetic key events without a key
Guard shortcut parsing against synthetic key events that do not expose a string key.
2026-07-17 16:51:21 +08:00

359 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
getShortcutPlatform,
getShortcutRuntime,
type ShortcutPlatform,
type ShortcutRuntime,
} from "./platform";
export type ShortcutActionId =
| "openSearch"
| "createIssue"
| "toggleSidebar"
| "findInIssue"
| "send"
| "goInbox"
| "goChat"
| "goMyIssues"
| "goIssues"
| "goProjects"
| "goAutopilots"
| "goAgents"
| "goSquads"
| "goUsage"
| "goRuntimes"
| "goSkills"
| "goSettings";
export type ShortcutCategory = "general" | "navigation";
export interface ShortcutModifiers {
/** Command on macOS, Control on Windows/Linux. */
primary: boolean;
/** Literal Control, distinct from Command on macOS. */
control: boolean;
/** Literal Meta/Windows/Super, distinct from Control on Windows/Linux. */
meta: boolean;
alt: boolean;
shift: boolean;
}
export interface ShortcutChord {
/** Logical key (`KeyboardEvent.key`), not physical keyboard position. */
key: string;
modifiers: ShortcutModifiers;
}
export interface ShortcutActionDefinition {
id: ShortcutActionId;
category: ShortcutCategory;
defaultShortcut: ShortcutChord | null;
/** Whether the global handler may run while focus is inside an editor/control. */
allowInEditable: boolean;
}
export function createShortcutChord(
key: string,
modifiers: Partial<ShortcutModifiers> = {},
): ShortcutChord {
return {
key,
modifiers: {
primary: false,
control: false,
meta: false,
alt: false,
shift: false,
...modifiers,
},
};
}
const primary = (key: string) =>
createShortcutChord(key, { primary: true });
export const SHORTCUT_ACTIONS: readonly ShortcutActionDefinition[] = [
{ id: "openSearch", category: "general", defaultShortcut: primary("K"), allowInEditable: true },
{ id: "createIssue", category: "general", defaultShortcut: createShortcutChord("C"), allowInEditable: false },
{ id: "toggleSidebar", category: "general", defaultShortcut: primary("B"), allowInEditable: false },
{ id: "findInIssue", category: "general", defaultShortcut: primary("F"), allowInEditable: true },
{ id: "send", category: "general", defaultShortcut: primary("Enter"), allowInEditable: true },
{ id: "goInbox", category: "navigation", defaultShortcut: null, allowInEditable: false },
{ id: "goChat", category: "navigation", defaultShortcut: null, allowInEditable: false },
{ id: "goMyIssues", category: "navigation", defaultShortcut: null, allowInEditable: false },
{ id: "goIssues", category: "navigation", defaultShortcut: null, allowInEditable: false },
{ id: "goProjects", category: "navigation", defaultShortcut: null, allowInEditable: false },
{ id: "goAutopilots", category: "navigation", defaultShortcut: null, allowInEditable: false },
{ id: "goAgents", category: "navigation", defaultShortcut: null, allowInEditable: false },
{ id: "goSquads", category: "navigation", defaultShortcut: null, allowInEditable: false },
{ id: "goUsage", category: "navigation", defaultShortcut: null, allowInEditable: false },
{ id: "goRuntimes", category: "navigation", defaultShortcut: null, allowInEditable: false },
{ id: "goSkills", category: "navigation", defaultShortcut: null, allowInEditable: false },
{ id: "goSettings", category: "navigation", defaultShortcut: null, allowInEditable: false },
] as const;
export const SHORTCUT_ACTION_BY_ID = Object.fromEntries(
SHORTCUT_ACTIONS.map((action) => [action.id, action]),
) as Record<ShortcutActionId, ShortcutActionDefinition>;
const MODIFIER_KEYS = new Set([
"Alt", "AltGraph", "CapsLock", "Control", "Fn", "FnLock", "Hyper",
"Meta", "NumLock", "ScrollLock", "Shift", "Super", "Symbol", "SymbolLock",
]);
const NON_ACTIONABLE_KEYS = new Set(["Dead", "Process", "Unidentified"]);
const KEY_LABELS: Record<string, string> = {
" ": "Space",
ArrowUp: "Up",
ArrowDown: "Down",
ArrowLeft: "Left",
ArrowRight: "Right",
Esc: "Escape",
"+": "Plus",
"-": "Minus",
"=": "Equals",
"_": "Underscore",
};
function eventKey(event: KeyboardEvent): string | null {
// Synthetic events (e.g. Chrome autofill) may omit `key` entirely.
if (typeof event.key !== "string") return null;
if (MODIFIER_KEYS.has(event.key) || NON_ACTIONABLE_KEYS.has(event.key)) return null;
const key = KEY_LABELS[event.key] ?? event.key;
if (key.length === 1 && /[a-z]/i.test(key)) return key.toUpperCase();
return key;
}
export function isShortcutChordActionable(shortcut: ShortcutChord): boolean {
return shortcut.key.length > 0 &&
!MODIFIER_KEYS.has(shortcut.key) &&
!NON_ACTIONABLE_KEYS.has(shortcut.key);
}
/** Convert a key event into a platform-aware, structured chord. */
export function shortcutFromEvent(
event: KeyboardEvent,
platform: ShortcutPlatform = getShortcutPlatform(),
): ShortcutChord | null {
if (event.getModifierState?.("AltGraph")) return null;
const key = eventKey(event);
if (!key) return null;
const mac = platform === "macos";
return createShortcutChord(key, {
primary: mac ? event.metaKey : event.ctrlKey,
control: mac ? event.ctrlKey : false,
meta: mac ? false : event.metaKey,
alt: event.altKey,
shift: event.shiftKey,
});
}
export function shortcutChordEquals(
left: ShortcutChord | null,
right: ShortcutChord | null,
): boolean {
if (left === null || right === null) return left === right;
return (
left.key === right.key &&
left.modifiers.primary === right.modifiers.primary &&
left.modifiers.control === right.modifiers.control &&
left.modifiers.meta === right.modifiers.meta &&
left.modifiers.alt === right.modifiers.alt &&
left.modifiers.shift === right.modifiers.shift
);
}
/** Exact matcher: any additional modifier intentionally makes the event differ. */
export function shortcutMatchesEvent(
shortcut: ShortcutChord | null,
event: KeyboardEvent,
platform: ShortcutPlatform = getShortcutPlatform(),
): boolean {
// `null` means two different things at this boundary: an action without an
// assigned shortcut, or an event that contains no actionable key (for
// example pressing Command/Control/Shift by itself). Neither may execute an
// action, even though `shortcutChordEquals(null, null)` is useful elsewhere
// when comparing persisted values.
if (!shortcut) return false;
const eventShortcut = shortcutFromEvent(event, platform);
return eventShortcut !== null && shortcutChordEquals(shortcut, eventShortcut);
}
export function isPlainShortcut(
shortcut: ShortcutChord | null,
key: string,
): boolean {
return shortcutChordEquals(shortcut, createShortcutChord(key));
}
export function formatShortcut(
shortcut: ShortcutChord | null,
platform: ShortcutPlatform = getShortcutPlatform(),
): string {
if (!shortcut) return "—";
const { modifiers } = shortcut;
const keyLabels: Record<string, string> = {
Enter: platform === "macos" ? "↵" : "Enter",
Backspace: platform === "macos" ? "⌫" : "Backspace",
Delete: platform === "macos" ? "⌦" : "Delete",
Escape: "Esc",
Up: "↑",
Down: "↓",
Left: "←",
Right: "→",
Plus: "+",
Minus: platform === "macos" ? "" : "-",
Equals: "=",
Underscore: "_",
Space: "Space",
};
const key = keyLabels[shortcut.key] ?? shortcut.key;
if (platform === "macos") {
return [
modifiers.primary ? "⌘" : "",
modifiers.control ? "⌃" : "",
modifiers.alt ? "⌥" : "",
modifiers.shift ? "⇧" : "",
modifiers.meta ? "Meta" : "",
key,
].join("");
}
return [
modifiers.primary ? "Ctrl" : null,
modifiers.control ? "Control" : null,
modifiers.meta ? (platform === "windows" ? "Win" : "Super") : null,
modifiers.alt ? "Alt" : null,
modifiers.shift ? "Shift" : null,
key,
].filter(Boolean).join("+");
}
export function isEditableShortcutTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return (
target.isContentEditable ||
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.closest("[contenteditable='true']") !== null
);
}
const PRIMARY_RESERVED_KEYS = new Set([
// Window operations the app itself owns on every runtime: W closes the
// tab, R/F5 is the reload guard, Q quits.
"W", "R", "Q",
// Fundamental editing operations should never become product actions.
"A", "C", "V", "X", "Y", "Z",
// Zoom accelerators: fixed app shortcuts on desktop, browser zoom on web.
"Equals", "Plus", "Minus", "Underscore", "0",
]);
// Accelerators owned by the browser UI around a tab: print, address bar,
// new tab/window, bookmark, view source. A web page cannot reliably own
// them, but the Electron renderer receives the bare primary chords as plain
// keydowns — neither Electron's default menu nor the desktop shell binds
// any of them — so exactly those are recordable on desktop (MUL-4457).
// Variants with extra modifiers stay reserved on both runtimes: several
// belong to the OS or window manager (Option+Cmd+D toggles the macOS Dock,
// Ctrl+Alt+T opens a terminal on common Linux desktops), which even the
// desktop app cannot own.
const BROWSER_ONLY_PRIMARY_RESERVED_KEYS = new Set([
"P", "L", "T", "N", "D", "U",
]);
/** Browser/window/OS accelerators the app cannot reliably own in `runtime`. */
export function isReservedShortcut(
shortcut: ShortcutChord,
platform: ShortcutPlatform = getShortcutPlatform(),
runtime: ShortcutRuntime = getShortcutRuntime(),
): boolean {
const { modifiers, key } = shortcut;
if (key === "F5") return true;
if (modifiers.primary && PRIMARY_RESERVED_KEYS.has(key)) return true;
if (modifiers.primary && BROWSER_ONLY_PRIMARY_RESERVED_KEYS.has(key)) {
const barePrimary =
!modifiers.control && !modifiers.meta && !modifiers.alt && !modifiers.shift;
if (runtime !== "desktop" || !barePrimary) return true;
}
if (platform === "macos") {
if (modifiers.primary && (key === "Space" || key === "Tab" || key === "M" || key === "H")) return true;
if (modifiers.control && ["Up", "Down", "Left", "Right"].includes(key)) return true;
} else {
// Windows/Super shortcuts are owned by the shell/window manager and often
// never reach the browser. Reject all of them instead of pretending a
// recorded binding will be dependable.
if (modifiers.meta) return true;
if (modifiers.alt && (key === "Tab" || key === "F4")) return true;
}
return false;
}
const PLAIN_GLOBAL_RESERVED_KEYS = new Set([
"Enter", "Space", "Tab", "Escape", "Backspace", "Delete",
"Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown",
]);
function hasShortcutModifier(shortcut: ShortcutChord): boolean {
const { modifiers } = shortcut;
return modifiers.primary || modifiers.control || modifiers.meta ||
modifiers.alt || modifiers.shift;
}
function hasCommandModifier(shortcut: ShortcutChord): boolean {
const { modifiers } = shortcut;
return modifiers.primary || modifiers.control || modifiers.meta;
}
/**
* Product-level safety policy layered on top of OS/browser reservations.
* Plain text keys are useful for non-editable navigation (for example `C`),
* but an action allowed inside editors must not fire while the user types.
* Send is the one deliberate exception: plain Enter is supported explicitly.
*/
export function isShortcutAllowedForAction(
actionId: ShortcutActionId,
shortcut: ShortcutChord,
platform: ShortcutPlatform = getShortcutPlatform(),
runtime: ShortcutRuntime = getShortcutRuntime(),
): boolean {
if (!isShortcutChordActionable(shortcut)) return false;
if (isReservedShortcut(shortcut, platform, runtime)) return false;
// Tab is focus navigation (and Ctrl+Tab is browser tab navigation) on every
// supported platform. It is never a dependable product-level binding.
if (shortcut.key === "Tab") return false;
const hasModifier = hasShortcutModifier(shortcut);
const hasCommand = hasCommandModifier(shortcut);
if (actionId === "send") {
return shortcutChordEquals(shortcut, createShortcutChord("Enter")) ||
shortcutChordEquals(
shortcut,
createShortcutChord("Enter", { primary: true }),
);
}
if (SHORTCUT_ACTION_BY_ID[actionId].allowInEditable) {
return hasCommand;
}
return hasModifier || !PLAIN_GLOBAL_RESERVED_KEYS.has(shortcut.key);
}
/** One-time parser for v1 string preferences persisted before structured chords. */
export function parseLegacyShortcut(value: string): ShortcutChord | null {
if (!value) return null;
const parts = value.split("+");
const key = parts.pop();
if (!key) return null;
const knownModifiers = new Set(["Mod", "Ctrl", "Meta", "Alt", "Shift"]);
if (parts.some((part) => !knownModifiers.has(part))) return null;
return createShortcutChord(key, {
primary: parts.includes("Mod"),
control: parts.includes("Ctrl"),
meta: parts.includes("Meta"),
alt: parts.includes("Alt"),
shift: parts.includes("Shift"),
});
}