返回 DeepSeek-Reasonix
keyboardShortcuts.ts
根目录 / desktop / frontend / src / lib / keyboardShortcuts.ts
1 import { useAppNavigationStore } from "../store/appNavigation";
2 import { useEffect, useState, type DependencyList } from "react";
3 import type { DictKey } from "./i18n";
4
5 export type ShortcutPlatform = "darwin" | "windows" | "linux";
6
7 export type ShortcutAction =
8 | "app.newSession"
9 | "commandPalette.open"
10 | "composer.newline"
11 | "composer.redo"
12 | "composer.send"
13 | "composer.undo"
14 | "selection.addToChat"
15 | "settings.open"
16 | "tab.close"
17 | "shell.toggle"
18 | "terminal.toggle"
19 | "terminal.newSession"
20 | "sidebar.toggle"
21 | "textSize.increase"
22 | "textSize.decrease"
23 | "textSize.reset"
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: "shortcuts.show",
221 section: "help",
222 labelKey: "shortcuts.action.showShortcuts",
223 descriptionKey: "shortcuts.desc.showShortcuts",
224 defaults: allPlatforms({ key: "?", shift: true }),
225 preventDefault: true,
226 },
227 {
228 action: "topic.goto.1",
229 section: "session",
230 labelKey: "shortcuts.action.topicGoto1",
231 descriptionKey: "shortcuts.desc.topicGoto",
232 defaults: modCombo("1"),
233 preventDefault: true,
234 configurable: false,
235 },
236 {
237 action: "topic.goto.2",
238 section: "session",
239 labelKey: "shortcuts.action.topicGoto2",
240 descriptionKey: "shortcuts.desc.topicGoto",
241 defaults: modCombo("2"),
242 preventDefault: true,
243 configurable: false,
244 },
245 {
246 action: "topic.goto.3",
247 section: "session",
248 labelKey: "shortcuts.action.topicGoto3",
249 descriptionKey: "shortcuts.desc.topicGoto",
250 defaults: modCombo("3"),
251 preventDefault: true,
252 configurable: false,
253 },
254 {
255 action: "topic.goto.4",
256 section: "session",
257 labelKey: "shortcuts.action.topicGoto4",
258 descriptionKey: "shortcuts.desc.topicGoto",
259 defaults: modCombo("4"),
260 preventDefault: true,
261 configurable: false,
262 },
263 {
264 action: "topic.goto.5",
265 section: "session",
266 labelKey: "shortcuts.action.topicGoto5",
267 descriptionKey: "shortcuts.desc.topicGoto",
268 defaults: modCombo("5"),
269 preventDefault: true,
270 configurable: false,
271 },
272 {
273 action: "topic.goto.6",
274 section: "session",
275 labelKey: "shortcuts.action.topicGoto6",
276 descriptionKey: "shortcuts.desc.topicGoto",
277 defaults: modCombo("6"),
278 preventDefault: true,
279 configurable: false,
280 },
281 {
282 action: "topic.goto.7",
283 section: "session",
284 labelKey: "shortcuts.action.topicGoto7",
285 descriptionKey: "shortcuts.desc.topicGoto",
286 defaults: modCombo("7"),
287 preventDefault: true,
288 configurable: false,
289 },
290 {
291 action: "topic.goto.8",
292 section: "session",
293 labelKey: "shortcuts.action.topicGoto8",
294 descriptionKey: "shortcuts.desc.topicGoto",
295 defaults: modCombo("8"),
296 preventDefault: true,
297 configurable: false,
298 },
299 {
300 action: "topic.goto.9",
301 section: "session",
302 labelKey: "shortcuts.action.topicGoto9",
303 descriptionKey: "shortcuts.desc.topicGoto",
304 defaults: modCombo("9"),
305 preventDefault: true,
306 configurable: false,
307 },
308 ] as const;
309
310 let cachedCustomShortcuts: Partial<Record<ShortcutAction, ShortcutCombo>> | null = null;
311
312 if (typeof window !== "undefined") {
313 window.addEventListener("storage", (event) => {
314 if (event.key === SHORTCUTS_STORAGE_KEY) cachedCustomShortcuts = null;
315 });
316 }
317
318 function allPlatforms(combo: ShortcutCombo): Record<ShortcutPlatform, ShortcutCombo> {
319 return {
320 darwin: combo,
321 windows: combo,
322 linux: combo,
323 };
324 }
325
326 function modCombo(key: string): Record<ShortcutPlatform, ShortcutCombo> {
327 return {
328 darwin: { key, meta: true },
329 windows: { key, ctrl: true },
330 linux: { key, ctrl: true },
331 };
332 }
333
334 export function detectShortcutPlatform(): ShortcutPlatform {
335 if (typeof navigator === "undefined") return "linux";
336 const platform = navigator.platform || "";
337 const userAgent = navigator.userAgent || "";
338 if (/Mac|iPhone|iPad/.test(platform) || /Mac|iPhone|iPad/.test(userAgent)) return "darwin";
339 if (/Win/.test(platform) || /Windows/.test(userAgent)) return "windows";
340 return "linux";
341 }
342
343 export function shortcutDefinitions(): readonly ShortcutDefinition[] {
344 return SHORTCUT_DEFINITIONS;
345 }
346
347 export function shortcutDefinition(action: ShortcutAction): ShortcutDefinition {
348 const found = SHORTCUT_DEFINITIONS.find((item) => item.action === action);
349 if (!found) throw new Error(`unknown shortcut action: ${action}`);
350 return found;
351 }
352
353 export function defaultShortcutCombo(action: ShortcutAction, platform: ShortcutPlatform): ShortcutCombo {
354 return shortcutDefinition(action).defaults[platform];
355 }
356
357 export function resolvedShortcutCombo(action: ShortcutAction, platform: ShortcutPlatform): ShortcutCombo {
358 return loadCustomShortcuts()[action] ?? defaultShortcutCombo(action, platform);
359 }
360
361 export function loadCustomShortcuts(): Partial<Record<ShortcutAction, ShortcutCombo>> {
362 if (cachedCustomShortcuts) return cachedCustomShortcuts;
363 try {
364 const raw = localStorage.getItem(SHORTCUTS_STORAGE_KEY);
365 const parsed = raw ? JSON.parse(raw) : {};
366 cachedCustomShortcuts = normalizeCustomShortcuts(parsed);
367 } catch {
368 cachedCustomShortcuts = {};
369 }
370 return cachedCustomShortcuts;
371 }
372
373 export function saveCustomShortcut(action: ShortcutAction, combo: ShortcutCombo | null): void {
374 const next = { ...loadCustomShortcuts() };
375 if (combo) {
376 next[action] = normalizeCombo(combo);
377 } else {
378 delete next[action];
379 }
380 try {
381 localStorage.setItem(SHORTCUTS_STORAGE_KEY, JSON.stringify(next));
382 } catch {
383 // Keep runtime behavior usable even when storage is unavailable.
384 }
385 cachedCustomShortcuts = next;
386 notifyShortcutsChanged();
387 }
388
389 export function resetCustomShortcuts(): void {
390 try {
391 localStorage.removeItem(SHORTCUTS_STORAGE_KEY);
392 } catch {
393 // Ignore storage failures; the in-memory cache is still reset below.
394 }
395 cachedCustomShortcuts = {};
396 notifyShortcutsChanged();
397 }
398
399 export function notifyShortcutsChanged(): void {
400 if (typeof window === "undefined") return;
401 window.dispatchEvent(new CustomEvent(SHORTCUTS_CHANGED_EVENT));
402 }
403
404 export function onShortcutsChanged(callback: () => void): () => void {
405 if (typeof window === "undefined") return () => {};
406 const onStorage = (event: StorageEvent) => {
407 if (event.key !== SHORTCUTS_STORAGE_KEY) return;
408 cachedCustomShortcuts = null;
409 callback();
410 };
411 const onCustom = () => {
412 cachedCustomShortcuts = null;
413 callback();
414 };
415 window.addEventListener("storage", onStorage);
416 window.addEventListener(SHORTCUTS_CHANGED_EVENT, onCustom);
417 return () => {
418 window.removeEventListener("storage", onStorage);
419 window.removeEventListener(SHORTCUTS_CHANGED_EVENT, onCustom);
420 };
421 }
422
423 export function formatShortcutCombo(combo: ShortcutCombo, platform: ShortcutPlatform): string {
424 return formatShortcutComboParts(combo, platform).join(platform === "darwin" ? "" : "+");
425 }
426
427 export function formatShortcutComboParts(combo: ShortcutCombo, platform: ShortcutPlatform): string[] {
428 const normalized = normalizeCombo(combo);
429 const parts: string[] = [];
430 if (platform === "darwin") {
431 if (normalized.meta) parts.push("⌘");
432 if (normalized.ctrl) parts.push("⌃");
433 if (normalized.alt) parts.push("⌥");
434 if (normalized.shift) parts.push("⇧");
435 parts.push(displayKey(normalized.key));
436 return parts;
437 }
438 if (normalized.ctrl) parts.push("Ctrl");
439 if (normalized.meta) parts.push("Meta");
440 if (normalized.alt) parts.push("Alt");
441 if (normalized.shift) parts.push("Shift");
442 parts.push(displayKey(normalized.key));
443 return parts;
444 }
445
446 export function comboFromKeyboardEvent(event: KeyboardShortcutEvent): ShortcutCombo | null {
447 if (isModifierKey(event.key)) return null;
448 return normalizeCombo({
449 key: event.key,
450 ctrl: event.ctrlKey ?? false,
451 meta: event.metaKey ?? false,
452 alt: event.altKey ?? false,
453 shift: event.shiftKey ?? false,
454 });
455 }
456
457 export function matchesShortcut(event: KeyboardShortcutEvent, action: ShortcutAction, platform: ShortcutPlatform): boolean {
458 const combo = comboFromKeyboardEvent(event);
459 if (!combo) return false;
460 const definition = shortcutDefinition(action);
461 if (sameCombo(combo, resolvedShortcutCombo(action, platform))) return true;
462 if (loadCustomShortcuts()[action]) return false;
463 return definition.aliases?.[platform]?.some((alias) => sameCombo(combo, alias)) ?? false;
464 }
465
466 export function isReservedComposerHistoryShortcut(
467 event: KeyboardShortcutEvent,
468 platform: ShortcutPlatform,
469 ): boolean {
470 const combo = comboFromKeyboardEvent(event);
471 if (!combo) return false;
472 return sameCombo(combo, defaultShortcutCombo("composer.undo", platform))
473 || sameCombo(combo, defaultShortcutCombo("composer.redo", platform));
474 }
475
476 export function shortcutConflict(
477 action: ShortcutAction,
478 combo: ShortcutCombo,
479 platform: ShortcutPlatform,
480 ): ShortcutDefinition | null {
481 return SHORTCUT_DEFINITIONS.find((definition) => {
482 if (definition.action === action) return false;
483 return sameCombo(resolvedShortcutCombo(definition.action, platform), combo);
484 }) ?? null;
485 }
486
487 export function shortcutAcceptsCombo(action: ShortcutAction, combo: ShortcutCombo): boolean {
488 const allowedKeys = shortcutDefinition(action).allowedKeys;
489 if (!allowedKeys || allowedKeys.length === 0) return true;
490 const key = normalizeCombo(combo).key;
491 return allowedKeys.some((allowedKey) => normalizeKey(allowedKey) === key);
492 }
493
494 export function useGlobalShortcut(
495 action: ShortcutAction,
496 handler: (event: globalThis.KeyboardEvent) => void,
497 deps: DependencyList = [],
498 enabled = true,
499 ): void {
500 const definition = shortcutDefinition(action);
501 useEffect(() => {
502 if (!enabled) return;
503 const platform = detectShortcutPlatform();
504 const onKey = (event: globalThis.KeyboardEvent) => {
505 if (isShortcutRecorderTarget(event.target)) return;
506 if (useAppNavigationStore.getState().page.kind !== "workspace") {
507 if (!["commandPalette.open", "settings.open", "app.newSession", "tab.close", "shortcuts.show", "textSize.increase", "textSize.decrease", "textSize.reset"].includes(action)) return;
508 if (document.querySelector('[aria-modal="true"]') && !action.startsWith("textSize.")) return;
509 }
510 const editableTarget = isEditableTarget(event.target);
511 if (!definition.allowInEditable && editableTarget) return;
512 // Existing installations may already have a global action stored on
513 // Cmd/Ctrl+Z. Keep that legacy binding outside editors, but never let it
514 // intercept the platform undo/redo chord while text is being edited.
515 if (editableTarget && isReservedComposerHistoryShortcut(event, platform)) return;
516 if (!matchesShortcut(event, action, platform)) return;
517 if (definition.preventDefault !== false) event.preventDefault();
518 handler(event);
519 };
520 document.addEventListener("keydown", onKey, { capture: true });
521 return () => document.removeEventListener("keydown", onKey, { capture: true });
522 // eslint-disable-next-line react-hooks/exhaustive-deps
523 }, [action, enabled, handler, ...deps]);
524 }
525
526 // useShortcutComboLabel resolves an action's current combo as display text
527 // (e.g. "Enter", "⌃Enter") and re-renders when the user rebinds shortcuts, so
528 // tooltips and hints never show a stale key.
529 export function useShortcutComboLabel(action: ShortcutAction): string {
530 const [, setRevision] = useState(0);
531 useEffect(() => onShortcutsChanged(() => setRevision((value) => value + 1)), []);
532 const platform = detectShortcutPlatform();
533 return formatShortcutCombo(resolvedShortcutCombo(action, platform), platform);
534 }
535
536 export function isCloseTabShortcut(event: KeyboardShortcutEvent, platform: ShortcutPlatform): boolean {
537 return matchesShortcut(event, "tab.close", platform);
538 }
539
540 function normalizeCustomShortcuts(value: unknown): Partial<Record<ShortcutAction, ShortcutCombo>> {
541 if (!value || typeof value !== "object") return {};
542 const out: Partial<Record<ShortcutAction, ShortcutCombo>> = {};
543 for (const definition of SHORTCUT_DEFINITIONS) {
544 const raw = (value as Record<string, unknown>)[definition.action];
545 if (!raw || typeof raw !== "object") continue;
546 const combo = normalizeCombo(raw as ShortcutCombo);
547 if (combo.key) out[definition.action] = combo;
548 }
549 return out;
550 }
551
552 function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
553 const key = normalizeKey(combo.key);
554 return {
555 key,
556 ctrl: Boolean(combo.ctrl),
557 meta: Boolean(combo.meta),
558 alt: Boolean(combo.alt),
559 shift: Boolean(combo.shift),
560 };
561 }
562
563 function normalizeKey(key: string): string {
564 if (key === " ") return "Space";
565 if (key.length === 1) return key.toLowerCase();
566 return key;
567 }
568
569 function displayKey(key: string): string {
570 if (key === " ") return "Space";
571 if (key === "ArrowUp") return "↑";
572 if (key === "ArrowDown") return "↓";
573 if (key === "ArrowLeft") return "←";
574 if (key === "ArrowRight") return "→";
575 if (key.length === 1) return key.toUpperCase();
576 return key;
577 }
578
579 function sameCombo(a: ShortcutCombo, b: ShortcutCombo): boolean {
580 const left = normalizeCombo(a);
581 const right = normalizeCombo(b);
582 return (
583 left.key === right.key &&
584 Boolean(left.ctrl) === Boolean(right.ctrl) &&
585 Boolean(left.meta) === Boolean(right.meta) &&
586 Boolean(left.alt) === Boolean(right.alt) &&
587 Boolean(left.shift) === Boolean(right.shift)
588 );
589 }
590
591 function isModifierKey(key: string): boolean {
592 return key === "Meta" || key === "Control" || key === "Alt" || key === "Shift";
593 }
594
595 export function isEditableTarget(target: EventTarget | null): boolean {
596 if (typeof HTMLElement === "undefined") return false;
597 if (!(target instanceof HTMLElement)) return false;
598 if (target.isContentEditable) return true;
599 const tag = target.tagName.toLowerCase();
600 return tag === "input" || tag === "textarea" || tag === "select";
601 }
602
603 export function isShortcutRecorderTarget(target: EventTarget | null): boolean {
604 if (typeof HTMLElement === "undefined") return false;
605 return target instanceof HTMLElement && Boolean(target.closest(".shortcuts-settings__key--recording"));
606 }
607
607 lines TYPESCRIPT