diff --git a/android/src/main/java/com/appandflow/transformertextinput/TransformerTextInputDecoratorView.kt b/android/src/main/java/com/appandflow/transformertextinput/TransformerTextInputDecoratorView.kt index ee3f266..090314d 100644 --- a/android/src/main/java/com/appandflow/transformertextinput/TransformerTextInputDecoratorView.kt +++ b/android/src/main/java/com/appandflow/transformertextinput/TransformerTextInputDecoratorView.kt @@ -2,6 +2,10 @@ package com.appandflow.transformertextinput import android.content.Context import android.text.Editable +import android.text.Selection +import android.text.SpanWatcher +import android.text.Spannable +import android.text.Spanned import android.text.TextWatcher import com.facebook.react.views.textinput.ReactEditText import com.facebook.react.views.view.ReactViewGroup @@ -19,6 +23,47 @@ class TransformerTextInputDecoratorView( private var resetLastEventValueJob: Job? = null private var reactEditText: ReactEditText? = null private var isUpdating = false + private var selectionSyncPosted = false + + // ReactEditText does not expose a listener for selection-only changes. Its + // selection is represented by spans, so observe those markers directly. + private val selectionWatcher = + object : SpanWatcher { + override fun onSpanAdded( + text: Spannable?, + what: Any?, + start: Int, + end: Int, + ) { + if (isSelectionMarker(what)) { + scheduleSelectionHistorySync() + } + } + + override fun onSpanRemoved( + text: Spannable?, + what: Any?, + start: Int, + end: Int, + ) { + if (isSelectionMarker(what)) { + scheduleSelectionHistorySync() + } + } + + override fun onSpanChanged( + text: Spannable?, + what: Any?, + oldStart: Int, + oldEnd: Int, + newStart: Int, + newEnd: Int, + ) { + if (isSelectionMarker(what)) { + scheduleSelectionHistorySync() + } + } + } private fun currentValue(): String = reactEditText?.text?.toString() ?: "" @@ -50,19 +95,55 @@ class TransformerTextInputDecoratorView( transform, ) ?: state + private fun isSelectionMarker(span: Any?): Boolean = span === Selection.SELECTION_START || span === Selection.SELECTION_END + + private fun attachSelectionWatcher(editable: Editable?) { + if (editable == null || editable.getSpanStart(selectionWatcher) >= 0) { + return + } + editable.setSpan( + selectionWatcher, + 0, + editable.length, + Spanned.SPAN_INCLUSIVE_INCLUSIVE, + ) + } + + private fun scheduleSelectionHistorySync() { + if (selectionSyncPosted) { + return + } + selectionSyncPosted = true + reactEditText?.post { + selectionSyncPosted = false + if (!isUpdating) { + syncTransformerHistory() + } + } + } + + private fun syncTransformerHistory() { + if (reactEditText == null || transformerId == 0) { + return + } + val current = TextState(currentValue(), currentSelection()) + transformTextState(current, false) + } + fun setTransformerId(newTransformerId: Int) { val previousTransformerId = transformerId transformerId = newTransformerId lastEventValue = null // When the transformer is swapped after mount, re-run it on the current - // text so the displayed value reformats immediately. The initial prop - // set is excluded by checking that the previous id was non-zero - // (default) and that the backing edit text is attached. + // text so the displayed value reformats immediately. On the initial prop + // set, seed the transformer's previous value and selection instead. if (previousTransformerId != 0 && previousTransformerId != newTransformerId && reactEditText != null ) { reapplyTransformer() + } else if (previousTransformerId == 0 && reactEditText != null) { + scheduleSelectionHistorySync() } } @@ -91,13 +172,17 @@ class TransformerTextInputDecoratorView( val child = getChildAt(0) if (child is ReactEditText) { reactEditText = child - reactEditText?.addTextChangedListener(this) + child.addTextChangedListener(this) + attachSelectionWatcher(child.text) + child.post { syncTransformerHistory() } } } override fun onDetachedFromWindow() { super.onDetachedFromWindow() + reactEditText?.text?.removeSpan(selectionWatcher) reactEditText?.removeTextChangedListener(this) + selectionSyncPosted = false reactEditText = null } @@ -120,6 +205,7 @@ class TransformerTextInputDecoratorView( } override fun afterTextChanged(s: Editable?) { + attachSelectionWatcher(s) if (isUpdating) { return } diff --git a/ios/TransformerTextInputDecoratorView.mm b/ios/TransformerTextInputDecoratorView.mm index 8f0e210..7a363e9 100644 --- a/ios/TransformerTextInputDecoratorView.mm +++ b/ios/TransformerTextInputDecoratorView.mm @@ -28,6 +28,7 @@ @implementation TransformerTextInputDecoratorView { bool _observersAdded; __weak id _baseDelegate; __weak UIView *_backedTextInput; + NSString *_lastKnownValue; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -58,11 +59,14 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & [super updateProps:props oldProps:oldProps]; // When the transformer is swapped after mount, re-run it on the current - // text so the displayed value reformats immediately. The initial prop set - // is excluded by checking that the previous id was non-zero (default) and - // that the backing text input is attached. - if (transformerIdChanged && oldViewProps.transformerId != 0 && _backedTextInput != nil) { - [self reapplyTransformer]; + // text so the displayed value reformats immediately. On the initial prop + // set, seed the transformer's previous value and selection instead. + if (transformerIdChanged && _backedTextInput != nil) { + if (oldViewProps.transformerId != 0) { + [self reapplyTransformer]; + } else { + [self syncTransformerHistory]; + } } } @@ -80,11 +84,24 @@ - (void)reapplyTransformer [self applyValue:next.value]; } [self applySelection:next.selection]; + _lastKnownValue = next.value; if (didTransformValue) { [_baseDelegate textInputDidChange]; } } +- (void)syncTransformerHistory +{ + if (!_backedTextInput || !_transformer) { + return; + } + NSString *currentValue = [self currentValue]; + NSRange currentSelection = [self currentSelection]; + RNTTITextState current{currentValue, currentSelection}; + [self transformTextState:current transform:NO]; + _lastKnownValue = currentValue; +} + - (void)applyValue:(NSString *)newValue { NSMutableAttributedString *newAttributedText = @@ -186,6 +203,7 @@ - (void)addTextInputObservers backedTextInputView.textInputDelegate = self; _observersAdded = true; + [self syncTransformerHistory]; } - (void)removeTextInputObservers @@ -197,6 +215,7 @@ - (void)removeTextInputObservers _baseDelegate = nil; _observersAdded = false; _transformer = std::nullopt; + _lastKnownValue = nil; } - (void)textInputDidBeginEditing @@ -218,12 +237,20 @@ - (void)textInputDidChange if (didTransformValue || !NSEqualRanges(next.selection, current.selection)) { [self applySelection:next.selection]; } + _lastKnownValue = next.value; [_baseDelegate textInputDidChange]; } - (void)textInputDidChangeSelection { + NSString *currentValue = [self currentValue]; + // Selection callbacks can arrive before text-change callbacks for an edit. + // Only sync when the text itself is unchanged so the edit still receives + // the state from before the user typed. + if (_lastKnownValue == nil || [currentValue isEqualToString:_lastKnownValue]) { + [self syncTransformerHistory]; + } [_baseDelegate textInputDidChangeSelection]; } @@ -289,6 +316,7 @@ - (void)update:(BOOL)transform if (didTransformValue || !NSEqualRanges(next.selection, currentSelection)) { [self applySelection:next.selection]; } + _lastKnownValue = next.value; [_baseDelegate textInputDidChange]; } diff --git a/src/TransformerTextInput.tsx b/src/TransformerTextInput.tsx index 4a31d11..429ab6b 100644 --- a/src/TransformerTextInput.tsx +++ b/src/TransformerTextInput.tsx @@ -66,7 +66,7 @@ export const TransformerTextInput = forwardRef( useRef< ElementRef >(null); - const textRef = useRef(''); + const textRef = useRef(transformedDefaultValue ?? ''); const setInputRef = useCallback((instance: HostInstance | null) => { if (instance != null) { diff --git a/src/TransformerTextInput.web.tsx b/src/TransformerTextInput.web.tsx index 330cbc9..607b653 100644 --- a/src/TransformerTextInput.web.tsx +++ b/src/TransformerTextInput.web.tsx @@ -35,6 +35,7 @@ export const TransformerTextInput = forwardRef( { transformer, onChangeText, + onSelectionChange, defaultValue, ...others }: TransformerTextInputProps, @@ -124,6 +125,24 @@ export const TransformerTextInput = forwardRef( [applyTransform, onChangeText], ); + const handleSelectionChange = useCallback( + ( + event: Parameters< + NonNullable + >[0], + ) => { + const node = nodeRef.current; + if (node == null || node.value === valueRef.current) { + previousRef.current = { + value: valueRef.current, + selection: event.nativeEvent.selection, + }; + } + onSelectionChange?.(event); + }, + [onSelectionChange], + ); + const setInputRef = useCallback( (instance: TransformerTextInputInstance | null) => { nodeRef.current = instance as unknown as WebInputNode | null; @@ -154,6 +173,7 @@ export const TransformerTextInput = forwardRef( ref={inputRef} defaultValue={transformedDefaultValue} onChangeText={handleChangeText} + onSelectionChange={handleSelectionChange} {...others} /> ); diff --git a/src/__tests__/TransformerTextInput.test.tsx b/src/__tests__/TransformerTextInput.test.tsx index 04364bd..743a20c 100644 --- a/src/__tests__/TransformerTextInput.test.tsx +++ b/src/__tests__/TransformerTextInput.test.tsx @@ -71,6 +71,24 @@ describe('TransformerTextInput', () => { expect(onChangeText).toHaveBeenCalledWith('hello'); }); + it('initializes ref value from the transformed default value', () => { + const transformer = new Transformer(({ value }) => { + 'worklet'; + return { value: value.toUpperCase() }; + }); + const ref = React.createRef(); + + render( + , + ); + + expect(ref.current?.getValue()).toBe('HELLO'); + }); + it('registers and unregisters transformers via registry', () => { const transformer = new Transformer(({ value }) => { 'worklet'; diff --git a/src/__tests__/TransformerTextInput.web.test.tsx b/src/__tests__/TransformerTextInput.web.test.tsx new file mode 100644 index 0000000..6c4dbbf --- /dev/null +++ b/src/__tests__/TransformerTextInput.web.test.tsx @@ -0,0 +1,65 @@ +import { act, render } from '@testing-library/react-native'; +import React from 'react'; +import { TextInput } from 'react-native'; +import { Transformer } from '../Transformer'; +import { + TransformerTextInput, + type TransformerTextInputInstance, +} from '../TransformerTextInput.web'; + +jest.mock('react-native-worklets'); +jest.mock('../NativeTransformerTextInputModule'); + +describe('TransformerTextInput web', () => { + it('uses a cursor-only movement as the previous selection', () => { + const calls: Array<{ + previousSelection: { start: number; end: number }; + }> = []; + const transformer = new Transformer((input) => { + 'worklet'; + calls.push({ previousSelection: input.previousSelection }); + return { value: input.value, selection: input.selection }; + }); + const node = { + value: '12.34', + selectionStart: 2, + selectionEnd: 2, + setSelectionRange: jest.fn(), + }; + const inputRef = React.createRef(); + const onSelectionChange = jest.fn(); + + const { UNSAFE_getByType } = render( + , + { createNodeMock: () => node }, + ); + const textInput = UNSAFE_getByType(TextInput); + const input = inputRef.current; + if (input == null) { + throw new Error('Expected TextInput ref to be set'); + } + Object.assign(input, node); + + act(() => { + textInput.props.onSelectionChange({ + nativeEvent: { selection: { start: 2, end: 2 } }, + }); + }); + Object.assign(input, { + value: '125.34', + selectionStart: 3, + selectionEnd: 3, + }); + act(() => { + textInput.props.onChangeText('125.34'); + }); + + expect(calls.at(-1)?.previousSelection).toEqual({ start: 2, end: 2 }); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index ab23e40..bd79113 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -97,4 +97,26 @@ describe('registry', () => { expect(result?.value).toEqual('abc'); expect(result?.selection).toEqual({ start: 1, end: 2 }); }); + + it('updates previous state when transform is false', () => { + let receivedPreviousValue = ''; + let receivedPreviousSelection = { start: 0, end: 0 }; + const transformer = new Transformer( + ({ value, previousValue, selection, previousSelection }) => { + 'worklet'; + receivedPreviousValue = previousValue; + receivedPreviousSelection = previousSelection; + return { value, selection }; + }, + ); + + const id = registerTransformer(transformer); + const wrapper = globalThis.__rntti_registerTransformerRegistry?.get(id); + + wrapper?.('12.34', 2, 2, false); + wrapper?.('125.34', 3, 3, true); + + expect(receivedPreviousValue).toBe('12.34'); + expect(receivedPreviousSelection).toEqual({ start: 2, end: 2 }); + }); });