Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() ?: ""

Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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
}

Expand All @@ -120,6 +205,7 @@ class TransformerTextInputDecoratorView(
}

override fun afterTextChanged(s: Editable?) {
attachSelectionWatcher(s)
if (isUpdating) {
return
}
Expand Down
38 changes: 33 additions & 5 deletions ios/TransformerTextInputDecoratorView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ @implementation TransformerTextInputDecoratorView {
bool _observersAdded;
__weak id<RCTBackedTextInputDelegate> _baseDelegate;
__weak UIView<RCTBackedTextInputViewProtocol> *_backedTextInput;
NSString *_lastKnownValue;
}

+ (ComponentDescriptorProvider)componentDescriptorProvider
Expand Down Expand Up @@ -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];
}
}
}

Expand All @@ -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 =
Expand Down Expand Up @@ -186,6 +203,7 @@ - (void)addTextInputObservers
backedTextInputView.textInputDelegate = self;

_observersAdded = true;
[self syncTransformerHistory];
}

- (void)removeTextInputObservers
Expand All @@ -197,6 +215,7 @@ - (void)removeTextInputObservers
_baseDelegate = nil;
_observersAdded = false;
_transformer = std::nullopt;
_lastKnownValue = nil;
}

- (void)textInputDidBeginEditing
Expand All @@ -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];
}

Expand Down Expand Up @@ -289,6 +316,7 @@ - (void)update:(BOOL)transform
if (didTransformValue || !NSEqualRanges(next.selection, currentSelection)) {
[self applySelection:next.selection];
}
_lastKnownValue = next.value;

[_baseDelegate textInputDidChange];
}
Expand Down
2 changes: 1 addition & 1 deletion src/TransformerTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const TransformerTextInput = forwardRef(
useRef<
ElementRef<typeof TransformerTextInputDecoratorViewNativeComponent>
>(null);
const textRef = useRef('');
const textRef = useRef(transformedDefaultValue ?? '');

const setInputRef = useCallback((instance: HostInstance | null) => {
if (instance != null) {
Expand Down
20 changes: 20 additions & 0 deletions src/TransformerTextInput.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const TransformerTextInput = forwardRef(
{
transformer,
onChangeText,
onSelectionChange,
defaultValue,
...others
}: TransformerTextInputProps,
Expand Down Expand Up @@ -124,6 +125,24 @@ export const TransformerTextInput = forwardRef(
[applyTransform, onChangeText],
);

const handleSelectionChange = useCallback(
(
event: Parameters<
NonNullable<TransformerTextInputProps['onSelectionChange']>
>[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;
Expand Down Expand Up @@ -154,6 +173,7 @@ export const TransformerTextInput = forwardRef(
ref={inputRef}
defaultValue={transformedDefaultValue}
onChangeText={handleChangeText}
onSelectionChange={handleSelectionChange}
{...others}
/>
);
Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/TransformerTextInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TransformerTextInputInstance>();

render(
<TransformerTextInput
ref={ref}
transformer={transformer}
defaultValue="hello"
/>,
);

expect(ref.current?.getValue()).toBe('HELLO');
});

it('registers and unregisters transformers via registry', () => {
const transformer = new Transformer(({ value }) => {
'worklet';
Expand Down
65 changes: 65 additions & 0 deletions src/__tests__/TransformerTextInput.web.test.tsx
Original file line number Diff line number Diff line change
@@ -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<TransformerTextInputInstance>();
const onSelectionChange = jest.fn();

const { UNSAFE_getByType } = render(
<TransformerTextInput
ref={inputRef}
transformer={transformer}
defaultValue="12.34"
onSelectionChange={onSelectionChange}
/>,
{ 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);
});
});
Loading
Loading