Skip to content

Commit 168742b

Browse files
takataka
authored andcommitted
Preserve connection capsules during IME composition
1 parent 1cb5e3d commit 168742b

1 file changed

Lines changed: 29 additions & 1 deletion

File tree

packages/workshop-frontend/src/ChatInterface.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1892,6 +1892,13 @@ export const ChatInput = ({
18921892
// Keep inputValue in a ref so handleCursorChange can read it without re-binding.
18931893
const inputValueRef = useRef(inputValue);
18941894
inputValueRef.current = inputValue;
1895+
// Mobile Safari emits controlled-textarea changes while an IME composition is still in
1896+
// progress. Treating each provisional value as an ordinary edit can make its occasionally stale
1897+
// selection range look as though the edit crossed a connection capsule, which removes the
1898+
// capsule. Keep rendering the provisional text, but defer token bookkeeping until composition
1899+
// ends and compare the committed value with the value from before composition began.
1900+
const isComposingRef = useRef(false);
1901+
const compositionStartValueRef = useRef("");
18951902

18961903
// Seed the composer from an external suggestion (Home task cards). Re-runs whenever the nonce
18971904
// changes so picking the same suggestion twice still works. Focus + move the cursor to the end.
@@ -3089,13 +3096,31 @@ export const ChatInput = ({
30893096
aria-controls={slashCommandPicker.open ? slashCommandPicker.listboxId : undefined}
30903097
aria-activedescendant={slashCommandPicker.activeDescendant}
30913098
onChange={(e) => {
3092-
handleInputChange(e.target.value, e.target.selectionStart ?? 0);
3099+
if (isComposingRef.current) {
3100+
setInputValue(e.target.value);
3101+
} else {
3102+
handleInputChange(e.target.value, e.target.selectionStart ?? 0);
3103+
}
30933104
syncPickerCaret(e.target.selectionStart ?? 0);
30943105
requestAnimationFrame(handleCursorChange);
30953106
// Auto-resize after value change
30963107
autoResizeTextarea(e.target, minRows, newChat ? 10 : 4);
30973108
syncMirrorScroll(e.target);
30983109
}}
3110+
onCompositionStart={() => {
3111+
isComposingRef.current = true;
3112+
compositionStartValueRef.current = inputValueRef.current;
3113+
}}
3114+
onCompositionEnd={(e) => {
3115+
isComposingRef.current = false;
3116+
// handleInputChange deliberately reads the old value through inputValueRef. React
3117+
// state has tracked provisional composition text, so restore only the ref long
3118+
// enough to calculate the complete committed edit against the pre-IME value.
3119+
inputValueRef.current = compositionStartValueRef.current;
3120+
handleInputChange(e.currentTarget.value, e.currentTarget.selectionStart ?? 0);
3121+
syncPickerCaret(e.currentTarget.selectionStart ?? 0);
3122+
requestAnimationFrame(handleCursorChange);
3123+
}}
30993124
onSelect={handleCursorChange}
31003125
onClick={handleCursorChange}
31013126
onKeyUp={handleCursorChange}
@@ -3146,6 +3171,9 @@ export const ChatInput = ({
31463171
}
31473172
}}
31483173
onKeyDown={(e) => {
3174+
// Enter confirms Japanese input on iOS. It must not select a slash command or send
3175+
// the message while the browser still considers the keypress part of composition.
3176+
if (e.nativeEvent.isComposing || isComposingRef.current) return;
31493177
if (slashCommandPicker.open && e.key === "Escape") {
31503178
e.preventDefault();
31513179
slashCommandPicker.dismiss();

0 commit comments

Comments
 (0)