返回 DeepSeek-Reasonix
topicShortcuts.ts
根目录 / desktop / frontend / src / lib / topicShortcuts.ts
1 // useTopicShortcuts - Cmd/Ctrl hold detection plus 1-9 sidebar topic navigation.
2 //
3 // When the user holds Cmd (macOS) or Ctrl (Windows/Linux) for a brief moment
4 // without pressing another key, shortcut badges appear over the sidebar topic
5 // list. Releasing the modifier hides them immediately. Pressing Cmd/Ctrl+1-9
6 // navigates to the matching topic.
7
8 import { useCallback, useEffect, useRef, useState } from "react";
9 import {
10 defaultShortcutCombo,
11 detectShortcutPlatform,
12 formatShortcutCombo,
13 isShortcutRecorderTarget,
14 matchesShortcut,
15 type ShortcutAction,
16 type ShortcutPlatform,
17 } from "./keyboardShortcuts";
18
19 /** Delay (ms) before showing badges after modifier is held. */
20 const SHOW_DELAY_MS = 250;
21
22 type TopicShortcutEntry = {
23 scope: "global" | "project";
24 workspaceRoot: string;
25 topicId: string;
26 sessionPath?: string;
27 };
28
29 type TopicShortcutKeyboardEvent = Pick<globalThis.KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey" | "shiftKey" | "defaultPrevented" | "target">;
30
31 function topicShortcutAction(index: number): ShortcutAction {
32 return `topic.goto.${index}` as ShortcutAction;
33 }
34
35 function isPlatformModifierKey(key: string, platform: ShortcutPlatform): boolean {
36 return platform === "darwin" ? key === "Meta" : key === "Control";
37 }
38
39 function hasOnlyPlatformModifier(event: TopicShortcutKeyboardEvent, platform: ShortcutPlatform): boolean {
40 if (platform === "darwin") return Boolean(event.metaKey) && !event.ctrlKey && !event.altKey && !event.shiftKey;
41 return Boolean(event.ctrlKey) && !event.metaKey && !event.altKey && !event.shiftKey;
42 }
43
44 export function topicShortcutLabel(index: number, platform: ShortcutPlatform = detectShortcutPlatform()): string {
45 return formatShortcutCombo(defaultShortcutCombo(topicShortcutAction(index), platform), platform);
46 }
47
48 export function topicShortcutIndexFromEvent(
49 event: TopicShortcutKeyboardEvent,
50 platform: ShortcutPlatform = detectShortcutPlatform(),
51 ): number | null {
52 if (event.defaultPrevented) return null;
53 // Allow Cmd/Ctrl+1-9 even when focus is in an editable element (input/textarea)
54 // because these are application-level navigation shortcuts, not editor keys
55 // like Cmd+C/V/Z. Only block during shortcut-recording mode. Matches CodeX
56 // behavior where topic shortcuts work regardless of input focus state.
57 if (isShortcutRecorderTarget(event.target ?? null)) return null;
58 for (let index = 1; index <= 9; index += 1) {
59 if (matchesShortcut(event, topicShortcutAction(index), platform)) return index - 1;
60 }
61 return null;
62 }
63
64 export function useTopicShortcuts(
65 enabled = true,
66 platform: ShortcutPlatform = detectShortcutPlatform(),
67 ) {
68 const [showBadges, setShowBadges] = useState(false);
69 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
70 const heldRef = useRef(false);
71
72 const clearTimer = useCallback(() => {
73 if (timerRef.current !== null) {
74 clearTimeout(timerRef.current);
75 timerRef.current = null;
76 }
77 }, []);
78
79 const hideBadges = useCallback(() => {
80 clearTimer();
81 heldRef.current = false;
82 setShowBadges(false);
83 }, [clearTimer]);
84
85 useEffect(() => {
86 if (!enabled) hideBadges();
87 }, [enabled, hideBadges]);
88
89 useEffect(() => {
90 if (!enabled) return undefined;
91
92 const onKeydown = (event: globalThis.KeyboardEvent) => {
93 if (!isPlatformModifierKey(event.key, platform)) {
94 if (heldRef.current) hideBadges();
95 return;
96 }
97 if (!hasOnlyPlatformModifier(event, platform)) {
98 hideBadges();
99 return;
100 }
101 if (heldRef.current) return; // already tracking
102 heldRef.current = true;
103 clearTimer();
104 timerRef.current = setTimeout(() => {
105 timerRef.current = null;
106 setShowBadges(true);
107 }, SHOW_DELAY_MS);
108 };
109
110 const onKeyup = (event: globalThis.KeyboardEvent) => {
111 if (!isPlatformModifierKey(event.key, platform)) return;
112 hideBadges();
113 };
114
115 // If the window loses focus, hide badges
116 const onBlur = () => hideBadges();
117
118 document.addEventListener("keydown", onKeydown);
119 document.addEventListener("keyup", onKeyup);
120 window.addEventListener("blur", onBlur);
121 return () => {
122 document.removeEventListener("keydown", onKeydown);
123 document.removeEventListener("keyup", onKeyup);
124 window.removeEventListener("blur", onBlur);
125 hideBadges();
126 };
127 }, [enabled, platform, clearTimer, hideBadges]);
128
129 return { showBadges, hideBadges };
130 }
131
132 export type { TopicShortcutEntry };
133
133 lines TYPESCRIPT