fix: prevent IME enter from submitting composer

This commit is contained in:
qer 2026-06-11 17:09:00 +08:00
parent 7e9606a0b2
commit b992196c29
2 changed files with 97 additions and 0 deletions

View file

@ -328,6 +328,7 @@ onUnmounted(() => {
for (const att of attachments.value) {
revokeAttachment(att);
}
clearCompositionEndTimer();
});
// ---------------------------------------------------------------------------
@ -422,7 +423,36 @@ function handleSteer(): void {
emit('steer', payload);
}
let isComposingText = false;
let compositionEndTimer: ReturnType<typeof setTimeout> | null = null;
function clearCompositionEndTimer(): void {
if (compositionEndTimer !== null) {
clearTimeout(compositionEndTimer);
compositionEndTimer = null;
}
}
function handleCompositionStart(): void {
clearCompositionEndTimer();
isComposingText = true;
}
function handleCompositionEnd(): void {
clearCompositionEndTimer();
compositionEndTimer = setTimeout(() => {
compositionEndTimer = null;
isComposingText = false;
}, 0);
}
function isComposingKeyEvent(e: KeyboardEvent): boolean {
return isComposingText || e.isComposing || e.keyCode === 229;
}
function handleKeydown(e: KeyboardEvent): void {
if (isComposingKeyEvent(e)) return;
// Close dropdowns on Escape
if (e.key === 'Escape') {
if (dropdownOpen.value) {
@ -729,6 +759,8 @@ function selectModel(modelId: string): void {
:placeholder="placeholder"
rows="1"
@keydown="handleKeydown"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@input="handleInput"
/>

View file

@ -0,0 +1,65 @@
import { mount } from '@vue/test-utils';
import { createI18n } from 'vue-i18n';
import { afterEach, describe, expect, it } from 'vitest';
import Composer from '../src/components/Composer.vue';
function mountComposer() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
composer: {
interrupt: 'Interrupt',
interruptTitle: 'Interrupt',
placeholder: 'Message Kimi',
send: 'Send',
},
},
},
});
return mount(Composer, {
global: {
plugins: [i18n],
},
});
}
function waitForCompositionEndTimer(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('Composer IME input', () => {
it('does not submit when Enter confirms active composition', async () => {
const wrapper = mountComposer();
const textarea = wrapper.get('textarea');
await textarea.setValue('ni');
await textarea.trigger('compositionstart');
await textarea.trigger('keydown', { key: 'Enter', isComposing: true });
expect(wrapper.emitted('submit')).toBeUndefined();
});
it('does not submit the Enter that immediately follows compositionend', async () => {
const wrapper = mountComposer();
const textarea = wrapper.get('textarea');
await textarea.setValue('你好');
await textarea.trigger('compositionstart');
await textarea.trigger('compositionend');
await textarea.trigger('keydown', { key: 'Enter', isComposing: false });
expect(wrapper.emitted('submit')).toBeUndefined();
await waitForCompositionEndTimer();
await textarea.trigger('keydown', { key: 'Enter', isComposing: false });
expect(wrapper.emitted('submit')).toEqual([[{ text: '你好', attachments: [] }]]);
});
});