fix(client-v2): fix IME input for text fields (#9764)

This commit is contained in:
Katherine
2026-06-15 09:48:53 +08:00
committed by GitHub
parent f99ee643ea
commit fa21a210b9
6 changed files with 512 additions and 42 deletions
@@ -9,18 +9,64 @@
import { EditableItemModel, FilterableItemModel, tExpr } from '@nocobase/flow-engine';
import { Input } from 'antd';
import React from 'react';
import type { InputProps, InputRef } from 'antd';
import React, { useEffect, useRef } from 'react';
import { customAlphabet as Alphabet } from 'nanoid';
import { FieldModel } from '../base/FieldModel';
import { ScanInput } from '../../../components/form/ScanInput';
function IMESafeInput(props: InputProps) {
const { value, onChange, onCompositionStart, onCompositionEnd, ...rest } = props;
const inputRef = useRef<InputRef>(null);
const previousValueRef = useRef(value);
const defaultValue = typeof value === 'bigint' ? String(value) : value;
useEffect(() => {
if (Object.is(previousValueRef.current, value)) {
return;
}
previousValueRef.current = value;
const input = inputRef.current?.input;
if (input) {
const nextValue = value == null ? '' : String(value);
if (input.value !== nextValue) {
input.value = nextValue;
}
}
}, [value]);
const getEventValue = (event: React.ChangeEvent<HTMLInputElement> | React.CompositionEvent<HTMLInputElement>) =>
event.currentTarget.value;
return (
<Input
{...rest}
ref={inputRef}
defaultValue={defaultValue}
onChange={(event) => {
previousValueRef.current = getEventValue(event);
onChange?.(event);
}}
onCompositionStart={(event) => {
previousValueRef.current = getEventValue(event);
onCompositionStart?.(event);
}}
onCompositionEnd={(event) => {
previousValueRef.current = getEventValue(event);
onCompositionEnd?.(event);
}}
/>
);
}
export class InputFieldModel extends FieldModel {
render() {
if (this.props.enableScan) {
return <ScanInput {...this.props} />;
}
const { enableScan, disableManualInput, ...inputProps } = this.props;
return <Input {...inputProps} />;
return <IMESafeInput {...inputProps} />;
}
}
@@ -8,15 +8,61 @@
*/
import { Input } from 'antd';
import React from 'react';
import _ from 'lodash';
import type { TextAreaProps } from 'antd/es/input';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import React, { useEffect, useRef } from 'react';
import { largeField, EditableItemModel } from '@nocobase/flow-engine';
import { FieldModel } from '../base/FieldModel';
function IMESafeTextArea(props: TextAreaProps) {
const { value, onChange, onCompositionStart, onCompositionEnd, ...rest } = props;
const textAreaRef = useRef<TextAreaRef>(null);
const previousValueRef = useRef(value);
const defaultValue = typeof value === 'bigint' ? String(value) : value;
useEffect(() => {
if (Object.is(previousValueRef.current, value)) {
return;
}
previousValueRef.current = value;
const textArea = textAreaRef.current?.resizableTextArea?.textArea;
if (textArea) {
const nextValue = value == null ? '' : String(value);
if (textArea.value !== nextValue) {
textArea.value = nextValue;
}
}
}, [value]);
const getEventValue = (event: React.ChangeEvent<HTMLTextAreaElement> | React.CompositionEvent<HTMLTextAreaElement>) =>
event.currentTarget.value;
return (
<Input.TextArea
{...rest}
ref={textAreaRef}
defaultValue={defaultValue}
onChange={(event) => {
previousValueRef.current = getEventValue(event);
onChange?.(event);
}}
onCompositionStart={(event) => {
previousValueRef.current = getEventValue(event);
onCompositionStart?.(event);
}}
onCompositionEnd={(event) => {
previousValueRef.current = getEventValue(event);
onCompositionEnd?.(event);
}}
/>
);
}
@largeField()
export class TextareaFieldModel extends FieldModel {
render() {
return <Input.TextArea {...this.props} />;
return <IMESafeTextArea {...this.props} />;
}
}
@@ -8,7 +8,7 @@
*/
import { FlowEngine } from '@nocobase/flow-engine';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { InputFieldModel } from '../InputFieldModel';
@@ -43,6 +43,105 @@ describe('InputFieldModel', () => {
consoleError.mockRestore();
});
it('keeps IME composition text visible before the parent value is committed', () => {
const onChange = vi.fn();
const onCompositionStart = vi.fn();
const onCompositionEnd = vi.fn();
const model = createInputFieldModel({
value: '',
onChange,
onCompositionStart,
onCompositionEnd,
});
render(<>{model.render()}</>);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.compositionStart(input);
fireEvent.change(input, { target: { value: 'ni' }, nativeEvent: { isComposing: true } });
expect(input.value).toBe('ni');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onCompositionStart).toHaveBeenCalledTimes(1);
fireEvent.compositionEnd(input, { target: { value: '你' } });
expect(input.value).toBe('你');
expect(onCompositionEnd).toHaveBeenCalledTimes(1);
});
it('returns to the committed parent value after IME composition is submitted', () => {
const onChange = vi.fn();
const onCompositionEnd = vi.fn();
const createModel = (value: string) =>
createInputFieldModel({
value,
onChange,
onCompositionEnd,
});
const { rerender } = render(<>{createModel('').render()}</>);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.compositionStart(input);
fireEvent.change(input, { target: { value: 'ni' }, nativeEvent: { isComposing: true } });
fireEvent.compositionEnd(input, { target: { value: '你' } });
expect(input.value).toBe('你');
rerender(<>{createModel('你').render()}</>);
expect(input.value).toBe('你');
});
it('does not overwrite in-progress DOM input with a stale parent value', async () => {
const onChange = vi.fn();
const createModel = (value: string) =>
createInputFieldModel({
value,
onChange,
});
const { rerender } = render(<>{createModel('').render()}</>);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'té' } });
rerender(<>{createModel('').render()}</>);
expect(input.value).toBe('té');
rerender(<>{createModel('reset').render()}</>);
await waitFor(() => {
expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('reset');
});
});
it('keeps replacement-style Vietnamese IME edits in the DOM', () => {
const onChange = vi.fn();
const createModel = (value: string) =>
createInputFieldModel({
value,
onChange,
});
const { rerender } = render(<>{createModel('').render()}</>);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'te' } });
rerender(<>{createModel('te').render()}</>);
expect(input.value).toBe('te');
fireEvent.change(input, { target: { value: 'té' } });
rerender(<>{createModel('te').render()}</>);
expect(input.value).toBe('té');
fireEvent.change(input, { target: { value: 'tét' } });
rerender(<>{createModel('té').render()}</>);
expect(input.value).toBe('tét');
});
it('renders ScanInput when scan input is enabled', () => {
const model = createInputFieldModel({ enableScan: true });
@@ -0,0 +1,100 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { FlowEngine } from '@nocobase/flow-engine';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { TextareaFieldModel } from '../TextareaFieldModel';
function createTextareaFieldModel(props?: Record<string, unknown>) {
const engine = new FlowEngine();
return new TextareaFieldModel({
uid: 'textarea-field-model-test',
flowEngine: engine,
props,
});
}
describe('TextareaFieldModel', () => {
it('keeps IME composition text visible before the parent value is committed', () => {
const onChange = vi.fn();
const onCompositionStart = vi.fn();
const onCompositionEnd = vi.fn();
const model = createTextareaFieldModel({
value: '',
onChange,
onCompositionStart,
onCompositionEnd,
});
render(<>{model.render()}</>);
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.compositionStart(textarea);
fireEvent.change(textarea, { target: { value: 'ni' }, nativeEvent: { isComposing: true } });
expect(textarea.value).toBe('ni');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onCompositionStart).toHaveBeenCalledTimes(1);
fireEvent.compositionEnd(textarea, { target: { value: '你' } });
expect(textarea.value).toBe('你');
expect(onCompositionEnd).toHaveBeenCalledTimes(1);
});
it('does not overwrite in-progress DOM input with a stale parent value', async () => {
const onChange = vi.fn();
const createModel = (value: string) =>
createTextareaFieldModel({
value,
onChange,
});
const { rerender } = render(<>{createModel('').render()}</>);
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: 'té' } });
rerender(<>{createModel('').render()}</>);
expect(textarea.value).toBe('té');
rerender(<>{createModel('reset').render()}</>);
await waitFor(() => {
expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toBe('reset');
});
});
it('keeps replacement-style Vietnamese IME edits in the DOM', () => {
const onChange = vi.fn();
const createModel = (value: string) =>
createTextareaFieldModel({
value,
onChange,
});
const { rerender } = render(<>{createModel('').render()}</>);
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: 'te' } });
rerender(<>{createModel('te').render()}</>);
expect(textarea.value).toBe('te');
fireEvent.change(textarea, { target: { value: 'té' } });
rerender(<>{createModel('te').render()}</>);
expect(textarea.value).toBe('té');
fireEvent.change(textarea, { target: { value: 'tét' } });
rerender(<>{createModel('té').render()}</>);
expect(textarea.value).toBe('tét');
});
});
@@ -10,7 +10,7 @@
import type { FlowModelRendererProps } from './FlowModelRenderer';
import { FlowModelRenderer } from './FlowModelRenderer';
import _ from 'lodash';
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
const flowModelRendererPropKeys: (keyof FlowModelRendererProps)[] = [
'model',
@@ -28,49 +28,63 @@ const flowModelRendererPropKeys: (keyof FlowModelRendererProps)[] = [
];
export function FieldModelRenderer(props: any) {
const { model, ...rest } = props;
const { model, onChange, ...rest } = props;
const composingRef = useRef(false);
const handleChange = (e: any) => {
let val;
if (e && e.target && typeof e.target.value !== 'undefined') {
val = e.target.value;
} else if (
typeof e === 'string' ||
typeof e === 'number' ||
typeof e === 'boolean' ||
(typeof e === 'object' && !(e instanceof Event))
) {
val = e;
} else {
val = null;
}
const handleChange = useCallback(
(e: any) => {
let val;
if (e && e.target && typeof e.target.value !== 'undefined') {
val = e.target.value;
} else if (
typeof e === 'string' ||
typeof e === 'number' ||
typeof e === 'boolean' ||
(typeof e === 'object' && !(e instanceof Event))
) {
val = e;
} else {
val = null;
}
model.setProps({ value: val });
if (!composingRef.current) {
props.onChange?.(val);
}
};
const handleCompositionStart = () => {
const isComposing = composingRef.current || e?.nativeEvent?.isComposing;
if (isComposing) {
return;
}
model.setProps({ value: val });
if (!composingRef.current) {
onChange?.(val);
}
},
[model, onChange],
);
const handleCompositionStart = useCallback(() => {
composingRef.current = true;
};
}, []);
const handleCompositionEnd = (e: React.CompositionEvent<HTMLInputElement>, flag = true) => {
composingRef.current = false;
if (flag) {
props.onChange(e);
}
};
const handleCompositionEnd = useCallback(
(e: React.CompositionEvent<HTMLInputElement>, flag = true) => {
composingRef.current = false;
if (flag) {
handleChange(e);
}
},
[handleChange],
);
const modelProps = {
onCompositionStart: handleCompositionStart,
onCompositionEnd: handleCompositionEnd,
..._.omit(rest, flowModelRendererPropKeys),
onChange: handleChange,
};
const modelProps = useMemo(
() => ({
onCompositionStart: handleCompositionStart,
onCompositionEnd: handleCompositionEnd,
..._.omit(rest, flowModelRendererPropKeys),
onChange: handleChange,
}),
[handleChange, handleCompositionEnd, handleCompositionStart, rest],
);
useEffect(() => {
model && model.setProps(modelProps);
}, [modelProps]);
}, [model, modelProps]);
return <FlowModelRenderer model={model} {...rest} />;
}
@@ -0,0 +1,165 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { vi } from 'vitest';
import { FlowEngine } from '../../flowEngine';
import { FlowModel } from '../../models/flowModel';
import { FlowEngineProvider } from '../../provider';
import { FieldModelRenderer } from '../FieldModelRenderer';
class InputModel extends FlowModel {
render(): React.ReactNode {
return (
<input
data-testid="field-input"
value={this.props.value ?? ''}
onChange={this.props.onChange}
onCompositionStart={this.props.onCompositionStart}
onCompositionEnd={this.props.onCompositionEnd}
/>
);
}
}
describe('FieldModelRenderer', () => {
it('updates model and form value for normal input changes', async () => {
const flowEngine = new FlowEngine();
const model = new InputModel({
uid: 'normal-input-field-model',
flowEngine,
props: {
value: '',
},
});
model.dispatchEvent = vi.fn().mockResolvedValue([]);
const onChange = vi.fn();
render(
<FlowEngineProvider engine={flowEngine}>
<FieldModelRenderer model={model} onChange={onChange} />
</FlowEngineProvider>,
);
const input = await screen.findByTestId('field-input');
await waitFor(() => {
expect(model.props.onChange).toBeTypeOf('function');
});
fireEvent.change(input, { target: { value: 'abc' } });
expect(model.props.value).toBe('abc');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('abc');
});
it('updates model and form value when field components pass values directly', async () => {
const flowEngine = new FlowEngine();
const model = new InputModel({
uid: 'direct-value-field-model',
flowEngine,
props: {
value: '',
},
});
model.dispatchEvent = vi.fn().mockResolvedValue([]);
const onChange = vi.fn();
render(
<FlowEngineProvider engine={flowEngine}>
<FieldModelRenderer model={model} onChange={onChange} />
</FlowEngineProvider>,
);
await screen.findByTestId('field-input');
await waitFor(() => {
expect(model.props.onChange).toBeTypeOf('function');
});
act(() => {
model.props.onChange(123);
});
expect(model.props.value).toBe(123);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(123);
});
it('defers model and form value updates while IME composition is active', async () => {
const flowEngine = new FlowEngine();
const model = new InputModel({
uid: 'input-field-model',
flowEngine,
props: {
value: '',
},
});
model.dispatchEvent = vi.fn().mockResolvedValue([]);
const onChange = vi.fn();
render(
<FlowEngineProvider engine={flowEngine}>
<FieldModelRenderer model={model} onChange={onChange} />
</FlowEngineProvider>,
);
const input = await screen.findByTestId('field-input');
await waitFor(() => {
expect(model.props.onChange).toBeTypeOf('function');
});
fireEvent.compositionStart(input);
fireEvent.change(input, { target: { value: 't' }, nativeEvent: { isComposing: true } });
expect(model.props.value).toBe('');
expect(onChange).not.toHaveBeenCalled();
fireEvent.compositionEnd(input, { target: { value: 'te' } });
expect(model.props.value).toBe('te');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('te');
});
it('does not commit composition value when composition end opts out', async () => {
const flowEngine = new FlowEngine();
const model = new InputModel({
uid: 'composition-opt-out-field-model',
flowEngine,
props: {
value: '',
},
});
model.dispatchEvent = vi.fn().mockResolvedValue([]);
const onChange = vi.fn();
render(
<FlowEngineProvider engine={flowEngine}>
<FieldModelRenderer model={model} onChange={onChange} />
</FlowEngineProvider>,
);
await screen.findByTestId('field-input');
await waitFor(() => {
expect(model.props.onChange).toBeTypeOf('function');
expect(model.props.onCompositionStart).toBeTypeOf('function');
expect(model.props.onCompositionEnd).toBeTypeOf('function');
});
act(() => {
model.props.onCompositionStart();
model.props.onChange({ target: { value: 'search' }, nativeEvent: { isComposing: true } });
model.props.onCompositionEnd({ target: { value: 'search' } }, false);
});
expect(model.props.value).toBe('');
expect(onChange).not.toHaveBeenCalled();
});
});