返回 DeepSeek-Reasonix
keyboardShortcuts.ts
根目录 / desktop / frontend / src / lib / keyboardShortcuts.ts
1 import { useEffect, useState, type DependencyList } from "react";
2 import type { DictKey } from "./i18n";
3
4 export type ShortcutPlatform = "darwin" | "windows" | "linux";
5
6 export type ShortcutAction =
7 | "app.newSession"
8 | "commandPalette.open"
9 | "composer.newline"
10 | "composer.redo"
11 | "composer.send"
12 | "composer.undo"
13 | "selection.addToChat"
14 | "settings.open"
15 | "tab.close"
16 | "shell.toggle"
17 | "terminal.toggle"
18 | "terminal.newSession"
19 | "sidebar.toggle"
20 | "textSize.increase"
21 | "textSize.decrease"
22 | "textSize.reset"
23 | "toolApproval.yolo"
24 | "shortcuts.show"
25 | "topic.goto.1"
26 | "topic.goto.2"
27 | "topic.goto.3"
28 | "topic.goto.4"
29 | "topic.goto.5"
30 | "topic.goto.6"
31 | "topic.goto.7"
32 | "topic.goto.8"
33 | "topic.goto.9";
34
35 type KeyboardShortcutEvent = Pick<globalThis.KeyboardEvent, "key"> &
36 Partial<Pick<globalThis.KeyboardEvent, "ctrlKey" | "metaKey" | "altKey" | "shiftKey" | "target">>;
37
38 export type ShortcutCombo = {
39 key: string;
40 ctrl?: boolean;
41 meta?: boolean;
42 alt?: boolean;
43 shift?: boolean;
44 };
45
46 export type ShortcutSection = "global" | "session" | "view" | "tools" | "help";
47
48 export type ShortcutDefinition = {
49 action: ShortcutAction;
50 section: ShortcutSection;
51 labelKey: DictKey;
52 descriptionKey: DictKey;
53 defaults: Record<ShortcutPlatform, ShortcutCombo>;
54 aliases?: Partial<Record<ShortcutPlatform, ShortcutCombo[]>>;
55 preventDefault?: boolean;
56 allowInEditable?: boolean;
57 configurable?: boolean;
58 allowedKeys?: readonly string[];
59 };
60
61 const SHORTCUTS_STORAGE_KEY = "reasonix.customShortcuts";
62 const SHORTCUTS_CHANGED_EVENT = "reasonix:shortcuts-changed";
63
64 export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [
65 {
66 action: "app.newSession",
67 section: "session",
68 labelKey: "shortcuts.action.newSession",
69 descriptionKey: "shortcuts.desc.newSession",
70 defaults: modCombo("n"),
71 preventDefault: true,
72 },
73 {
74 action: "commandPalette.open",
75 section: "global",
76 labelKey: "shortcuts.action.commandPalette",
77 descriptionKey: "shortcuts.desc.commandPalette",
78 defaults: modCombo("k"),
79 preventDefault: true,
80 allowInEditable: true,
81 },
82 {
83 action: "settings.open",
84 section: "global",
85 labelKey: "shortcuts.action.settings",
86 descriptionKey: "shortcuts.desc.settings",
87 defaults: modCombo(","),
88 preventDefault: true,
89 },
90 {
91 action: "tab.close",
92 section: "session",
93 labelKey: "shortcuts.action.closeTab",
94 descriptionKey: "shortcuts.desc.closeTab",
95 defaults: modCombo("w"),
96 preventDefault: true,
97 },
98 // Composer-owned shortcuts are handled inside its keydown path rather than
99 // useGlobalShortcut. Undo/redo stay locked to the platform editing standard
100 // so native textarea history and Reasonix transactions share one chord.
101 {
102 action: "composer.send",
103 section: "session",
104 labelKey: "shortcuts.action.composerSend",
105 descriptionKey: "shortcuts.desc.composerSend",
106 defaults: allPlatforms({ key: "Enter" }),
107 allowInEditable: true,
108 allowedKeys: ["Enter"],
109 },
110 {
111 action: "composer.newline",
112 section: "session",
113 labelKey: "shortcuts.action.composerNewline",
114 descriptionKey: "shortcuts.desc.composerNewline",
115 defaults: allPlatforms({ key: "Enter", shift: true }),
116 allowInEditable: true,
117 allowedKeys: ["Enter"],
118 },
119 {
120 action: "composer.undo",
121 section: "session",
122 labelKey: "shortcuts.action.composerUndo",
123 descriptionKey: "shortcuts.desc.composerUndo",
124 defaults: modCombo("z"),
125 allowInEditable: true,
126 configurable: false,
127 },
128 {
129 action: "composer.redo",
130 section: "session",
131 labelKey: "shortcuts.action.composerRedo",
132 descriptionKey: "shortcuts.desc.composerRedo",
133 defaults: {
134 darwin: { key: "z", meta: true, shift: true },
135 windows: { key: "z", ctrl: true, shift: true },
136 linux: { key: "z", ctrl: true, shift: true },
137 },
138 allowInEditable: true,
139 configurable: false,
140 },
141 {
142 action: "selection.addToChat",
143 section: "session",
144 labelKey: "shortcuts.action.addSelectionToChat",
145 descriptionKey: "shortcuts.desc.addSelectionToChat",
146 defaults: modCombo("l"),
147 preventDefault: true,
148 // The handler only arms while the transcript selection action is visible,
149 // so firing from an editable target (composer focus) is safe and expected.
150 allowInEditable: true,
151 },
152 {
153 action: "shell.toggle",
154 section: "view",
155 labelKey: "shortcuts.action.shellToggle",
156 descriptionKey: "shortcuts.desc.shellToggle",
157 defaults: {
158 darwin: { key: "b", meta: true, shift: true },
159 windows: { key: "b", ctrl: true, shift: true },
160 linux: { key: "b", ctrl: true, shift: true },
161 },
162 preventDefault: true,
163 },
164 {
165 action: "terminal.toggle",
166 section: "view",
167 labelKey: "shortcuts.action.terminalToggle",
168 descriptionKey: "shortcuts.desc.terminalToggle",
169 defaults: allPlatforms({ key: "`", ctrl: true }),
170 preventDefault: true,
171 allowInEditable: true,
172 },
173 {
174 action: "terminal.newSession",
175 section: "view",
176 labelKey: "shortcuts.action.terminalNewSession",
177 descriptionKey: "shortcuts.desc.terminalNewSession",
178 defaults: allPlatforms({ key: "`", ctrl: true, shift: true }),
179 preventDefault: true,
180 allowInEditable: true,
181 },
182 {
183 action: "sidebar.toggle",
184 section: "view",
185 labelKey: "shortcuts.action.sidebarToggle",
186 descriptionKey: "shortcuts.desc.sidebarToggle",
187 defaults: modCombo("b"),
188 preventDefault: true,
189 },
190 {
191 action: "textSize.increase",
192 section: "view",
193 labelKey: "shortcuts.action.textSizeIncrease",
194 descriptionKey: "shortcuts.desc.textSizeIncrease",
195 defaults: modCombo("="),
196 aliases: {
197 darwin: [{ key: "+", meta: true, shift: true }],
198 windows: [{ key: "+", ctrl: true, shift: true }],
199 linux: [{ key: "+", ctrl: true, shift: true }],
200 },
201 preventDefault: true,
202 },
203 {
204 action: "textSize.decrease",
205 section: "view",
206 labelKey: "shortcuts.action.textSizeDecrease",
207 descriptionKey: "shortcuts.desc.textSizeDecrease",
208 defaults: modCombo("-"),
209 preventDefault: true,
210 },
211 {
212 action: "textSize.reset",
213 section: "view",
214 labelKey: "shortcuts.action.textSizeReset",
215 descriptionKey: "shortcuts.desc.textSizeReset",
216 defaults: modCombo("0"),
217 preventDefault: true,
218 },
219 {
220 action: "toolApproval.yolo",
221 section: "tools",
222 labelKey: "shortcuts.action.yoloToggle",
223 descriptionKey: "shortcuts.desc.yoloToggle",
224 defaults: modCombo("y"),
225 preventDefault: true,
226 allowInEditable: true,
227 },
228 {
229 action: "shortcuts.show",
230 section: "help",
231 labelKey: "shortcuts.action.showShortcuts",
232 descriptionKey: "shortcuts.desc.showShortcuts",
233 defaults: allPlatforms({ key: "?", shift: true }),
234 preventDefault: true,
235 },
236 {
237 action: "topic.goto.1",
238 section: "session",
239 labelKey: "shortcuts.action.topicGoto1",
240 descriptionKey: "shortcuts.desc.topicGoto",
241 defaults: modCombo("1"),
242 preventDefault: true,
243 configurable: false,
244 },
245 {
246 action: "topic.goto.2",
247 section: "session",
248 labelKey: "shortcuts.action.topicGoto2",
249 descriptionKey: "shortcuts.desc.topicGoto",
250 defaults: modCombo("2"),
251 preventDefault: true,
252 configurable: false,
253 },
254 {
255 action: "topic.goto.3",
256 section: "session",
257 labelKey: "shortcuts.action.topicGoto3",
258 descriptionKey: "shortcuts.desc.topicGoto",
259 defaults: modCombo("3"),
260 preventDefault: true,
261 configurable: false,
262 },
263 {
264 action: "topic.goto.4",
265 section: "session",
266 labelKey: "shortcuts.action.topicGoto4",
267 descriptionKey: "shortcuts.desc.topicGoto",
268 defaults: modCombo("4"),
269 preventDefault: true,
270 configurable: false,
271 },
272 {
273 action: "topic.goto.5",
274 section: "session",
275 labelKey: "shortcuts.action.topicGoto5",
276 descriptionKey: "shortcuts.desc.topicGoto",
277 defaults: modCombo("5"),
278 preventDefault: true,
279 configurable: false,
280 },
281 {
282 action: "topic.goto.6",
283 section: "session",
284 labelKey: "shortcuts.action.topicGoto6",
285 descriptionKey: "shortcuts.desc.topicGoto",
286 defaults: modCombo("6"),
287 preventDefault: true,
288 configurable: false,
289 },
290 {
291 action: "topic.goto.7",
292 section: "session",
293 labelKey: "shortcuts.action.topicGoto7",
294 descriptionKey: "shortcuts.desc.topicGoto",
295 defaults: modCombo("7"),
296 preventDefault: true,
297 configurable: false,
298 },
299 {
300 action: "topic.goto.8",
301 section: "session",
302 labelKey: "shortcuts.action.topicGoto8",
303 descriptionKey: "shortcuts.desc.topicGoto",
304 defaults: modCombo("8"),
305 preventDefault: true,
306 configurable: false,
307 },
308 {
309 action: "topic.goto.9",
310 section: "session",
311 labelKey: "shortcuts.action.topicGoto9",
312 descriptionKey: "shortcuts.desc.topicGoto",
313 defaults: modCombo("9"),
314 preventDefault: true,
315 configurable: false,
316 },
317 ] as const;
318
319 let cachedCustomShortcuts: Partial<Record<ShortcutAction, ShortcutCombo>> | null = null;
320
321 if (typeof window !== "undefined") {
322 window.addEventListener("storage", (event) => {
323 if (event.key === SHORTCUTS_STORAGE_KEY) cachedCustomShortcuts = null;
324 });
325 }
326
327 function allPlatforms(combo: ShortcutCombo): Record<ShortcutPlatform, ShortcutCombo> {
328 return {
329 darwin: combo,
330 windows: combo,
331 linux: combo,
332 };
333 }
334
335 function modCombo(key: string): Record<ShortcutPlatform, ShortcutCombo> {
336 return {
337 darwin: { key, meta: true },
338 windows: { key, ctrl: true },
339 linux: { key, ctrl: true },
340 };
341 }
342
343 export function detectShortcutPlatform(): ShortcutPlatform {
344 if (typeof navigator === "undefined") return "linux";
345 const platform = navigator.platform || "";
346 const userAgent = navigator.userAgent || "";
347 if (/Mac|iPhone|iPad/.test(platform) || /Mac|iPhone|iPad/.test(userAgent)) return "darwin";
348 if (/Win/.test(platform) || /Windows/.test(userAgent)) return "windows";
349 return "linux";
350 }
351
352 export function shortcutDefinitions(): readonly ShortcutDefinition[] {
353 return SHORTCUT_DEFINITIONS;
354 }
355
356 export function shortcutDefinition(action: ShortcutAction): ShortcutDefinition {
357 const found = SHORTCUT_DEFINITIONS.find((item) => item.action === action);
358 if (!found) throw new Error(`unknown shortcut action: ${action}`);
359 return found;
360 }
361
362 export function defaultShortcutCombo(action: ShortcutAction, platform: ShortcutPlatform): ShortcutCombo {
363 return shortcutDefinition(action).defaults[platform];
364 }
365
366 export function resolvedShortcutCombo(action: ShortcutAction, platform: ShortcutPlatform): ShortcutCombo {
367 return loadCustomShortcuts()[action] ?? defaultShortcutCombo(action, platform);
368 }
369
370 export function loadCustomShortcuts(): Partial<Record<ShortcutAction, ShortcutCombo>> {
371 if (cachedCustomShortcuts) return cachedCustomShortcuts;
372 try {
373 const raw = localStorage.getItem(SHORTCUTS_STORAGE_KEY);
374 const parsed = raw ? JSON.parse(raw) : {};
375 cachedCustomShortcuts = normalizeCustomShortcuts(parsed);
376 } catch {
377 cachedCustomShortcuts = {};
378 }
379 return cachedCustomShortcuts;
380 }
381
382 export function saveCustomShortcut(action: ShortcutAction, combo: ShortcutCombo | null): void {
383 const next = { ...loadCustomShortcuts() };
384 if (combo) {
385 next[action] = normalizeCombo(combo);
386 } else {
387 delete next[action];
388 }
389 try {
390 localStorage.setItem(SHORTCUTS_STORAGE_KEY, JSON.stringify(next));
391 } catch {
392 // Keep runtime behavior usable even when storage is unavailable.
393 }
394 cachedCustomShortcuts = next;
395 notifyShortcutsChanged();
396 }
397
398 export function resetCustomShortcuts(): void {
399 try {
400 localStorage.removeItem(SHORTCUTS_STORAGE_KEY);
401 } catch {
402 // Ignore storage failures; the in-memory cache is still reset below.
403 }
404 cachedCustomShortcuts = {};
405 notifyShortcutsChanged();
406 }
407
408 export function notifyShortcutsChanged(): void {
409 if (typeof window === "undefined") return;
410 window.dispatchEvent(new CustomEvent(SHORTCUTS_CHANGED_EVENT));
411 }
412
413 export function onShortcutsChanged(callback: () => void): () => void {
414 if (typeof window === "undefined") return () => {};
415 const onStorage = (event: StorageEvent) => {
416 if (event.key !== SHORTCUTS_STORAGE_KEY) return;
417 cachedCustomShortcuts = null;
418 callback();
419 };
420 const onCustom = () => {
421 cachedCustomShortcuts = null;
422 callback();
423 };
424 window.addEventListener("storage", onStorage);
425 window.addEventListener(SHORTCUTS_CHANGED_EVENT, onCustom);
426 return () => {
427 window.removeEventListener("storage", onStorage);
428 window.removeEventListener(SHORTCUTS_CHANGED_EVENT, onCustom);
429 };
430 }
431
432 export function formatShortcutCombo(combo: ShortcutCombo, platform: ShortcutPlatform): string {
433 return formatShortcutComboParts(combo, platform).join(platform === "darwin" ? "" : "+");
434 }
435
436 export function formatShortcutComboParts(combo: ShortcutCombo, platform: ShortcutPlatform): string[] {
437 const normalized = normalizeCombo(combo);
438 const parts: string[] = [];
439 if (platform === "darwin") {
440 if (normalized.meta) parts.push("⌘");
441 if (normalized.ctrl) parts.push("⌃");
442 if (normalized.alt) parts.push("⌥");
443 if (normalized.shift) parts.push("⇧");
444 parts.push(displayKey(normalized.key));
445 return parts;
446 }
447 if (normalized.ctrl) parts.push("Ctrl");
448 if (normalized.meta) parts.push("Meta");
449 if (normalized.alt) parts.push("Alt");
450 if (normalized.shift) parts.push("Shift");
451 parts.push(displayKey(normalized.key));
452 return parts;
453 }
454
455 export function comboFromKeyboardEvent(event: KeyboardShortcutEvent): ShortcutCombo | null {
456 if (isModifierKey(event.key)) return null;
457 return normalizeCombo({
458 key: event.key,
459 ctrl: event.ctrlKey ?? false,
460 meta: event.metaKey ?? false,
461 alt: event.altKey ?? false,
462 shift: event.shiftKey ?? false,
463 });
464 }
465
466 export function matchesShortcut(event: KeyboardShortcutEvent, action: ShortcutAction, platform: ShortcutPlatform): boolean {
467 const combo = comboFromKeyboardEvent(event);
468 if (!combo) return false;
469 const definition = shortcutDefinition(action);
470 if (sameCombo(combo, resolvedShortcutCombo(action, platform))) return true;
471 if (loadCustomShortcuts()[action]) return false;
472 return definition.aliases?.[platform]?.some((alias) => sameCombo(combo, alias)) ?? false;
473 }
474
475 export function isReservedComposerHistoryShortcut(
476 event: KeyboardShortcutEvent,
477 platform: ShortcutPlatform,
478 ): boolean {
479 const combo = comboFromKeyboardEvent(event);
480 if (!combo) return false;
481 return sameCombo(combo, defaultShortcutCombo("composer.undo", platform))
482 || sameCombo(combo, defaultShortcutCombo("composer.redo", platform));
483 }
484
485 export function shortcutConflict(
486 action: ShortcutAction,
487 combo: ShortcutCombo,
488 platform: ShortcutPlatform,
489 ): ShortcutDefinition | null {
490 return SHORTCUT_DEFINITIONS.find((definition) => {
491 if (definition.action === action) return false;
492 return sameCombo(resolvedShortcutCombo(definition.action, platform), combo);
493 }) ?? null;
494 }
495
496 export function shortcutAcceptsCombo(action: ShortcutAction, combo: ShortcutCombo): boolean {
497 const allowedKeys = shortcutDefinition(action).allowedKeys;
498 if (!allowedKeys || allowedKeys.length === 0) return true;
499 const key = normalizeCombo(combo).key;
500 return allowedKeys.some((allowedKey) => normalizeKey(allowedKey) === key);
501 }
502
503 export function useGlobalShortcut(
504 action: ShortcutAction,
505 handler: (event: globalThis.KeyboardEvent) => void,
506 deps: DependencyList = [],
507 enabled = true,
508 ): void {
509 const definition = shortcutDefinition(action);
510 useEffect(() => {
511 if (!enabled) return;
512 const platform = detectShortcutPlatform();
513 const onKey = (event: globalThis.KeyboardEvent) => {
514 if (isShortcutRecorderTarget(event.target)) return;
515 const editableTarget = isEditableTarget(event.target);
516 if (!definition.allowInEditable && editableTarget) return;
517 // Existing installations may already have a global action stored on
518 // Cmd/Ctrl+Z. Keep that legacy binding outside editors, but never let it
519 // intercept the platform undo/redo chord while text is being edited.
520 if (editableTarget && isReservedComposerHistoryShortcut(event, platform)) return;
521 if (!matchesShortcut(event, action, platform)) return;
522 if (definition.preventDefault !== false) event.preventDefault();
523 handler(event);
524 };
525 document.addEventListener("keydown", onKey, { capture: true });
526 return () => document.removeEventListener("keydown", onKey, { capture: true });
527 // eslint-disable-next-line react-hooks/exhaustive-deps
528 }, [action, enabled, handler, ...deps]);
529 }
530
531 // useShortcutComboLabel resolves an action's current combo as display text
532 // (e.g. "Enter", "⌃Enter") and re-renders when the user rebinds shortcuts, so
533 // tooltips and hints never show a stale key.
534 export function useShortcutComboLabel(action: ShortcutAction): string {
535 const [, setRevision] = useState(0);
536 useEffect(() => onShortcutsChanged(() => setRevision((value) => value + 1)), []);
537 const platform = detectShortcutPlatform();
538 return formatShortcutCombo(resolvedShortcutCombo(action, platform), platform);
539 }
540
541 export function isCloseTabShortcut(event: KeyboardShortcutEvent, platform: ShortcutPlatform): boolean {
542 return matchesShortcut(event, "tab.close", platform);
543 }
544
545 function normalizeCustomShortcuts(value: unknown): Partial<Record<ShortcutAction, ShortcutCombo>> {
546 if (!value || typeof value !== "object") return {};
547 const out: Partial<Record<ShortcutAction, ShortcutCombo>> = {};
548 for (const definition of SHORTCUT_DEFINITIONS) {
549 const raw = (value as Record<string, unknown>)[definition.action];
550 if (!raw || typeof raw !== "object") continue;
551 const combo = normalizeCombo(raw as ShortcutCombo);
552 if (combo.key) out[definition.action] = combo;
553 }
554 return out;
555 }
556
557 function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
558 const key = normalizeKey(combo.key);
559 return {
560 key,
561 ctrl: Boolean(combo.ctrl),
562 meta: Boolean(combo.meta),
563 alt: Boolean(combo.alt),
564 shift: Boolean(combo.shift),
565 };
566 }
567
568 function normalizeKey(key: string): string {
569 if (key === " ") return "Space";
570 if (key.length === 1) return key.toLowerCase();
571 return key;
572 }
573
574 function displayKey(key: string): string {
575 if (key === " ") return "Space";
576 if (key === "ArrowUp") return "↑";
577 if (key === "ArrowDown") return "↓";
578 if (key === "ArrowLeft") return "←";
579 if (key === "ArrowRight") return "→";
580 if (key.length === 1) return key.toUpperCase();
581 return key;
582 }
583
584 function sameCombo(a: ShortcutCombo, b: ShortcutCombo): boolean {
585 const left = normalizeCombo(a);
586 const right = normalizeCombo(b);
587 return (
588 left.key === right.key &&
589 Boolean(left.ctrl) === Boolean(right.ctrl) &&
590 Boolean(left.meta) === Boolean(right.meta) &&
591 Boolean(left.alt) === Boolean(right.alt) &&
592 Boolean(left.shift) === Boolean(right.shift)
593 );
594 }
595
596 function isModifierKey(key: string): boolean {
597 return key === "Meta" || key === "Control" || key === "Alt" || key === "Shift";
598 }
599
600 export function isEditableTarget(target: EventTarget | null): boolean {
601 if (typeof HTMLElement === "undefined") return false;
602 if (!(target instanceof HTMLElement)) return false;
603 if (target.isContentEditable) return true;
604 const tag = target.tagName.toLowerCase();
605 return tag === "input" || tag === "textarea" || tag === "select";
606 }
607
608 export function isShortcutRecorderTarget(target: EventTarget | null): boolean {
609 if (typeof HTMLElement === "undefined") return false;
610 return target instanceof HTMLElement && Boolean(target.closest(".shortcuts-settings__key--recording"));
611 }
612
612 lines TYPESCRIPT