返回 DeepSeek-Reasonix
useComposerImeGuard.ts
根目录 / desktop / frontend / src / lib / useComposerImeGuard.ts
1 import { useEffect, useLayoutEffect, useRef, type RefObject } from "react";
2
3 // Plain-textarea IME guard (#8593/#8409). While a composition is active the
4 // composer textarea renders uncontrolled (value={undefined}), so no unrelated
5 // re-render (autosize, selection tracking, run-strip ticker) can write
6 // node.value and cancel the in-flight composition — React 19's updateTextarea
7 // compares the controlled value against the live DOM value on every commit,
8 // which is what swallowed the first CJK keystroke. onChange still flows into
9 // state; compositionend (or a non-composing commit input) ends the freeze and
10 // the next render resyncs against the live DOM value without clobbering it.
11
12 export interface ComposerImeGuardOptions {
13 taRef: RefObject<HTMLTextAreaElement | null>;
14 text: string;
15 // Drives listener (re)attachment: the plain textarea unmounts while an
16 // invocation token mounts the rich input.
17 invocationCount: number;
18 textRef: RefObject<string>;
19 lastSelectionRef: RefObject<{ start: number; end: number }>;
20 setText: (next: string) => void;
21 setPlainSelection: (selection: { start: number; end: number }) => void;
22 }
23
24 export interface ComposerImeGuard {
25 composingRef: RefObject<boolean>;
26 lastCompositionEndAt: RefObject<number>;
27 // Feeds the textarea's onChange into the freeze bookkeeping.
28 trackImeInputChange: (nativeEvent: InputEvent, inputType: string | undefined, value: string) => void;
29 }
30
31 export function useComposerImeGuard(options: ComposerImeGuardOptions): ComposerImeGuard {
32 const { taRef, text, invocationCount, textRef, lastSelectionRef, setText, setPlainSelection } = options;
33 const composingRef = useRef(false);
34 const lastCompositionEndAt = useRef(0);
35 const setTextRef = useRef(setText);
36 const setPlainSelectionRef = useRef(setPlainSelection);
37 setTextRef.current = setText;
38 setPlainSelectionRef.current = setPlainSelection;
39 // Latest text the IME path itself produced, so a programmatic setText
40 // landing mid-composition can be told apart from IME input and force a
41 // resync.
42 const imeStateTextRef = useRef<string | null>(null);
43
44 // Native composition listeners, the same mechanism the rich input uses:
45 // React's synthetic composition events fall back to keyCode-229 inference
46 // wherever CompositionEvent is missing, and the freeze bookkeeping must run
47 // synchronously with the browser's composition lifecycle.
48 useEffect(() => {
49 const node = taRef.current;
50 if (!node) return;
51 const onStart = () => {
52 composingRef.current = true;
53 imeStateTextRef.current = textRef.current;
54 };
55 const onEnd = () => {
56 composingRef.current = false;
57 lastCompositionEndAt.current = Date.now();
58 imeStateTextRef.current = null;
59 // compositionend's DOM value is authoritative: an IME cancel restores
60 // the pre-composition text while state may still hold the provisional
61 // text.
62 if (node.value !== textRef.current) {
63 const nextSelection = {
64 start: node.selectionStart ?? node.value.length,
65 end: node.selectionEnd ?? node.value.length,
66 };
67 textRef.current = node.value;
68 setTextRef.current(node.value);
69 lastSelectionRef.current = nextSelection;
70 setPlainSelectionRef.current(nextSelection);
71 }
72 };
73 node.addEventListener("compositionstart", onStart);
74 node.addEventListener("compositionend", onEnd);
75 return () => {
76 node.removeEventListener("compositionstart", onStart);
77 node.removeEventListener("compositionend", onEnd);
78 // Unmounting the textarea mid-composition (e.g. an invocation token
79 // swaps in the rich input) may never deliver compositionend; leaving
80 // composingRef stuck would suppress Enter-to-send forever.
81 if (composingRef.current) {
82 composingRef.current = false;
83 lastCompositionEndAt.current = Date.now();
84 imeStateTextRef.current = null;
85 }
86 };
87 }, [invocationCount, taRef, textRef, lastSelectionRef]);
88
89 // Programmatic setText (history recall, menu inserts, draft switches)
90 // bypasses the textarea's onChange, so while the IME freeze renders the
91 // textarea uncontrolled those writes would never reach the DOM. A text
92 // change the IME path did not produce forces an authoritative resync and
93 // ends the frozen composition: programmatic content wins.
94 useLayoutEffect(() => {
95 if (!composingRef.current) return;
96 if (imeStateTextRef.current === text) return;
97 composingRef.current = false;
98 imeStateTextRef.current = null;
99 const node = taRef.current;
100 if (!node) return;
101 node.value = text;
102 const caret = Math.min(lastSelectionRef.current.start, text.length);
103 try {
104 node.setSelectionRange(caret, caret);
105 } catch {
106 // Detached/jsdom node: caret restore is best-effort.
107 }
108 }, [text, taRef, lastSelectionRef]);
109
110 const trackImeInputChange = (nativeEvent: InputEvent, inputType: string | undefined, value: string) => {
111 if (!composingRef.current) return;
112 if (nativeEvent.isComposing || inputType === "insertCompositionText") {
113 // Mid-composition edits still reach state so app logic (menus,
114 // counters, drafts) sees the live text; the uncontrolled render keeps
115 // the provisional text out of React's DOM sync.
116 imeStateTextRef.current = value;
117 return;
118 }
119 // A non-composition input while frozen is the commit (Chromium can fire
120 // it before compositionend; WebView2 can deliver the committed text in a
121 // following non-composing input): end the freeze so this render resyncs
122 // normally.
123 composingRef.current = false;
124 imeStateTextRef.current = null;
125 };
126
127 return { composingRef, lastCompositionEndAt, trackImeInputChange };
128 }
129
129 lines TYPESCRIPT