返回 DeepSeek-Reasonix
composerKeyboard.ts
根目录 / desktop / frontend / src / lib / composerKeyboard.ts
1 import { loadCustomShortcuts, matchesShortcut, type ShortcutPlatform } from "./keyboardShortcuts";
2 import { replaceInvocationTextRange, type ComposerInvocation } from "./invocationDisplay";
3
4 export type PromptHistoryDirection = "up" | "down";
5
6 export interface ComposerEnterKeyLike {
7 key: string;
8 ctrlKey?: boolean;
9 metaKey?: boolean;
10 altKey?: boolean;
11 shiftKey?: boolean;
12 }
13
14 export interface ComposerImeKeyLike {
15 key?: string;
16 isComposing?: boolean;
17 keyCode?: number;
18 }
19
20 // Keep the post-composition window short enough that a deliberate second
21 // shortcut remains responsive while covering browser IME event reordering.
22 export const IME_CONFIRM_GRACE_MS = 100;
23
24 export function isImeKeyEvent(
25 event: ComposerImeKeyLike,
26 composing: boolean,
27 lastCompositionEndAt: number,
28 now = Date.now(),
29 ): boolean {
30 const inGraceWindow =
31 lastCompositionEndAt > 0
32 && now >= lastCompositionEndAt
33 && now - lastCompositionEndAt < IME_CONFIRM_GRACE_MS;
34 return composing || event.isComposing === true || event.keyCode === 229 || inGraceWindow;
35 }
36
37 export type ComposerEscapeAction = "cancel" | "pass-through";
38
39 export function composerEscapeAction(
40 event: Pick<ComposerImeKeyLike, "key">,
41 running: boolean,
42 composing: boolean,
43 ): ComposerEscapeAction {
44 return event.key === "Escape" && running && !composing ? "cancel" : "pass-through";
45 }
46
47 export type ComposerMenuKeyAction = "handle" | "pass-through";
48
49 export function composerMenuKeyAction(
50 event: Pick<ComposerImeKeyLike, "key">,
51 composing: boolean,
52 ): ComposerMenuKeyAction {
53 if (composing) return "pass-through";
54 switch (event.key) {
55 case "ArrowDown":
56 case "ArrowUp":
57 case "Enter":
58 case "Tab":
59 case "Escape":
60 return "handle";
61 default:
62 return "pass-through";
63 }
64 }
65
66 // "newline-native" keeps the browser's own line-break insertion (only the
67 // plain Shift+Enter chord, today's proven path); "newline-insert" means the
68 // chord has no native insertion (e.g. Ctrl+Enter) so the composer must insert
69 // the "\n" itself. "none" swallows the chord: an Enter combo matching neither
70 // the send nor the newline shortcut must not fall through to the input, or
71 // native insertion would resurrect the unbound default.
72 export type ComposerEnterAction = "send" | "newline-native" | "newline-insert" | "none";
73
74 export function composerEnterAction(event: ComposerEnterKeyLike, platform: ShortcutPlatform): ComposerEnterAction | null {
75 if (event.key !== "Enter") return null;
76 if (matchesShortcut(event, "composer.newline", platform)) {
77 const nativeInserts = Boolean(event.shiftKey) && !event.ctrlKey && !event.metaKey && !event.altKey;
78 return nativeInserts ? "newline-native" : "newline-insert";
79 }
80 if (matchesShortcut(event, "composer.send", platform)) return "send";
81 // Before composer shortcuts were configurable, every non-Shift Enter chord
82 // submitted. Preserve that default compatibility, but stop applying it as
83 // soon as the user explicitly chooses a send chord.
84 if (!loadCustomShortcuts()["composer.send"] && !event.shiftKey) return "send";
85 // Plain Enter bound to neither chord (send moved to e.g. Ctrl+Enter) still
86 // breaks the line — the WeChat/DingTalk-style layout users expect there.
87 if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) return "newline-insert";
88 return "none";
89 }
90
91 export interface ComposerSelectionLike {
92 start: number;
93 end: number;
94 afterInvocationId?: string;
95 }
96
97 export function insertComposerNewline(
98 text: string,
99 invocations: ComposerInvocation[],
100 selection: ComposerSelectionLike,
101 ): { text: string; invocations: ComposerInvocation[] } {
102 return replaceInvocationTextRange(
103 text,
104 invocations,
105 selection.start,
106 selection.end,
107 "\n",
108 selection.afterInvocationId,
109 );
110 }
111
112 export interface PromptHistoryKeyLike {
113 key?: string;
114 code?: string;
115 keyCode?: number;
116 which?: number;
117 getModifierState?: (keyArg: string) => boolean;
118 }
119
120 export interface PromptHistoryEligibility {
121 direction: PromptHistoryDirection | null;
122 menuOpen: boolean;
123 composing: boolean;
124 altKey: boolean;
125 ctrlKey: boolean;
126 metaKey: boolean;
127 shiftKey: boolean;
128 fnKey: boolean;
129 value: string;
130 selectionStart: number | null;
131 selectionEnd: number | null;
132 historyIndex: number;
133 }
134
135 export function isFnKeyEvent(event: PromptHistoryKeyLike): boolean {
136 return event.key === "Fn" || event.code === "Fn" || event.getModifierState?.("Fn") === true;
137 }
138
139 export function promptHistoryDirectionFromEvent(event: PromptHistoryKeyLike): PromptHistoryDirection | null {
140 const key = event.key ?? "";
141 const code = event.code ?? "";
142 const keyCode = event.keyCode ?? event.which ?? 0;
143 const useLegacyCode = key === "" || key === "Unidentified";
144
145 if (key === "ArrowUp" || key === "Up" || code === "ArrowUp" || (useLegacyCode && keyCode === 38)) {
146 return "up";
147 }
148 if (key === "ArrowDown" || key === "Down" || code === "ArrowDown" || (useLegacyCode && keyCode === 40)) {
149 return "down";
150 }
151 return null;
152 }
153
154 export function canUsePromptHistory(options: PromptHistoryEligibility): boolean {
155 const {
156 direction,
157 menuOpen,
158 composing,
159 altKey,
160 ctrlKey,
161 metaKey,
162 shiftKey,
163 fnKey,
164 value,
165 selectionStart,
166 selectionEnd,
167 historyIndex,
168 } = options;
169 if (!direction || menuOpen || composing || fnKey || altKey || ctrlKey || metaKey || shiftKey) return false;
170 if (selectionStart === null || selectionEnd === null) return false;
171 if (selectionStart !== selectionEnd) return false;
172
173 if (direction === "up") {
174 return historyIndex >= 0 || selectionStart === 0;
175 }
176
177 return historyIndex >= 0 && selectionEnd === value.length;
178 }
179
179 lines TYPESCRIPT