diff --git a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js index 55b770d26a35ef..89ef19a51ab2c4 100644 --- a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +++ b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js @@ -409,7 +409,20 @@ export type NativeProps = $ReadOnly<{| onSelectionChange?: ?DirectEventHandler< $ReadOnly<{| target: Int32, - selection: $ReadOnly<{|start: Double, end: Double|}>, + selection: $ReadOnly<{| + start: Double, + end: Double, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: Double, + y: Double, + |}>, + end: $ReadOnly<{| + x: Double, + y: Double, + |}>, + |}>, + |}>, |}>, >, diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts b/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts index 97fe9371065419..819335524b2019 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts @@ -416,6 +416,16 @@ export interface TextInputSelectionChangeEventData extends TargetedEvent { selection: { start: number; end: number; + cursorPosition: { + start: { + x: number; + y: number; + }; + end: { + x: number; + y: number; + }; + }; }; } @@ -817,7 +827,28 @@ export interface TextInputProps * The start and end of the text input's selection. Set start and end to * the same value to position the cursor. */ - selection?: {start: number; end?: number | undefined} | undefined; + selection?: + | { + start: number; + end?: number | undefined; + cursorPosition: + | { + start: + | { + x: number; + y: number; + } + | undefined; + end: + | { + x: number; + y: number; + } + | undefined; + } + | undefined; + } + | undefined; /** * The highlight (and cursor on ios) color of the text input diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js index be702737024815..dbb73fc58108ad 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js @@ -69,6 +69,16 @@ export type FocusEvent = TargetEvent; type Selection = $ReadOnly<{| start: number, end: number, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: number, + y: number, + |}>, + end: $ReadOnly<{| + x: number, + y: number, + |}>, + |}>, |}>; export type SelectionChangeEvent = SyntheticEvent< @@ -837,6 +847,16 @@ export type Props = $ReadOnly<{| selection?: ?$ReadOnly<{| start: number, end?: ?number, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: number, + y: number, + |}>, + end: $ReadOnly<{| + x: number, + y: number, + |}>, + |}>, |}>, /** diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index 657145ef2ad781..64c601741c9923 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -39,7 +39,14 @@ type TextInputInstance = React.ElementRef> & { +clear: () => void, +isFocused: () => boolean, +getNativeRef: () => ?React.ElementRef>, - +setSelection: (start: number, end: number) => void, + +setSelection: ( + start: number, + end: number, + cursorPosition: { + start: {x: number, y: number}, + end: {x: number, y: number}, + }, + ) => void, }; let AndroidTextInput; @@ -79,6 +86,16 @@ export type TextInputEvent = SyntheticEvent< range: $ReadOnly<{| start: number, end: number, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: number, + y: number, + |}>, + end: $ReadOnly<{| + x: number, + y: number, + |}>, + |}>, |}>, target: number, text: string, @@ -107,6 +124,16 @@ export type FocusEvent = TargetEvent; type Selection = $ReadOnly<{| start: number, end: number, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: number, + y: number, + |}>, + end: $ReadOnly<{| + x: number, + y: number, + |}>, + |}>, |}>; export type SelectionChangeEvent = SyntheticEvent< @@ -798,7 +825,7 @@ export type Props = $ReadOnly<{| /** * Callback that is called when the text input selection is changed. * This will be called with - * `{ nativeEvent: { selection: { start, end } } }`. + * `{ nativeEvent: { selection: { start, end, cursorPosition: {start: {x, y}, end: {x, y}}} } }`. */ onSelectionChange?: ?(e: SelectionChangeEvent) => mixed, @@ -875,10 +902,21 @@ export type Props = $ReadOnly<{| /** * The start and end of the text input's selection. Set start and end to * the same value to position the cursor. + * cursorPosition specify the location of the cursor */ selection?: ?$ReadOnly<{| start: number, end?: ?number, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: number, + y: number, + |}>, + end: $ReadOnly<{| + x: number, + y: number, + |}>, + |}>, |}>, /** @@ -1092,6 +1130,16 @@ function InternalTextInput(props: Props): React.Node { : { start: propsSelection.start, end: propsSelection.end ?? propsSelection.start, + cursorPosition: { + start: { + x: propsSelection?.cursorPosition?.start?.x ?? 0, + y: propsSelection?.cursorPosition?.start?.y ?? 0, + }, + end: { + x: propsSelection?.cursorPosition?.end?.x ?? 0, + y: propsSelection?.cursorPosition?.end?.y ?? 0, + }, + }, }; const [mostRecentEventCount, setMostRecentEventCount] = useState(0); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java index b27ace40cc98cf..c37132cf7c01b5 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java @@ -29,6 +29,7 @@ import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.EditorInfo; +import android.view.ViewTreeObserver; import android.widget.TextView; import androidx.annotation.Nullable; import androidx.autofill.HintConstants; @@ -1232,18 +1233,67 @@ public ReactSelectionWatcher(ReactEditText editText) { @Override public void onSelectionChanged(int start, int end) { // Android will call us back for both the SELECTION_START span and SELECTION_END span in text - // To prevent double calling back into js we cache the result of the previous call and only + // To prevent double calling back into js, we cache the result of the previous call and only // forward it on if we have new values // Apparently Android might call this with an end value that is less than the start value // Lets normalize them. See https://github.com/facebook/react-native/issues/18579 + Layout layout = mReactEditText.getLayout(); + if (layout == null) { + mReactEditText.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + mReactEditText.getViewTreeObserver().removeOnGlobalLayoutListener(this); + onSelectionChanged(start, end); + } + }); + return; + } int realStart = Math.min(start, end); int realEnd = Math.max(start, end); + int cursorPositionStartX = 0; + int cursorPositionStartY = 0; + int cursorPositionEndX = 0; + int cursorPositionEndY = 0; + + int lineStart = layout.getLineForOffset(realStart); + int baselineStart = layout.getLineBaseline(lineStart); + int ascentStart = layout.getLineAscent(lineStart); + cursorPositionStartX = (int) Math.round(PixelUtil.toDIPFromPixel(layout.getPrimaryHorizontal(realStart))); + cursorPositionStartY = (int) Math.round(PixelUtil.toDIPFromPixel(baselineStart + ascentStart)); + int lineEnd = layout.getLineForOffset(realEnd); + int baselineEnd = layout.getLineBaseline(lineEnd); + int ascentEnd = layout.getLineAscent(lineEnd); + int descentEnd = layout.getLineDescent(lineEnd); + + float right = layout.getPrimaryHorizontal(realEnd); + float bottom = layout.getLineBaseline(lineEnd) + layout.getLineDescent(lineEnd); + int cursorWidth = 0; + + Drawable cursorDrawable = null; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + cursorDrawable = mReactEditText.getTextCursorDrawable(); + if (cursorDrawable != null) { + cursorWidth = cursorDrawable.getIntrinsicWidth(); + } + } + + cursorPositionEndX = (int) Math.round(PixelUtil.toDIPFromPixel(right + cursorWidth)); + cursorPositionEndY = (int) Math.round(PixelUtil.toDIPFromPixel(bottom)); if (mPreviousSelectionStart != realStart || mPreviousSelectionEnd != realEnd) { mEventDispatcher.dispatchEvent( - new ReactTextInputSelectionEvent( - mSurfaceId, mReactEditText.getId(), realStart, realEnd)); + new ReactTextInputSelectionEvent( + mSurfaceId, + mReactEditText.getId(), + realStart, + realEnd, + cursorPositionStartX, + cursorPositionStartY, + cursorPositionEndX, + cursorPositionEndY + ) + ); mPreviousSelectionStart = realStart; mPreviousSelectionEnd = realEnd; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputSelectionEvent.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputSelectionEvent.java index ede96cd369f65e..09af37b935c8b4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputSelectionEvent.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputSelectionEvent.java @@ -20,17 +20,39 @@ private int mSelectionStart; private int mSelectionEnd; + private int mCursorPositionStartX; + private int mCursorPositionStartY; + private int mCursorPositionEndX; + private int mCursorPositionEndY; @Deprecated - public ReactTextInputSelectionEvent(int viewId, int selectionStart, int selectionEnd) { - this(ViewUtil.NO_SURFACE_ID, viewId, selectionStart, selectionEnd); + public ReactTextInputSelectionEvent( + int viewId, + int selectionStart, + int selectionEnd, + int cursorPositionStartX, + int cursorPositionStartY, + int cursorPositionEndX, + int cursorPositionEndY) { + this(-1, viewId, selectionStart, selectionEnd, cursorPositionStartX, cursorPositionStartY, cursorPositionEndX, cursorPositionEndY); } public ReactTextInputSelectionEvent( - int surfaceId, int viewId, int selectionStart, int selectionEnd) { + int surfaceId, + int viewId, + int selectionStart, + int selectionEnd, + int cursorPositionStartX, + int cursorPositionStartY, + int cursorPositionEndX, + int cursorPositionEndY) { super(surfaceId, viewId); mSelectionStart = selectionStart; mSelectionEnd = selectionEnd; + mCursorPositionStartX = cursorPositionStartX; + mCursorPositionStartY = cursorPositionStartY; + mCursorPositionEndX = cursorPositionEndX; + mCursorPositionEndY = cursorPositionEndY; } @Override @@ -42,10 +64,23 @@ public String getEventName() { @Override protected WritableMap getEventData() { WritableMap eventData = Arguments.createMap(); - WritableMap selectionData = Arguments.createMap(); + + WritableMap startPosition = Arguments.createMap(); + startPosition.putInt("x", mCursorPositionStartX); + startPosition.putInt("y", mCursorPositionStartY); + + WritableMap endPosition = Arguments.createMap(); + endPosition.putInt("x", mCursorPositionEndX); + endPosition.putInt("y", mCursorPositionEndY); + + WritableMap selectionPosition = Arguments.createMap(); + selectionPosition.putMap("start", startPosition); + selectionPosition.putMap("end", endPosition); + selectionData.putInt("end", mSelectionEnd); selectionData.putInt("start", mSelectionStart); + selectionData.putMap("cursorPosition", selectionPosition); eventData.putMap("selection", selectionData); return eventData; diff --git a/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js b/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js index c05948b0639800..6d85fcfbc828d7 100644 --- a/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js +++ b/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js @@ -478,6 +478,16 @@ type SelectionExampleState = { selection: $ReadOnly<{| start: number, end: number, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: number, + y: number, + |}>, + end: $ReadOnly<{| + x: number, + y: number, + |}>, + |}>, |}>, value: string, ... @@ -494,7 +504,14 @@ class SelectionExample extends React.Component< constructor(props) { super(props); this.state = { - selection: {start: 0, end: 0}, + selection: { + start: 0, + end: 0, + cursorPosition: { + start: {x: 0, y: 0}, + end: {x: 0, y: 0}, + }, + }, value: props.value, }; } @@ -512,7 +529,13 @@ class SelectionExample extends React.Component< select(start: number, end: number) { this._textInput?.focus(); - this.setState({selection: {start, end}}); + this.setState({ + selection: { + start, + end, + cursorPosition: {...this.state.selection.cursorPosition}, + }, + }); if (this.props.imperative) { this._textInput?.setSelection(start, end); } @@ -555,7 +578,18 @@ class SelectionExample extends React.Component< selection ={' '} - {`{start:${this.state.selection.start},end:${this.state.selection.end}}`} + {`{ + start:${this.state.selection.start}, end:${this.state.selection.end}, + cursorPosition: { + start: { + x: ${this.state.selection.cursorPosition.start.x}, + y: ${this.state.selection.cursorPosition.start.y} + }, + end: { + x: ${this.state.selection.cursorPosition.end.x}, + y: ${this.state.selection.cursorPosition.end.y} + }, + }`}