diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index 34dfa8f1c5f..483adb736b1 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -79,6 +79,30 @@ @implementation RCTTextInputComponentView { */ NSDictionary *_originalTypingAttributes; + /* + * While JS's text is written into the field, UIKit can deliver the keys queued behind `-setAttributedText:` inside + * the write, and the write then replaces them. `_textBeforeUpdateFromJS` is the field's text before the write (nil + * outside one). A key that goes through -textInputShouldChangeText:inRange: during the write sets + * `_keyTypedDuringUpdateFromJS`, and the change UIKit reports next is recorded in `_textTypedDuringUpdateFromJS` + * (and the selection it left in `_selectionTypedDuringUpdateFromJS`). + * Other changes reported during the write are the write's own (a text view reports it). A key that leaves marked + * text (IME composition) sets `_markedTextDuringUpdateFromJS`: composition is not put back as plain text. + * See -_setAttributedStringFromJS: and -_restoreTextTypedDuringUpdateFromJS:writtenText:. + */ + NSString *_textBeforeUpdateFromJS; + NSString *_textTypedDuringUpdateFromJS; + AttributedString::Range _selectionTypedDuringUpdateFromJS; + BOOL _keyTypedDuringUpdateFromJS; + BOOL _markedTextDuringUpdateFromJS; + /* + * While -_restoreTextTypedDuringUpdateFromJS:writtenText: puts keys back, the range its edit replaces and the length + * it adds (both 0 outside it). UIKit can deliver more queued keys inside that edit, before it applies: their + * `maxLength` check counts that length, and a key that edits text before the end of that range is dropped (see + * -textInputShouldChangeText:inRange:). + */ + NSRange _rangeBeingRestored; + NSInteger _lengthBeingRestored; + BOOL _hasInputAccessoryView; CGSize _previousContentSize; } @@ -361,10 +385,127 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & } if (_mostRecentEventCount == _state->getData().mostRecentEventCount) { - _comingFromJS = YES; - [self _setAttributedString:RCTNSAttributedStringFromAttributedStringBox(data.attributedStringBox)]; - _comingFromJS = NO; + [self _setAttributedStringFromJS:RCTNSAttributedStringFromAttributedStringBox(data.attributedStringBox)]; + } +} + +// Writes JS's text into the field and puts back the keys UIKit delivered during the write. +- (void)_setAttributedStringFromJS:(NSAttributedString *)attributedString +{ + NSString *textBeforeUpdate = [self _beginUpdateFromJS]; + [self _setAttributedString:attributedString]; + _comingFromJS = NO; + [self _restoreTextTypedDuringUpdateFromJS:textBeforeUpdate writtenText:attributedString.string]; +} + +// Starts a write from JS (`_comingFromJS`): returns the field's text before it. +- (NSString *)_beginUpdateFromJS +{ + _comingFromJS = YES; + _textBeforeUpdateFromJS = [_backedTextInputView.attributedText.string copy]; + _textTypedDuringUpdateFromJS = nil; + _selectionTypedDuringUpdateFromJS = {}; + _keyTypedDuringUpdateFromJS = NO; + _markedTextDuringUpdateFromJS = NO; + return _textBeforeUpdateFromJS; +} + +- (void)_setSelectedRange:(AttributedString::Range)range +{ + UITextPosition *start = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument + offset:range.location]; + UITextPosition *end = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument + offset:range.location + range.length]; + if (start != nil && end != nil) { + [_backedTextInputView setSelectedTextRange:[_backedTextInputView textRangeFromPosition:start toPosition:end] + notifyDelegate:NO]; + } +} + +- (void)_endUpdateFromJS +{ + _textBeforeUpdateFromJS = nil; + _textTypedDuringUpdateFromJS = nil; + _selectionTypedDuringUpdateFromJS = {}; + _keyTypedDuringUpdateFromJS = NO; + _markedTextDuringUpdateFromJS = NO; +} + +// Applies the edit the user made during the write from JS (`textBeforeUpdate` -> the text the keys left, see +// RCTTextInputTextToRestore) to the text JS wrote, within `maxLength`, and reports it as typing (-textInputDidChange, +// then -textInputDidChangeSelection), so JS receives those keys and can still change or reject them. The selection is +// mapped through the edit: JS's selection (setTextAndSelection) or the caret the write restored. Nothing is put back +// while the user composes text (IME marked text): composition is not committed as plain text. +- (void)_restoreTextTypedDuringUpdateFromJS:(NSString *)textBeforeUpdate writtenText:(NSString *)writtenText +{ + NSString *typedText = _textTypedDuringUpdateFromJS; + AttributedString::Range typedSelection = _selectionTypedDuringUpdateFromJS; + BOOL markedText = _markedTextDuringUpdateFromJS || _backedTextInputView.markedTextRange != nil; + [self _endUpdateFromJS]; + if (typedText == nil || markedText) { + return; + } + if (![_backedTextInputView.attributedText.string isEqualToString:writtenText]) { + // The keys arrived after the write had replaced the text (a text view can deliver them there), so the field + // still has them: only report them. The write then put the caret back where it was before the keys: put it + // after them again. + if ([_backedTextInputView.attributedText.string isEqualToString:typedText]) { + [self _setSelectedRange:typedSelection]; + } + [self textInputDidChange]; + [self textInputDidChangeSelection]; + return; + } + NSRange replaced; + NSString *inserted; + if (!RCTTextInputTextToRestore(textBeforeUpdate, typedText, writtenText, &replaced, &inserted)) { + return; } + const auto &props = static_cast(*_props); + if (inserted.length > 0 && props.maxLength < std::numeric_limits::max()) { + // UIKit checked these keys against the text before the write. + inserted = RCTTextInputTextWithinLength( + inserted, (NSInteger)props.maxLength - (NSInteger)writtenText.length + (NSInteger)replaced.length); + if (inserted == nil) { + return; + } + } + + AttributedString::Range selection = [self _selectionRange]; + UITextPosition *replacedStart = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument + offset:replaced.location]; + UITextPosition *replacedEnd = [_backedTextInputView positionFromPosition:replacedStart offset:replaced.length]; + UITextRange *replacedRange = [_backedTextInputView textRangeFromPosition:replacedStart toPosition:replacedEnd]; + if (replacedRange == nil) { + return; + } + // Put the keys back as UIKit inserts typed text: as an edit the user can undo, with the attributes of JS's text + // before them (the typing attributes, set after the caret moved). UIKit can deliver more queued keys inside this + // edit, before it applies: the caret is put after `replacedRange`, so they land after it, stay in the field (only + // `replacedRange` is replaced) and are reported with it. + [_backedTextInputView setSelectedTextRange:[_backedTextInputView textRangeFromPosition:replacedEnd + toPosition:replacedEnd] + notifyDelegate:NO]; + NSAttributedString *textBeforeRestore = _backedTextInputView.attributedText; + [self _updateTypingAttributesSampledAt:replaced.location]; + NSInteger lengthAdded = (NSInteger)inserted.length - (NSInteger)replaced.length; + _comingFromJS = YES; + _rangeBeingRestored = replaced; + _lengthBeingRestored = lengthAdded; + [_backedTextInputView replaceRange:replacedRange withText:inserted]; + _rangeBeingRestored = NSMakeRange(0, 0); + _lengthBeingRestored = 0; + // The keys UIKit delivered inside the edit follow the restored ones: the selection moves past them too. + NSInteger delivered = + (NSInteger)_backedTextInputView.attributedText.length - (NSInteger)textBeforeRestore.length - lengthAdded; + NSUInteger insertedLength = inserted.length + (NSUInteger)MAX(delivered, (NSInteger)0); + NSInteger start = RCTTextInputOffsetAfterEdit(selection.location, replaced, insertedLength); + NSInteger end = RCTTextInputOffsetAfterEdit(selection.location + selection.length, replaced, insertedLength); + [self _setSelectedRange:{.location = (int)start, .length = (int)(end - start)}]; + [self _updateTypingAttributes]; + _comingFromJS = NO; + [self textInputDidChange]; + [self textInputDidChangeSelection]; } - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics @@ -466,26 +607,24 @@ - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range } } + NSString *allowedText = text; if (props.maxLength < std::numeric_limits::max()) { - NSInteger allowedLength = props.maxLength - _backedTextInputView.attributedText.string.length + range.length; - - if (allowedLength > 0 && text.length > allowedLength) { - // make sure unicode characters that are longer than 16 bits (such as emojis) are not cut off - NSRange cutOffCharacterRange = [text rangeOfComposedCharacterSequenceAtIndex:allowedLength - 1]; - if (cutOffCharacterRange.location + cutOffCharacterRange.length > allowedLength) { - // the character at the length limit takes more than 16bits, truncation should end at the character before - allowedLength = cutOffCharacterRange.location; - } - } - - if (allowedLength <= 0) { - return nil; - } - - return allowedLength > text.length ? text : [text substringToIndex:allowedLength]; + allowedText = RCTTextInputTextWithinLength( + text, + (NSInteger)props.maxLength - (NSInteger)_backedTextInputView.attributedText.string.length - + _lengthBeingRestored + (NSInteger)range.length); } - return text; + if (range.location < NSMaxRange(_rangeBeingRestored)) { + // UIKit delivers this key inside the edit that puts keys back, before it applies, and it edits text before the + // end of the range that edit replaces (a backspace): the edit would then replace other text. The key is dropped. + return nil; + } + if (_textBeforeUpdateFromJS != nil) { + // UIKit delivers this key during a write from JS: see -textInputDidChange. + _keyTypedDuringUpdateFromJS = YES; + } + return allowedText; } - (BOOL)textInputShouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text @@ -496,6 +635,14 @@ - (BOOL)textInputShouldChangeTextInRange:(NSRange)range replacementText:(NSStrin - (void)textInputDidChange { if (_comingFromJS) { + // The change a key made during a write from JS (see `_textBeforeUpdateFromJS`). Other changes reported during the + // write are the write's own: a text view reports the text JS wrote. + if (_keyTypedDuringUpdateFromJS) { + _keyTypedDuringUpdateFromJS = NO; + _textTypedDuringUpdateFromJS = [_backedTextInputView.attributedText.string copy]; + _selectionTypedDuringUpdateFromJS = [self _selectionRange]; + _markedTextDuringUpdateFromJS = _markedTextDuringUpdateFromJS || _backedTextInputView.markedTextRange != nil; + } return; } @@ -595,12 +742,14 @@ - (void)setTextAndSelection:(NSInteger)eventCount if (_mostRecentEventCount != eventCount) { return; } - _comingFromJS = YES; + NSString *textBeforeUpdate = [self _beginUpdateFromJS]; + NSString *writtenText = nil; if (value && ![value isEqualToString:_backedTextInputView.attributedText.string]) { NSAttributedString *attributedString = [[NSAttributedString alloc] initWithString:value attributes:_backedTextInputView.defaultTextAttributes]; [self _setAttributedString:attributedString]; [self _updateState]; + writtenText = attributedString.string; } UITextPosition *startPosition = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument @@ -617,6 +766,13 @@ - (void)setTextAndSelection:(NSInteger)eventCount [_backedTextInputView scrollRangeToVisible:NSMakeRange(offsetEnd, 0)]; } _comingFromJS = NO; + // A controlled `value` that JS changed (e.g. a transform in onChangeText) is written here: put back the keys UIKit + // delivered during the write, keeping JS's selection. + if (writtenText != nil) { + [self _restoreTextTypedDuringUpdateFromJS:textBeforeUpdate writtenText:writtenText]; + } else { + [self _endUpdateFromJS]; + } } #pragma mark - Default input accessory view @@ -826,8 +982,16 @@ - (void)_updateTypingAttributes if (_backedTextInputView.attributedText.length > 0 && _backedTextInputView.selectedTextRange != nil) { NSUInteger offsetStart = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument toPosition:_backedTextInputView.selectedTextRange.start]; + [self _updateTypingAttributesSampledAt:offsetStart]; + } +} - NSUInteger samplePoint = offsetStart == 0 ? 0 : offsetStart - 1; +// The typing attributes for text inserted at `offset`: the attributes of the character before it, or after it at the +// start of the field. Used for the caret (-_updateTypingAttributes) and for keys put back after a write from JS. +- (void)_updateTypingAttributesSampledAt:(NSUInteger)offset +{ + if (_backedTextInputView.attributedText.length > 0) { + NSUInteger samplePoint = offset == 0 ? 0 : offset - 1; _backedTextInputView.typingAttributes = [_backedTextInputView.attributedText attributesAtIndex:samplePoint effectiveRange:NULL]; } @@ -900,8 +1064,21 @@ - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldTe if (shouldFallbackToBareTextComparison) { return [newText.string isEqualToString:oldText.string]; } else { + // Typed text carries attributes a state built from props (a controlled `value`) does not: the input's + // `defaultTextAttributes` (the first character typed into an empty field also gets their background color and + // event emitter) and a paragraph style, which is UIKit's default one or the one in its initial typing attributes + // (another line break strategy). Both are accepted. + const auto &props = static_cast(*_props); + NSMutableDictionary *defaultAttributes = + [_originalTypingAttributes mutableCopy] ?: [NSMutableDictionary new]; + [defaultAttributes addEntriesFromDictionary:_backedTextInputView.defaultTextAttributes]; + defaultAttributes[NSParagraphStyleAttributeName] = + _backedTextInputView.defaultTextAttributes[NSParagraphStyleAttributeName] + ?: NSParagraphStyle.defaultParagraphStyle; return RCTIsAttributedStringEffectivelySame( - newText, oldText, _originalTypingAttributes, static_cast(*_props).textAttributes); + newText, oldText, defaultAttributes, props.textAttributes, !props.multiline) || + RCTIsAttributedStringEffectivelySame( + newText, oldText, _originalTypingAttributes, props.textAttributes, !props.multiline); } } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.h index 90a2816bf2a..0ad5d29c6ff 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.h @@ -41,6 +41,42 @@ UITextInputPasswordRules *RCTUITextInputPasswordRulesFromString(const std::strin UITextSmartInsertDeleteType RCTUITextSmartInsertDeleteTypeFromOptionalBool(std::optional smartInsertDelete); +/* + * The range of `oldText` that an edit replaced to give `newText`: what lies between their common prefix and their + * common suffix, widened so it does not split a composed character sequence (an emoji, a flag, a letter with + * combining marks). The inserted text is `newText` over the same location, `newText.length - oldText.length + length` + * long. + */ +NSRange RCTTextInputChangedRange(NSString *oldText, NSString *newText); + +/* + * The user typed while JS's text was being written into a focused text input: UIKit delivers keys queued behind + * `-setAttributedText:` inside it, and the write replaces them. Given the input's text before the write + * (`textBeforeUpdate`), what it held with the keys typed during the write (`typedText`), and the text JS wrote + * (`writtenText`), sets the range of `writtenText` to replace and the text to put there, and returns YES; returns NO + * if there is nothing to put back. If JS changed only attributes, the user's edit applies where it was made. If JS + * changed the characters (e.g. upper-casing), only an insertion is put back, at the same distance from the end. + */ +BOOL RCTTextInputTextToRestore( + NSString *textBeforeUpdate, + NSString *typedText, + NSString *writtenText, + NSRange *range, + NSString *_Nullable *_Nonnull text); + +/* + * `text` cut to `allowedLength` (`maxLength`) without splitting a composed character sequence, or nil if no + * character fits. + */ +NSString *_Nullable RCTTextInputTextWithinLength(NSString *text, NSInteger allowedLength); + +/* + * Where `offset` in a text lies after `range` of it was replaced by `insertedLength` characters: an offset at or after + * the end of the range moves with the text after it, an offset inside the range or at its start moves to the end of + * the insertion (the caret of a user who typed over the range). + */ +NSInteger RCTTextInputOffsetAfterEdit(NSInteger offset, NSRange range, NSUInteger insertedLength); + #if !TARGET_OS_TV UIDataDetectorTypes RCTUITextViewDataDetectorTypesFromStringVector(const std::vector &dataDetectorTypes); #endif diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm index e10e01d2043..f6b0e257601 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm @@ -273,6 +273,111 @@ UITextSmartInsertDeleteType RCTUITextSmartInsertDeleteTypeFromOptionalBool(std:: : UITextSmartInsertDeleteTypeDefault; } +// The end of the composed character sequence (an emoji, a letter with combining marks) that `index` falls in. +static NSUInteger RCTComposedSequenceBoundaryAtOrAfter(NSString *text, NSUInteger index) +{ + if (index == 0 || index >= text.length) { + return index; + } + NSRange sequence = [text rangeOfComposedCharacterSequenceAtIndex:index]; + return sequence.location < index ? NSMaxRange(sequence) : index; +} + +NSRange RCTTextInputChangedRange(NSString *oldText, NSString *newText) +{ + // Read the characters through inline buffers rather than copying both strings. + CFStringInlineBuffer oldBuffer; + CFStringInlineBuffer newBuffer; + CFStringInitInlineBuffer((__bridge CFStringRef)oldText, &oldBuffer, CFRangeMake(0, oldText.length)); + CFStringInitInlineBuffer((__bridge CFStringRef)newText, &newBuffer, CFRangeMake(0, newText.length)); + NSUInteger oldLength = oldText.length; + NSUInteger newLength = newText.length; + NSUInteger shorter = MIN(oldLength, newLength); + NSUInteger prefix = 0; + while (prefix < shorter && + CFStringGetCharacterFromInlineBuffer(&oldBuffer, prefix) == + CFStringGetCharacterFromInlineBuffer(&newBuffer, prefix)) { + prefix++; + } + NSUInteger suffix = 0; + while (suffix < shorter - prefix && + CFStringGetCharacterFromInlineBuffer(&oldBuffer, oldLength - 1 - suffix) == + CFStringGetCharacterFromInlineBuffer(&newBuffer, newLength - 1 - suffix)) { + suffix++; + } + // Don't split a composed character sequence: the edit starts and ends on sequence boundaries of both texts. + if (prefix < oldLength) { + prefix = MIN(prefix, [oldText rangeOfComposedCharacterSequenceAtIndex:prefix].location); + } + if (prefix < newLength) { + prefix = MIN(prefix, [newText rangeOfComposedCharacterSequenceAtIndex:prefix].location); + } + suffix = MIN(suffix, shorter - prefix); + NSUInteger oldEnd = RCTComposedSequenceBoundaryAtOrAfter(oldText, oldLength - suffix); + NSUInteger newEnd = RCTComposedSequenceBoundaryAtOrAfter(newText, newLength - suffix); + suffix = MIN(oldLength - oldEnd, newLength - newEnd); + return NSMakeRange(prefix, oldLength - prefix - suffix); +} + +BOOL RCTTextInputTextToRestore( + NSString *textBeforeUpdate, + NSString *typedText, + NSString *writtenText, + NSRange *range, + NSString *_Nullable *_Nonnull text) +{ + if ([typedText isEqualToString:textBeforeUpdate]) { + return NO; + } + NSRange replaced = RCTTextInputChangedRange(textBeforeUpdate, typedText); + NSUInteger insertedLength = typedText.length - (textBeforeUpdate.length - replaced.length); + NSString *inserted = [typedText substringWithRange:NSMakeRange(replaced.location, insertedLength)]; + if (![writtenText isEqualToString:textBeforeUpdate]) { + // JS changed the characters: put back an insertion only, at the same distance from the end of the text. + if (replaced.length > 0 || insertedLength == 0) { + return NO; + } + NSUInteger distanceFromEnd = textBeforeUpdate.length - replaced.location; + NSUInteger location = writtenText.length > distanceFromEnd ? writtenText.length - distanceFromEnd : 0; + if (location < writtenText.length) { + location = [writtenText rangeOfComposedCharacterSequenceAtIndex:location].location; + } + replaced = NSMakeRange(location, 0); + } + *range = replaced; + *text = inserted; + return YES; +} + +NSString *_Nullable RCTTextInputTextWithinLength(NSString *text, NSInteger allowedLength) +{ + if (allowedLength > 0 && (NSInteger)text.length > allowedLength) { + // make sure unicode characters that are longer than 16 bits (such as emojis) are not cut off + NSRange cutOffCharacterRange = [text rangeOfComposedCharacterSequenceAtIndex:allowedLength - 1]; + if ((NSInteger)NSMaxRange(cutOffCharacterRange) > allowedLength) { + // the character at the length limit takes more than 16bits, truncation should end at the character before + allowedLength = cutOffCharacterRange.location; + } + } + + if (allowedLength <= 0) { + return nil; + } + + return allowedLength > (NSInteger)text.length ? text : [text substringToIndex:allowedLength]; +} + +NSInteger RCTTextInputOffsetAfterEdit(NSInteger offset, NSRange range, NSUInteger insertedLength) +{ + if (offset >= (NSInteger)NSMaxRange(range)) { + return offset + (NSInteger)insertedLength - (NSInteger)range.length; + } + if (offset >= (NSInteger)range.location) { + return range.location + insertedLength; + } + return offset; +} + #if !TARGET_OS_TV UIDataDetectorTypes RCTUITextViewDataDetectorTypesFromStringVector(const std::vector &dataDetectorTypes) { diff --git a/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm b/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm index a02622d5d7d..5a7a7d23cd5 100644 --- a/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm +++ b/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm @@ -386,6 +386,178 @@ - (void)testDifferenceOfMissingParagraphStyle attributedString2, attributedString1, insensitiveAttributes, textAttributes)); } +#pragma mark - RCTIsAttributedStringEffectivelySame for the text a text input shows + +static NSDictionary *RCTTestTextAttributes(void) +{ + return @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSForegroundColorAttributeName : UIColor.blackColor, + }; +} + +// The insensitive attributes of a text input: its default text attributes and UIKit's default paragraph style. +static NSDictionary *RCTTestInsensitiveAttributes(void) +{ + NSMutableDictionary *attributes = [RCTTestTextAttributes() mutableCopy]; + attributes[NSBackgroundColorAttributeName] = UIColor.clearColor; + attributes[NSParagraphStyleAttributeName] = NSParagraphStyle.defaultParagraphStyle; + return attributes; +} + +// What UIKit gives text typed into a text field whose text attributes set no paragraph style. +static NSParagraphStyle *RCTTestTypedParagraphStyle(void) +{ + NSMutableParagraphStyle *style = [NSParagraphStyle.defaultParagraphStyle mutableCopy]; + style.lineBreakMode = NSLineBreakByTruncatingTail; + style.baseWritingDirection = NSWritingDirectionLeftToRight; + return style; +} + +static NSAttributedString *RCTTestString(NSString *string, NSParagraphStyle *_Nullable style) +{ + NSMutableDictionary *attributes = [RCTTestTextAttributes() mutableCopy]; + attributes[NSParagraphStyleAttributeName] = style; + return [[NSAttributedString alloc] initWithString:string attributes:attributes]; +} + +// Whether a text input showing `shown` already shows `state`, both ways round. +static BOOL +RCTTestShowsBothWays(XCTestCase *self, NSAttributedString *shown, NSAttributedString *state, BOOL singleLine) +{ + BOOL same = + RCTIsAttributedStringEffectivelySame(state, shown, RCTTestInsensitiveAttributes(), TextAttributes{}, singleLine); + BOOL reversed = + RCTIsAttributedStringEffectivelySame(shown, state, RCTTestInsensitiveAttributes(), TextAttributes{}, singleLine); + XCTAssertEqual(same, reversed); + return same; +} + +#define RCTTestShows(shown, state, singleLine) RCTTestShowsBothWays(self, shown, state, singleLine) + +- (void)testSamenessOfTypedTextWithUIKitsDefaultParagraphStyle +{ + // The state built from a controlled `value` has no paragraph style; the typed text has UIKit's default one. + NSAttributedString *state = RCTTestString(@"abc", nil); + NSAttributedString *shown = RCTTestString(@"abc", RCTTestTypedParagraphStyle()); + + XCTAssertTrue(RCTTestShows(shown, state, YES)); + // A text view does not set its own line break mode: another one in a multiline input is a change. + XCTAssertFalse(RCTTestShows(shown, state, NO)); +} + +- (void)testSamenessOfTypedTextWithTheInputsDefaultAttributes +{ + // The first character typed into an empty field gets the field's default attributes after it reached JS. + NSMutableAttributedString *shown = [RCTTestString(@"/", RCTTestTypedParagraphStyle()) mutableCopy]; + [shown addAttribute:NSBackgroundColorAttributeName value:UIColor.clearColor range:NSMakeRange(0, 1)]; + + XCTAssertTrue(RCTTestShows(shown, RCTTestString(@"/", nil), YES)); +} + +- (void)testSamenessOfAnyLineBreakModeInASingleLineField +{ + NSMutableParagraphStyle *wrapping = [NSMutableParagraphStyle new]; + wrapping.lineBreakMode = NSLineBreakByWordWrapping; + NSMutableParagraphStyle *truncating = [NSMutableParagraphStyle new]; + truncating.lineBreakMode = NSLineBreakByTruncatingTail; + + XCTAssertTrue(RCTTestShows(RCTTestString(@"abc", truncating), RCTTestString(@"abc", wrapping), YES)); + // A multiline input shows the line break mode JS sets (`lineBreakModeIOS`). + XCTAssertFalse(RCTTestShows(RCTTestString(@"abc", truncating), RCTTestString(@"abc", wrapping), NO)); +} + +- (void)testSamenessOfASingleLineFieldWithoutTheLineHeight +{ + // `lineHeight` on a single-line input: the text field shows the text without the line height, with the writing + // direction resolved. + NSMutableParagraphStyle *stateStyle = [NSMutableParagraphStyle new]; + stateStyle.minimumLineHeight = 26; + stateStyle.maximumLineHeight = 26; + NSMutableParagraphStyle *shownStyle = [RCTTestTypedParagraphStyle() mutableCopy]; + + XCTAssertTrue(RCTTestShows(RCTTestString(@"p", shownStyle), RCTTestString(@"p", stateStyle), YES)); + // A multiline input keeps the line height. + XCTAssertFalse(RCTTestShows(RCTTestString(@"p", shownStyle), RCTTestString(@"p", stateStyle), NO)); +} + +- (void)testDifferenceOfAnotherLineHeightInAMultilineInput +{ + NSMutableParagraphStyle *stateStyle = [NSMutableParagraphStyle new]; + stateStyle.minimumLineHeight = 30; + stateStyle.maximumLineHeight = 30; + NSMutableParagraphStyle *shownStyle = [stateStyle mutableCopy]; + shownStyle.minimumLineHeight = 26; + shownStyle.maximumLineHeight = 26; + + XCTAssertFalse(RCTTestShows(RCTTestString(@"abc", shownStyle), RCTTestString(@"abc", stateStyle), NO)); +} + +- (void)testDifferenceOfAnotherAlignment +{ + NSMutableParagraphStyle *centered = [NSMutableParagraphStyle new]; + centered.alignment = NSTextAlignmentCenter; + + XCTAssertFalse( + RCTTestShows(RCTTestString(@"abc", RCTTestTypedParagraphStyle()), RCTTestString(@"abc", centered), YES)); + // Shown centered while the state sets no paragraph style: a change too. + XCTAssertFalse(RCTTestShows(RCTTestString(@"abc", centered), RCTTestString(@"abc", nil), YES)); +} + +- (void)testDifferenceOfAChangedOrRemovedAttribute +{ + NSMutableAttributedString *restyled = [RCTTestString(@"abc", nil) mutableCopy]; + [restyled addAttribute:NSForegroundColorAttributeName value:UIColor.redColor range:NSMakeRange(0, 3)]; + XCTAssertFalse(RCTTestShows(RCTTestString(@"abc", RCTTestTypedParagraphStyle()), restyled, YES)); + + // JS removed the underline the field shows: not one of the input's default attributes. + NSMutableAttributedString *underlined = [RCTTestString(@"abc", nil) mutableCopy]; + [underlined addAttribute:NSUnderlineStyleAttributeName value:@(NSUnderlineStyleSingle) range:NSMakeRange(0, 3)]; + XCTAssertFalse(RCTTestShows(underlined, RCTTestString(@"abc", nil), YES)); +} + +- (void)testSamenessAndDifferenceOfLetterSpacing +{ + NSMutableAttributedString *state = [RCTTestString(@"abc", nil) mutableCopy]; + [state addAttribute:NSKernAttributeName value:@3 range:NSMakeRange(0, 3)]; + NSMutableAttributedString *shown = [state mutableCopy]; + [shown addAttribute:NSParagraphStyleAttributeName value:RCTTestTypedParagraphStyle() range:NSMakeRange(0, 3)]; + + XCTAssertTrue(RCTTestShows(shown, state, YES)); + // JS removing the letter spacing is a change. + XCTAssertFalse(RCTTestShows(shown, RCTTestString(@"abc", nil), YES)); +} + +- (void)testSamenessOfRightToLeftTextInASingleLineField +{ + // A text field shows the text with its own writing direction, whatever the state's paragraph style sets. + NSString *hebrew = @"\u05E9\u05DC\u05D5\u05DD"; + NSMutableParagraphStyle *rtl = [NSMutableParagraphStyle new]; + rtl.baseWritingDirection = NSWritingDirectionRightToLeft; + rtl.alignment = NSTextAlignmentRight; + NSMutableParagraphStyle *shown = [rtl mutableCopy]; + shown.baseWritingDirection = NSWritingDirectionLeftToRight; + shown.lineBreakMode = NSLineBreakByTruncatingTail; + + XCTAssertTrue(RCTTestShows(RCTTestString(hebrew, shown), RCTTestString(hebrew, rtl), YES)); + // The alignment is still compared. + NSMutableParagraphStyle *centered = [rtl mutableCopy]; + centered.alignment = NSTextAlignmentCenter; + XCTAssertFalse(RCTTestShows(RCTTestString(hebrew, shown), RCTTestString(hebrew, centered), YES)); + // A multiline input keeps the direction. + XCTAssertFalse(RCTTestShows(RCTTestString(hebrew, shown), RCTTestString(hebrew, rtl), NO)); +} + +- (void)testSamenessOfEmojiAndCombiningMarks +{ + NSString *emoji = + @"a\U0001F468\u200D\U0001F469\u200D\U0001F467\u200D\U0001F466b\U0001F44D\U0001F3FDc\U0001F1EF\U0001F1F5de\u0301f"; + XCTAssertTrue(RCTTestShows(RCTTestString(emoji, RCTTestTypedParagraphStyle()), RCTTestString(emoji, nil), YES)); + NSString *precomposed = [emoji stringByReplacingOccurrencesOfString:@"e\u0301" withString:@"\u00E9"]; + XCTAssertFalse( + RCTTestShows(RCTTestString(precomposed, RCTTestTypedParagraphStyle()), RCTTestString(emoji, nil), YES)); +} + // A std::string may legitimately contain an embedded NUL. The C-string APIs // these converters used to call stop at it, silently truncating user text. diff --git a/packages/react-native/React/Tests/Text/RCTTextInputComponentViewTests.mm b/packages/react-native/React/Tests/Text/RCTTextInputComponentViewTests.mm new file mode 100644 index 00000000000..11d2d419600 --- /dev/null +++ b/packages/react-native/React/Tests/Text/RCTTextInputComponentViewTests.mm @@ -0,0 +1,949 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import +#import +#import +#import +#import +#import +#import +#import +#import + +#if TARGET_OS_IOS + +using namespace facebook::react; + +@interface RCTTextInputComponentView (Tests) +- (void)_setAttributedStringFromJS:(NSAttributedString *)attributedString; +- (void)textInputDidChange; +- (void)textInputDidChangeSelection; +- (void)setTextAndSelection:(NSInteger)eventCount + value:(NSString *__nullable)value + start:(NSInteger)start + end:(NSInteger)end; +@end + +// Types `text` at the selection one key (composed character sequence) at a time, as UIKit does: asks the delegate +// (-textInputShouldChangeText:inRange:, which sends onKeyPress and applies maxLength), puts the text it allows in +// place of the selection with the typing attributes, moves the caret after it and reports the change. A "\b" key is +// a backspace: it deletes the selection, or the character before the caret. +static void +RCTTestTypeKeys(UIView *input, NSString *text, void (^setText)(NSAttributedString *)) +{ + [text enumerateSubstringsInRange:NSMakeRange(0, text.length) + options:NSStringEnumerationByComposedCharacterSequences + usingBlock:^(NSString *key, NSRange, NSRange, BOOL *) { + UITextRange *selection = input.selectedTextRange; + NSInteger start = [input offsetFromPosition:input.beginningOfDocument + toPosition:selection.start]; + NSInteger end = [input offsetFromPosition:input.beginningOfDocument toPosition:selection.end]; + NSRange range = NSMakeRange(start, end - start); + if ([key isEqualToString:@"\b"]) { + if (range.length == 0) { + if (start == 0) { + return; + } + range = [input.attributedText.string rangeOfComposedCharacterSequenceAtIndex:start - 1]; + } + key = @""; + } + NSString *allowed = [input.textInputDelegate textInputShouldChangeText:key inRange:range]; + if (allowed == nil) { + return; + } + NSMutableAttributedString *typed = [input.attributedText mutableCopy]; + [typed replaceCharactersInRange:range + withAttributedString:[[NSAttributedString alloc] + initWithString:allowed + attributes:input.typingAttributes]]; + setText(typed); + UITextPosition *caret = [input positionFromPosition:input.beginningOfDocument + offset:range.location + allowed.length]; + [input setSelectedTextRange:[input textRangeFromPosition:caret toPosition:caret] + notifyDelegate:NO]; + [input.textInputDelegate textInputDidChange]; + }]; +} + +// IME composition, as UIKit does for a composition key: asks the delegate, shows `markedText` in place of the +// selection, marked, and reports the change. +static void RCTTestSetMarkedText(UIView *input, NSString *markedText) +{ + UITextRange *selection = input.markedTextRange ?: input.selectedTextRange; + NSInteger start = [input offsetFromPosition:input.beginningOfDocument toPosition:selection.start]; + NSInteger end = [input offsetFromPosition:input.beginningOfDocument toPosition:selection.end]; + if ([input.textInputDelegate textInputShouldChangeText:markedText inRange:NSMakeRange(start, end - start)] == nil) { + return; + } + [input setMarkedText:markedText selectedRange:NSMakeRange(markedText.length, 0)]; + [input.textInputDelegate textInputDidChange]; +} + +// Like UIKit, delivers the keys (or a composition) queued behind -setAttributedText: inside it: before the new text is +// assigned (a text field), or after (a text view can). +static void RCTTestSetAttributedText( + UIView *input, + NSAttributedString *attributedText, + NSString *keys, + NSString *markedText, + BOOL keysArriveAfterAssignment, + void (^setText)(NSAttributedString *)) +{ + if (!keysArriveAfterAssignment) { + if (keys != nil) { + RCTTestTypeKeys(input, keys, setText); + } + if (markedText != nil) { + RCTTestSetMarkedText(input, markedText); + } + } + setText(attributedText); + if (keysArriveAfterAssignment) { + if (keys != nil) { + RCTTestTypeKeys(input, keys, setText); + } + if (markedText != nil) { + RCTTestSetMarkedText(input, markedText); + } + } +} + +@protocol RCTTestKeysDuringWrite +@property (nonatomic, copy, nullable) NSString *keysDuringNextWrite; +@property (nonatomic, copy, nullable) NSString *markedTextDuringNextWrite; +// Keys UIKit delivers inside -replaceRange:withText:, before the edit applies. +@property (nonatomic, copy, nullable) NSString *keysDuringNextReplace; +@property (nonatomic, assign) BOOL keysArriveAfterAssignment; +@property (nonatomic, assign) NSUInteger writeCount; +@end + +@interface RCTKeysDuringWriteTextField : RCTUITextField +@end + +@implementation RCTKeysDuringWriteTextField + +@synthesize keysDuringNextWrite = _keysDuringNextWrite; +@synthesize markedTextDuringNextWrite = _markedTextDuringNextWrite; +@synthesize keysDuringNextReplace = _keysDuringNextReplace; +@synthesize keysArriveAfterAssignment = _keysArriveAfterAssignment; +@synthesize writeCount = _writeCount; + +- (void)setAttributedText:(NSAttributedString *)attributedText +{ + NSString *keys = self.keysDuringNextWrite; + NSString *markedText = self.markedTextDuringNextWrite; + self.keysDuringNextWrite = nil; + self.markedTextDuringNextWrite = nil; + self.writeCount++; + RCTTestSetAttributedText( + self, attributedText, keys, markedText, self.keysArriveAfterAssignment, ^(NSAttributedString *text) { + [super setAttributedText:text]; + }); +} + +- (void)replaceRange:(UITextRange *)range withText:(NSString *)text +{ + NSString *keys = self.keysDuringNextReplace; + self.keysDuringNextReplace = nil; + if (keys != nil) { + RCTTestTypeKeys(self, keys, ^(NSAttributedString *typed) { + [super setAttributedText:typed]; + }); + } + [super replaceRange:range withText:text]; +} +@end + +@interface RCTKeysDuringWriteTextView : RCTUITextView +@end + +@implementation RCTKeysDuringWriteTextView + +@synthesize keysDuringNextWrite = _keysDuringNextWrite; +@synthesize markedTextDuringNextWrite = _markedTextDuringNextWrite; +@synthesize keysDuringNextReplace = _keysDuringNextReplace; +@synthesize keysArriveAfterAssignment = _keysArriveAfterAssignment; +@synthesize writeCount = _writeCount; + +- (void)setAttributedText:(NSAttributedString *)attributedText +{ + NSString *keys = self.keysDuringNextWrite; + NSString *markedText = self.markedTextDuringNextWrite; + self.keysDuringNextWrite = nil; + self.markedTextDuringNextWrite = nil; + self.writeCount++; + RCTTestSetAttributedText( + self, attributedText, keys, markedText, self.keysArriveAfterAssignment, ^(NSAttributedString *text) { + [super setAttributedText:text]; + }); +} + +- (void)replaceRange:(UITextRange *)range withText:(NSString *)text +{ + NSString *keys = self.keysDuringNextReplace; + self.keysDuringNextReplace = nil; + if (keys != nil) { + RCTTestTypeKeys(self, keys, ^(NSAttributedString *typed) { + [super setAttributedText:typed]; + }); + } + [super replaceRange:range withText:text]; +} +@end + +// The paragraph style UIKit gives text typed into a text field whose text attributes set none. +static NSParagraphStyle *RCTTestUIKitParagraphStyle(void) +{ + NSMutableParagraphStyle *style = [NSParagraphStyle.defaultParagraphStyle mutableCopy]; + style.lineBreakMode = NSLineBreakByTruncatingTail; + style.baseWritingDirection = NSWritingDirectionLeftToRight; + return style; +} + +static NSAttributedString *RCTTestStyled(NSString *text, UIColor *color) +{ + return [[NSAttributedString alloc] initWithString:text + attributes:@{ + NSFontAttributeName : [UIFont boldSystemFontOfSize:16], + NSForegroundColorAttributeName : color, + }]; +} + +// Records what the view reports to JS outside writes from JS: "change" (onChange) and "selection" +// (onSelectionChange). +@interface RCTTestTextInputComponentView : RCTTextInputComponentView +@property (nonatomic, strong) NSMutableArray *reports; +@end + +@implementation RCTTestTextInputComponentView + +- (void)textInputDidChange +{ + if (![[self valueForKey:@"_comingFromJS"] boolValue]) { + [self.reports addObject:@"change"]; + } + [super textInputDidChange]; +} + +- (void)textInputDidChangeSelection +{ + if (![[self valueForKey:@"_comingFromJS"] boolValue]) { + [self.reports addObject:@"selection"]; + } + [super textInputDidChangeSelection]; +} + +@end + +@interface RCTTextInputComponentViewTests : XCTestCase +@end + +@implementation RCTTextInputComponentViewTests { + UIWindow *_window; + RCTTextInputComponentView *_view; + UIView *_input; + State::Shared _state; +} + +- (void)tearDown +{ + [_input resignFirstResponder]; + _window.hidden = YES; + _window = nil; + _view = nil; + _input = nil; + _state = nullptr; + [super tearDown]; +} + +// A focused TextInput whose backed text input delivers keys during writes, with an empty state from JS. +- (void)makeInputMultiline:(BOOL)multiline maxLength:(int)maxLength +{ + _window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 480)]; + _view = [[RCTTestTextInputComponentView alloc] initWithFrame:CGRectMake(0, 0, 300, 80)]; + auto props = std::make_shared(); + props->multiline = multiline; + if (maxLength > 0) { + props->maxLength = maxLength; + } + [_view updateProps:props oldProps:nullptr]; + + UIView *original = [_view valueForKey:@"_backedTextInputView"]; + _input = multiline ? [[RCTKeysDuringWriteTextView alloc] initWithFrame:original.frame] + : [[RCTKeysDuringWriteTextField alloc] initWithFrame:original.frame]; + RCTCopyBackedTextInput(original, _input); + _input.textInputDelegate = (id)_view; + [original removeFromSuperview]; + [_view setValue:_input forKey:@"_backedTextInputView"]; + [_view addSubview:_input]; + + [_window addSubview:_view]; + [_window makeKeyAndVisible]; + [self sendStateFromJS:[NSAttributedString new]]; + [_input becomeFirstResponder]; + _input.writeCount = 0; +} + +- (void)typeKeys:(NSString *)keys +{ + UIView *input = _input; + NSUInteger writes = input.writeCount; + RCTTestTypeKeys(input, keys, ^(NSAttributedString *text) { + input.attributedText = text; + }); + input.writeCount = writes; +} + +// Sets the input's text without telling anyone, as UIKit adjusts the attributes of the text it shows. +- (void)setTextQuietly:(NSAttributedString *)text +{ + NSUInteger writes = _input.writeCount; + _input.attributedText = text; + _input.writeCount = writes; +} + +// Commits a state as Fabric does: the text JS holds, with the view's current event count. +- (void)sendStateFromJS:(NSAttributedString *)text +{ + NSNumber *eventCount = [_view valueForKey:@"_mostRecentEventCount"]; + auto data = TextInputState{}; + data.attributedStringBox = RCTAttributedStringBoxFromNSAttributedString(text); + data.mostRecentEventCount = eventCount.longLongValue; + State::Shared state = std::make_shared( + std::make_shared(std::move(data)), ShadowNodeFamily::Weak{}); + [_view updateState:state oldState:_state]; + _state = state; +} + +// Keys queued while a state is committed arrive inside the write if there is one, or after it. +- (void)sendStateFromJS:(NSAttributedString *)text whileTyping:(NSString *)keys +{ + _input.keysDuringNextWrite = keys; + [self sendStateFromJS:text]; + if (_input.keysDuringNextWrite != nil) { + _input.keysDuringNextWrite = nil; + [self typeKeys:keys]; + } +} + +- (NSAttributedString *)textWithDefaultAttributes:(NSString *)text +{ + NSMutableDictionary *attributes = [_input.defaultTextAttributes mutableCopy]; + [attributes removeObjectForKey:RCTAttributedStringEventEmitterKey]; + return [[NSAttributedString alloc] initWithString:text attributes:attributes]; +} + +- (NSAttributedString *)textWithDefaultAttributes:(NSString *)text paragraphStyle:(NSParagraphStyle *)paragraphStyle +{ + NSMutableAttributedString *styled = [[self textWithDefaultAttributes:text] mutableCopy]; + [styled addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:NSMakeRange(0, styled.length)]; + return styled; +} + +#pragma mark - States that change nothing the field shows + +- (void)testControlledValueEqualToTheTypedTextIsNotWrittenWhileKeysArrive +{ + // A controlled `value` equal to what was typed: the state is built from props, without the paragraph style and the + // event emitter UIKit gave the typed text. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"p"]; + NSMutableAttributedString *shown = [_input.attributedText mutableCopy]; + [shown addAttribute:NSParagraphStyleAttributeName value:RCTTestUIKitParagraphStyle() range:NSMakeRange(0, 1)]; + [self setTextQuietly:shown]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"p"] whileTyping:@"ri"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"pri"); + XCTAssertEqual(_input.writeCount, 0u); +} + +- (void)testFirstCharacterWithTheInputsDefaultAttributesIsNotWrittenWhileKeysArrive +{ + // UIKit gives the first character typed into an empty field the field's default attributes (here a background + // color) after it reached JS; the state, built from props, does not have them. + [self makeInputMultiline:NO maxLength:0]; + NSMutableDictionary *defaults = [_input.defaultTextAttributes mutableCopy]; + defaults[NSFontAttributeName] = [UIFont systemFontOfSize:20]; + NSAttributedString *state = [[NSAttributedString alloc] initWithString:@"/" attributes:defaults]; + defaults[NSBackgroundColorAttributeName] = UIColor.clearColor; + _input.defaultTextAttributes = defaults; + [self typeKeys:@"/"]; + NSMutableDictionary *shown = [defaults mutableCopy]; + shown[NSParagraphStyleAttributeName] = RCTTestUIKitParagraphStyle(); + [self setTextQuietly:[[NSAttributedString alloc] initWithString:@"/" attributes:shown]]; + + [self sendStateFromJS:state whileTyping:@"pr"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"/pr"); + XCTAssertEqual(_input.writeCount, 0u); +} + +- (void)testSingleLineStateWithALineHeightIsNotWrittenWhileKeysArrive +{ + // `lineHeight` on a single-line input: a text field shows the text without the line height, with its own writing + // direction and line break mode. + [self makeInputMultiline:NO maxLength:0]; + NSMutableParagraphStyle *stateStyle = [NSMutableParagraphStyle new]; + stateStyle.minimumLineHeight = 26; + stateStyle.maximumLineHeight = 26; + NSMutableParagraphStyle *shownStyle = [RCTTestUIKitParagraphStyle() mutableCopy]; + [self typeKeys:@"p"]; + [self setTextQuietly:[self textWithDefaultAttributes:@"p" paragraphStyle:shownStyle]]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"p" paragraphStyle:stateStyle] whileTyping:@"ri"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"pri"); + XCTAssertEqual(_input.writeCount, 0u); +} + +- (void)testStateWithoutAParagraphStyleIsNotWrittenOverUIKitsInitialTypingParagraphStyle +{ + // After a write (a nested restyle), UIKit can show typed text with the paragraph style of its initial typing + // attributes (another line break strategy than the default one). A state without a paragraph style is the same text. + [self makeInputMultiline:NO maxLength:0]; + NSParagraphStyle *typingStyle = + [[_view valueForKey:@"_originalTypingAttributes"] objectForKey:NSParagraphStyleAttributeName]; + XCTAssertNotNil(typingStyle); + [self typeKeys:@"p"]; + NSMutableAttributedString *shown = [RCTTestStyled(@"p", UIColor.blueColor) mutableCopy]; + [shown addAttribute:NSParagraphStyleAttributeName value:typingStyle range:NSMakeRange(0, 1)]; + [self setTextQuietly:shown]; + + [self sendStateFromJS:RCTTestStyled(@"p", UIColor.blueColor) whileTyping:@"ri"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"pri"); + XCTAssertEqual(_input.writeCount, 0u); +} + +#pragma mark - States that change what the field shows + +- (void)testMultilineStateWithAnotherLineHeightIsWritten +{ + [self makeInputMultiline:YES maxLength:0]; + NSMutableParagraphStyle *style = [NSMutableParagraphStyle new]; + style.minimumLineHeight = 26; + style.maximumLineHeight = 26; + [self setTextQuietly:[self textWithDefaultAttributes:@"abc" paragraphStyle:style]]; + NSMutableParagraphStyle *taller = [style mutableCopy]; + taller.minimumLineHeight = 30; + taller.maximumLineHeight = 30; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"abc" paragraphStyle:taller]]; + + NSParagraphStyle *shown = [_input.attributedText attribute:NSParagraphStyleAttributeName + atIndex:0 + effectiveRange:NULL]; + XCTAssertEqual(shown.minimumLineHeight, 30); +} + +- (void)testMultilineStateWithAnotherLineBreakModeIsWritten +{ + // `lineBreakModeIOS` changed by JS: a text view's -setDefaultTextAttributes: only sets the typing attributes, so the + // text it shows gets the new mode from the state. + [self makeInputMultiline:YES maxLength:0]; + NSMutableParagraphStyle *wordWrapping = [NSMutableParagraphStyle new]; + wordWrapping.lineBreakMode = NSLineBreakByWordWrapping; + NSMutableParagraphStyle *charWrapping = [NSMutableParagraphStyle new]; + charWrapping.lineBreakMode = NSLineBreakByCharWrapping; + [self setTextQuietly:[self textWithDefaultAttributes:@"abc def" paragraphStyle:wordWrapping]]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"abc def" paragraphStyle:charWrapping]]; + + NSParagraphStyle *shown = [_input.attributedText attribute:NSParagraphStyleAttributeName + atIndex:0 + effectiveRange:NULL]; + XCTAssertEqual(shown.lineBreakMode, NSLineBreakByCharWrapping); +} + +- (void)testStateThatRestylesTheTextIsWritten +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + NSMutableAttributedString *restyled = [[self textWithDefaultAttributes:@"abc"] mutableCopy]; + [restyled addAttribute:NSForegroundColorAttributeName value:UIColor.redColor range:NSMakeRange(0, 3)]; + + [self sendStateFromJS:restyled]; + + XCTAssertEqualObjects( + [_input.attributedText attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:NULL], UIColor.redColor); +} + +- (void)testStateWithOtherTextIsWritten +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"]]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABC"); +} + +#pragma mark - Keys typed while JS's text is written (they are put back) + +- (void)setTextAndSelectionFromJS:(NSString *)value start:(NSInteger)start end:(NSInteger)end +{ + NSNumber *eventCount = [_view valueForKey:@"_mostRecentEventCount"]; + [_view setTextAndSelection:eventCount.integerValue value:value start:start end:end]; +} + +- (NSMutableArray *)reports +{ + RCTTestTextInputComponentView *view = (RCTTestTextInputComponentView *)_view; + view.reports = view.reports ?: [NSMutableArray new]; + return view.reports; +} + +- (NSInteger)caret +{ + return [_input offsetFromPosition:_input.beginningOfDocument toPosition:_input.selectedTextRange.start]; +} + +- (void)selectFrom:(NSInteger)start length:(NSInteger)length +{ + UITextPosition *from = [_input positionFromPosition:_input.beginningOfDocument offset:start]; + UITextPosition *to = [_input positionFromPosition:_input.beginningOfDocument offset:start + length]; + [_input setSelectedTextRange:[_input textRangeFromPosition:from toPosition:to] notifyDelegate:NO]; +} + +- (void)moveCaretTo:(NSInteger)offset +{ + UITextPosition *position = [_input positionFromPosition:_input.beginningOfDocument offset:offset]; + [_input setSelectedTextRange:[_input textRangeFromPosition:position toPosition:position] notifyDelegate:NO]; +} + +- (void)testKeysTypedWhileJSRestylesTheTextAreKept +{ + // A nested styles the first character: JS writes "p" with the child's attributes, and "ri" arrive meanwhile. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"p"]; + [self.reports removeAllObjects]; + + [self sendStateFromJS:RCTTestStyled(@"p", UIColor.blueColor) whileTyping:@"ri"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"pri"); + // JS hears about the keys (onChange) and then the caret they moved (onSelectionChange). + XCTAssertEqualObjects(self.reports, (@[ @"change", @"selection" ])); + // JS's style was applied, and the restored keys take it too. + XCTAssertEqualObjects( + [_input.attributedText attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:NULL], + UIColor.blueColor); + XCTAssertEqualObjects( + [_input.attributedText attribute:NSForegroundColorAttributeName atIndex:2 effectiveRange:NULL], + UIColor.blueColor); + XCTAssertEqual(self.caret, 3); +} + +- (void)testKeysTypedWhileJSTransformsTheTextAreKept +{ + // onChangeText upper-cases: JS writes "ABC" while "d" arrives. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"] whileTyping:@"d"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABCd"); +} + +- (void)testKeysTypedInAMultilineInputWhileJSTransformsTheTextAreKept +{ + [self makeInputMultiline:YES maxLength:0]; + [self typeKeys:@"abc"]; + [self.reports removeAllObjects]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"] whileTyping:@"d"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABCd"); + XCTAssertEqualObjects(self.reports, (@[ @"change", @"selection" ])); + XCTAssertEqual(self.caret, 4); +} + +- (void)testKeysThatArriveAfterTheWriteAreNotDuplicated +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"#a Hello"]; + [self moveCaretTo:2]; + _input.keysArriveAfterAssignment = YES; + [self.reports removeAllObjects]; + + [self sendStateFromJS:RCTTestStyled(@"#a Hello", UIColor.blueColor) whileTyping:@"b"]; + + XCTAssertEqualObjects(self.reports, (@[ @"change", @"selection" ])); + NSString *text = _input.attributedText.string; + XCTAssertEqual([text componentsSeparatedByString:@"b"].count, 2u, @"%@", text); + XCTAssertEqual(text.length, 9u, @"%@", text); + // The caret is after the key, where the next key goes (the write had put it back before the key). + XCTAssertEqual(self.caret, (NSInteger)NSMaxRange([text rangeOfString:@"b"])); +} + +- (void)assertKeyEqualToTheTextJSAppendsIsKeptMultiline:(BOOL)multiline +{ + // JS appends "d" while the user types "d": the field holds what the user typed during the write, which is also + // the text JS wrote. Both "d"s are kept. + [self makeInputMultiline:multiline maxLength:0]; + [self typeKeys:@"abc"]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"abcd"] whileTyping:@"d"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abcdd", @"multiline=%d", multiline); +} + +- (void)testAKeyEqualToTheTextJSAppendsIsKept +{ + [self assertKeyEqualToTheTextJSAppendsIsKeptMultiline:NO]; +} + +- (void)testAKeyEqualToTheTextJSAppendsIsKeptInAMultilineInput +{ + [self assertKeyEqualToTheTextJSAppendsIsKeptMultiline:YES]; +} + +- (void)testAWriteWithNoKeysTypedLeavesJSText +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"]]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABC"); +} + +- (void)testKeysTypedWhileAControlledValueIsSetAreKept +{ + // onChangeText strips "/": TextInput sets the controlled value "" with setTextAndSelection while "pr" arrive. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"/"]; + _input.keysDuringNextWrite = @"pr"; + + [self setTextAndSelectionFromJS:@"" start:0 end:0]; + + XCTAssertEqualObjects(_input.attributedText.string, @"pr"); + XCTAssertEqual(self.caret, 2); +} + +- (void)testAControlledValueSetWithNoKeysTypedLeavesJSText +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + + [self setTextAndSelectionFromJS:@"ABC" start:3 end:3]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABC"); +} + +- (void)testKeysRestoredAfterAControlledValueKeepJSSelection +{ + // JS sets "ABC" and puts the caret at 1; "d" was typed at the end meanwhile. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + _input.keysDuringNextWrite = @"d"; + + [self setTextAndSelectionFromJS:@"ABC" start:1 end:1]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABCd"); + XCTAssertEqual(self.caret, 1); +} + +- (void)testRestoredKeysAreAnEditTheUserCanUndo +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"p"]; + [self sendStateFromJS:RCTTestStyled(@"p", UIColor.blueColor) whileTyping:@"ri"]; + XCTAssertEqualObjects(_input.attributedText.string, @"pri"); + + XCTAssertTrue(_input.undoManager.canUndo); + [_input.undoManager undo]; + + XCTAssertEqualObjects(_input.attributedText.string, @"p"); +} + +#pragma mark - Text JS inserts with no key typed (a text view reports the write itself) + +- (void)assertJSInsertionIsNotDuplicatedMultiline:(BOOL)multiline focused:(BOOL)focused viaState:(BOOL)viaState +{ + [self makeInputMultiline:multiline maxLength:0]; + [self typeKeys:@"abc"]; + if (!focused) { + [_input resignFirstResponder]; + } + + if (viaState) { + [self sendStateFromJS:[self textWithDefaultAttributes:@"abc def"]]; + } else { + [self setTextAndSelectionFromJS:@"abc def" start:7 end:7]; + } + + XCTAssertEqualObjects( + _input.attributedText.string, @"abc def", @"multiline=%d focused=%d viaState=%d", multiline, focused, viaState); +} + +- (void)testJSInsertionInAFocusedMultilineInputIsNotDuplicated +{ + [self assertJSInsertionIsNotDuplicatedMultiline:YES focused:YES viaState:NO]; +} + +- (void)testJSInsertionInAnUnfocusedMultilineInputIsNotDuplicated +{ + [self assertJSInsertionIsNotDuplicatedMultiline:YES focused:NO viaState:NO]; +} + +- (void)testStateInsertionInAFocusedMultilineInputIsNotDuplicated +{ + [self assertJSInsertionIsNotDuplicatedMultiline:YES focused:YES viaState:YES]; +} + +- (void)testStateInsertionInAnUnfocusedMultilineInputIsNotDuplicated +{ + [self assertJSInsertionIsNotDuplicatedMultiline:YES focused:NO viaState:YES]; +} + +- (void)testJSInsertionInASingleLineInputIsNotDuplicated +{ + [self assertJSInsertionIsNotDuplicatedMultiline:NO focused:YES viaState:NO]; +} + +- (void)testSetAttributedStringFromJSInAMultilineInputIsNotDuplicated +{ + [self makeInputMultiline:YES maxLength:0]; + [self typeKeys:@"abc"]; + + [_view _setAttributedStringFromJS:[self textWithDefaultAttributes:@"abc def"]]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abc def"); +} + +#pragma mark - maxLength + +- (void)testKeysRestoredAfterAFormatterWriteRespectMaxLength +{ + // maxLength 5; JS formats "ab" to "ab-cd". "xy" arrive during the write; UIKit checked them against the old length + // (2) and let them through. Put back, they would exceed maxLength. + [self makeInputMultiline:NO maxLength:5]; + [self typeKeys:@"ab"]; + _input.keysDuringNextWrite = @"xy"; + + [self setTextAndSelectionFromJS:@"ab-cd" start:5 end:5]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ab-cd"); +} + +- (void)testKeysRestoredUpToMaxLength +{ + [self makeInputMultiline:NO maxLength:6]; + [self typeKeys:@"ab"]; + _input.keysDuringNextWrite = @"xy"; + + [self setTextAndSelectionFromJS:@"ab-cd" start:5 end:5]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ab-cdx"); +} + +- (void)testKeysRestoredUpToMaxLengthDoNotSplitAnEmoji +{ + // 👍🏽 is 4 UTF-16 units; 2 are left. + [self makeInputMultiline:NO maxLength:7]; + [self typeKeys:@"ab"]; + _input.keysDuringNextWrite = @"\U0001F44D\U0001F3FD"; + + [self setTextAndSelectionFromJS:@"ab-cd" start:5 end:5]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ab-cd"); +} + +- (void)testKeysDeliveredInsideTheRestoreRespectMaxLength +{ + // maxLength 5; JS formats "123" to "12-3"; "4" arrives during the write and is put back ("12-34"). "5" arrives + // inside that edit, before it applies: it must not exceed maxLength. + [self makeInputMultiline:NO maxLength:5]; + [self typeKeys:@"123"]; + _input.keysDuringNextWrite = @"4"; + _input.keysDuringNextReplace = @"5"; + + [self setTextAndSelectionFromJS:@"12-3" start:4 end:4]; + + XCTAssertEqualObjects(_input.attributedText.string, @"12-34"); +} + +- (void)testTypingRespectsMaxLength +{ + [self makeInputMultiline:NO maxLength:3]; + + [self typeKeys:@"abcd"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abc"); +} + +#pragma mark - Keys UIKit delivers inside the edit that puts keys back + +- (void)testKeysDeliveredInsideTheRestoreAreKeptAndReportedOnce +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + _input.keysDuringNextReplace = @"e"; + [self.reports removeAllObjects]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"] whileTyping:@"d"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABCde"); + XCTAssertEqualObjects(self.reports, (@[ @"change", @"selection" ])); + XCTAssertEqual(self.caret, 5); +} + +- (void)assertTheNextKeyFollowsAKeyDeliveredInsideTheRestoreMultiline:(BOOL)multiline +{ + [self makeInputMultiline:multiline maxLength:0]; + [self typeKeys:@"abc"]; + _input.keysDuringNextReplace = @"e"; + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"] whileTyping:@"d"]; + + [self typeKeys:@"f"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABCdef"); +} + +- (void)testTheNextKeyFollowsAKeyDeliveredInsideTheRestore +{ + [self assertTheNextKeyFollowsAKeyDeliveredInsideTheRestoreMultiline:NO]; +} + +- (void)testTheNextKeyFollowsAKeyDeliveredInsideTheRestoreInAMultilineInput +{ + [self assertTheNextKeyFollowsAKeyDeliveredInsideTheRestoreMultiline:YES]; +} + +- (void)testTheNextKeyFollowsAKeyDeliveredInsideTheRestoreAfterAControlledValue +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + _input.keysDuringNextWrite = @"d"; + _input.keysDuringNextReplace = @"e"; + [self setTextAndSelectionFromJS:@"ABC" start:3 end:3]; + + [self typeKeys:@"f"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABCdef"); +} + +- (void)testAKeyDeliveredInsideTheRestoreOfAReplacementIsKept +{ + // "d" is selected and "X" typed over it while JS restyles the text; "Y" arrives inside the edit that puts "X" back. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abcd"]; + [self selectFrom:3 length:1]; + _input.keysDuringNextReplace = @"Y"; + NSMutableAttributedString *restyled = [RCTTestStyled(@"abc", UIColor.blueColor) mutableCopy]; + [restyled appendAttributedString:RCTTestStyled(@"d", UIColor.redColor)]; + + [self sendStateFromJS:restyled whileTyping:@"X"]; + [self typeKeys:@"Z"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abcXYZ"); + // Typed over a selection, "X" takes the style of the text before it. + XCTAssertEqualObjects( + [_input.attributedText attribute:NSForegroundColorAttributeName atIndex:3 effectiveRange:NULL], + UIColor.blueColor); +} + +- (void)testAKeyTypedInTheRangeARestoreReplacedIsKept +{ + // "abc" is selected and "X" typed over it while JS restyles the text; "Y" arrives inside the edit that puts "X" back. + // Keys inside the range that edit replaces are dropped only while it applies: "Z", typed there next, is kept. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abcd"]; + [self selectFrom:0 length:3]; + _input.keysDuringNextReplace = @"Y"; + NSMutableAttributedString *restyled = [RCTTestStyled(@"ab", UIColor.blueColor) mutableCopy]; + [restyled appendAttributedString:RCTTestStyled(@"cd", UIColor.redColor)]; + + [self sendStateFromJS:restyled whileTyping:@"X"]; + XCTAssertEqualObjects(_input.attributedText.string, @"XYd"); + XCTAssertEqual(self.caret, 2); + [self typeKeys:@"Z"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"XYZd"); +} + +- (void)testAKeyDeliveredInsideTheRestoreOfABackspaceIsKept +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abcd"]; + _input.keysDuringNextReplace = @"e"; + + [self sendStateFromJS:RCTTestStyled(@"abcd", UIColor.blueColor) whileTyping:@"\b"]; + [self typeKeys:@"f"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abcef"); +} + +- (void)testABackspaceDeliveredInsideTheRestoreIsDropped +{ + // "d" is typed while JS restyles the text, and a backspace arrives inside the edit that puts "d" back. Applied, it + // would delete "c" (the caret is after the range that edit replaces), and the edit would then insert past the end. + // It is dropped: one backspace is lost; the other text and the caret stay right. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + _input.keysDuringNextReplace = @"\b"; + + [self sendStateFromJS:RCTTestStyled(@"abc", UIColor.blueColor) whileTyping:@"d"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abcd"); + XCTAssertEqual(self.caret, 4); +} + +- (void)testABackspaceDeliveredInsideTheRestoreOfABackspaceIsDropped +{ + // In the middle of the text: applied, the second backspace would delete "c", and the edit that puts back the first + // one would then delete "d". + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abcde"]; + [self moveCaretTo:3]; + _input.keysDuringNextReplace = @"\b"; + + [self sendStateFromJS:RCTTestStyled(@"abcde", UIColor.blueColor) whileTyping:@"\b"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abde"); + XCTAssertEqual(self.caret, 2); +} + +#pragma mark - IME composition (marked text) + +- (void)testMarkedTextStartedDuringAWriteIsNotCommittedAsPlainText +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + _input.markedTextDuringNextWrite = @"\u306B"; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"]]; + + // The write ended the composition; JS's text is left as it is, without the composed text as plain text. + XCTAssertEqualObjects(_input.attributedText.string, @"ABC"); + XCTAssertNil(_input.markedTextRange); +} + +- (void)testMarkedTextThatArrivesAfterAWriteIsLeftComposing +{ + [self makeInputMultiline:YES maxLength:0]; + [self typeKeys:@"abc"]; + _input.markedTextDuringNextWrite = @"\u306B"; + _input.keysArriveAfterAssignment = YES; + + NSNumber *eventCount = [_view valueForKey:@"_mostRecentEventCount"]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"]]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABC\u306B"); + XCTAssertNotNil(_input.markedTextRange); + XCTAssertEqual(_input.writeCount, 1u); + // Nothing is put back or reported while the user composes. + XCTAssertEqualObjects([_view valueForKey:@"_mostRecentEventCount"], eventCount); +} + +@end + +#endif diff --git a/packages/react-native/React/Tests/Text/RCTTextInputUtilsTest.mm b/packages/react-native/React/Tests/Text/RCTTextInputUtilsTest.mm index 0adb9424c4a..0024d252710 100644 --- a/packages/react-native/React/Tests/Text/RCTTextInputUtilsTest.mm +++ b/packages/react-native/React/Tests/Text/RCTTextInputUtilsTest.mm @@ -13,6 +13,35 @@ @interface RCTTextInputUtilsTest : XCTestCase @end +// Random short text over characters that make composed sequences: emoji, skin tones, flags, combining marks, ZWJ. +static NSString *RCTTestRandomText(uint32_t *seed, NSUInteger maxCharacters) +{ + NSArray *alphabet = @[ + @"a", + @"b", + @"a", + @"b", + @"c", + @"\U0001F44D", + @"\U0001F3FD", + @"\U0001F1EF", + @"\U0001F1F5", + @"\u00E9", + @"\u0301", + @"\u200D", + @"\U0001F468", + @"\u05E9", + @" ", + @"\n", + ]; + NSMutableString *text = [NSMutableString new]; + NSUInteger count = rand_r(seed) % maxCharacters; + for (NSUInteger i = 0; i < count; i++) { + [text appendString:alphabet[rand_r(seed) % alphabet.count]]; + } + return text; +} + @implementation RCTTextInputUtilsTest - (void)testCopyBackedTextInputPreservesTintColor @@ -37,4 +66,248 @@ - (void)testCopyBackedTextInputPreservesReturnKeyType XCTAssertEqual(destination.returnKeyType, UIReturnKeyDone); } +#pragma mark - RCTTextInputChangedRange + +- (void)testChangedRangeOfAnInsertion +{ + // "p" typed, then "ri" delivered during a write: inserted at 1, nothing replaced. + NSRange range = RCTTextInputChangedRange(@"p", @"pri"); + XCTAssertEqual(range.location, 1u); + XCTAssertEqual(range.length, 0u); +} + +- (void)testChangedRangeOfAnInsertionInTheMiddle +{ + NSRange range = RCTTextInputChangedRange(@"Hello World", @"Hello #tag World"); + XCTAssertEqual(range.location, 6u); + XCTAssertEqual(range.length, 0u); +} + +- (void)testChangedRangeOfAReplacement +{ + NSRange range = RCTTextInputChangedRange(@"abcdef", @"abXYef"); + XCTAssertEqual(range.location, 2u); + XCTAssertEqual(range.length, 2u); +} + +- (void)testChangedRangeOfADeletion +{ + NSRange range = RCTTextInputChangedRange(@"abcdef", @"abef"); + XCTAssertEqual(range.location, 2u); + XCTAssertEqual(range.length, 2u); +} + +- (void)testChangedRangeOfARepeatedCharacter +{ + NSRange range = RCTTextInputChangedRange(@"aa", @"aaa"); + XCTAssertEqual(range.length, 0u); + XCTAssertEqual(range.location, 2u); +} + +- (void)testChangedRangeDoesNotSplitAnEmoji +{ + // 👨 and 👩 share their high surrogate. + NSRange range = RCTTextInputChangedRange(@"a\U0001F468b", @"a\U0001F469b"); + XCTAssertEqual(range.location, 1u); + XCTAssertEqual(range.length, 2u); +} + +- (void)testChangedRangeKeepsACombiningMarkWithItsLetter +{ + // A combining acute accent typed after "e": the edit replaces "e" with "e\u0301". + NSRange range = RCTTextInputChangedRange(@"cafe", @"cafe\u0301"); + XCTAssertEqual(range.location, 3u); + XCTAssertEqual(range.length, 1u); +} + +- (void)testChangedRangeOfAFlagAndASkinTone +{ + NSRange flag = RCTTextInputChangedRange(@"ab", @"a\U0001F1EF\U0001F1F5b"); + XCTAssertEqual(flag.location, 1u); + XCTAssertEqual(flag.length, 0u); + // A skin tone added to 👍 changes that emoji as a whole. + NSRange tone = RCTTextInputChangedRange(@"\U0001F44D", @"\U0001F44D\U0001F3FD"); + XCTAssertEqual(tone.location, 0u); + XCTAssertEqual(tone.length, 2u); +} + +#pragma mark - RCTTextInputTextToRestore + +- (void)testRestoresKeysTypedDuringAnAttributeOnlyWrite +{ + // "p" typed; while JS restyles it (a nested ), "ri" arrive and the write replaces them. + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore(@"p", @"pri", @"p", &range, &text)); + XCTAssertEqual(range.location, 1u); + XCTAssertEqual(range.length, 0u); + XCTAssertEqualObjects(text, @"ri"); +} + +- (void)testRestoresABackspaceTypedDuringAnAttributeOnlyWrite +{ + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore(@"abc", @"ab", @"abc", &range, &text)); + XCTAssertEqual(range.location, 2u); + XCTAssertEqual(range.length, 1u); + XCTAssertEqualObjects(text, @""); +} + +- (void)testRestoresAnInsertionMadeInTheMiddleDuringAnAttributeOnlyWrite +{ + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore(@"#a Hello", @"#ab Hello", @"#a Hello", &range, &text)); + XCTAssertEqual(range.location, 2u); + XCTAssertEqualObjects(text, @"b"); +} + +- (void)testRestoresKeysTypedDuringAnUpperCasingWrite +{ + // "abc" was typed; JS writes "ABC"; "d" arrived during the write. + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore(@"abc", @"abcd", @"ABC", &range, &text)); + XCTAssertEqual(range.location, 3u); + XCTAssertEqual(range.length, 0u); + XCTAssertEqualObjects(text, @"d"); +} + +- (void)testRestoresAnInsertionInTheMiddleAtTheSameDistanceFromTheEnd +{ + // The caret is before "F": "x" was typed, JS upper-cases it, "y" arrived during the write. + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore(@"ABCDExF", @"ABCDExyF", @"ABCDEXF", &range, &text)); + XCTAssertEqual(range.location, 6u); + XCTAssertEqualObjects(text, @"y"); +} + +- (void)testRestoresAnInsertionWhenJSShortensTheText +{ + // JS strips spaces: "a b" becomes "ab", "c" arrived during the write. + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore(@"a b", @"a bc", @"ab", &range, &text)); + XCTAssertEqual(range.location, 2u); + XCTAssertEqualObjects(text, @"c"); +} + +- (void)testDoesNotRestoreAReplacementWhenJSChangedTheCharacters +{ + NSRange range; + NSString *text; + XCTAssertFalse(RCTTextInputTextToRestore(@"abc", @"ab", @"ABC", &range, &text)); + XCTAssertFalse(RCTTextInputTextToRestore(@"abc", @"abc", @"ABC", &range, &text)); +} + +- (void)testRestoresAnEmojiWithoutSplittingJSText +{ + // An emoji typed after a flag; JS upper-cased "a" so the characters changed. + NSString *flag = @"\U0001F1EF\U0001F1F5"; + NSString *family = @"\U0001F468\u200D\U0001F469\u200D\U0001F467"; + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore( + [@"a" stringByAppendingString:flag], + [[@"a" stringByAppendingString:flag] stringByAppendingString:family], + [@"A" stringByAppendingString:flag], + &range, + &text)); + XCTAssertEqual(range.location, 1 + flag.length); + XCTAssertEqualObjects(text, family); +} + +- (void)testChangedRangeRebuildsTheNewTextFromRandomEdits +{ + uint32_t seed = 58904; + for (int i = 0; i < 20000; i++) { + NSString *oldText = RCTTestRandomText(&seed, 8); + NSString *newText = RCTTestRandomText(&seed, 8); + if (rand_r(&seed) % 2) { + // Mostly small edits of oldText. + NSMutableString *edited = [oldText mutableCopy]; + [edited insertString:RCTTestRandomText(&seed, 3) atIndex:edited.length ? rand_r(&seed) % (edited.length + 1) : 0]; + newText = edited; + } + NSRange range = RCTTextInputChangedRange(oldText, newText); + XCTAssertLessThanOrEqual(NSMaxRange(range), oldText.length); + NSInteger insertedLength = (NSInteger)newText.length - (NSInteger)oldText.length + (NSInteger)range.length; + XCTAssertGreaterThanOrEqual(insertedLength, 0); + NSString *inserted = [newText substringWithRange:NSMakeRange(range.location, insertedLength)]; + NSString *rebuilt = [oldText stringByReplacingCharactersInRange:range withString:inserted]; + if (![rebuilt isEqualToString:newText]) { + XCTFail(@"old=%@ new=%@ range=%@", oldText, newText, NSStringFromRange(range)); + return; + } + } +} + +- (void)testTextToRestoreAfterAnAttributeOnlyWriteRebuildsTheTypedText +{ + uint32_t seed = 4242; + for (int i = 0; i < 20000; i++) { + NSString *before = RCTTestRandomText(&seed, 8); + NSMutableString *typed = [before mutableCopy]; + NSUInteger at = typed.length ? rand_r(&seed) % (typed.length + 1) : 0; + // UIKit inserts keys on composed character boundaries. + if (at < typed.length) { + at = [typed rangeOfComposedCharacterSequenceAtIndex:at].location; + } + [typed insertString:RCTTestRandomText(&seed, 3) atIndex:at]; + NSRange range; + NSString *text = nil; + if (!RCTTextInputTextToRestore(before, typed, before, &range, &text)) { + XCTAssertEqualObjects(typed, before); + continue; + } + NSString *rebuilt = [before stringByReplacingCharactersInRange:range withString:text]; + if (![rebuilt isEqualToString:typed]) { + XCTFail(@"before=%@ typed=%@ range=%@ text=%@", before, typed, NSStringFromRange(range), text); + return; + } + } +} + +#pragma mark - RCTTextInputTextWithinLength + +- (void)testTextWithinLength +{ + XCTAssertEqualObjects(RCTTextInputTextWithinLength(@"xy", 5), @"xy"); + XCTAssertEqualObjects(RCTTextInputTextWithinLength(@"xy", 2), @"xy"); + XCTAssertEqualObjects(RCTTextInputTextWithinLength(@"xy", 1), @"x"); + XCTAssertNil(RCTTextInputTextWithinLength(@"xy", 0)); + XCTAssertNil(RCTTextInputTextWithinLength(@"xy", -3)); +} + +- (void)testTextWithinLengthDoesNotSplitAComposedCharacterSequence +{ + // 👍🏽 is 4 UTF-16 units. + NSString *thumb = @"\U0001F44D\U0001F3FD"; + XCTAssertNil(RCTTextInputTextWithinLength(thumb, 2)); + XCTAssertEqualObjects(RCTTextInputTextWithinLength([@"a" stringByAppendingString:thumb], 3), @"a"); + XCTAssertEqualObjects( + RCTTextInputTextWithinLength([@"a" stringByAppendingString:thumb], 5), [@"a" stringByAppendingString:thumb]); +} + +#pragma mark - RCTTextInputOffsetAfterEdit + +- (void)testOffsetAfterAnInsertion +{ + // "ri" inserted at 1. + XCTAssertEqual(RCTTextInputOffsetAfterEdit(0, NSMakeRange(1, 0), 2), 0); + XCTAssertEqual(RCTTextInputOffsetAfterEdit(1, NSMakeRange(1, 0), 2), 3); + XCTAssertEqual(RCTTextInputOffsetAfterEdit(4, NSMakeRange(1, 0), 2), 6); +} + +- (void)testOffsetAfterAReplacementAndADeletion +{ + XCTAssertEqual(RCTTextInputOffsetAfterEdit(3, NSMakeRange(2, 2), 1), 3); + XCTAssertEqual(RCTTextInputOffsetAfterEdit(5, NSMakeRange(2, 2), 1), 4); + XCTAssertEqual(RCTTextInputOffsetAfterEdit(2, NSMakeRange(2, 2), 1), 3); + XCTAssertEqual(RCTTextInputOffsetAfterEdit(1, NSMakeRange(2, 2), 1), 1); + XCTAssertEqual(RCTTextInputOffsetAfterEdit(3, NSMakeRange(2, 1), 0), 2); +} + @end diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h index 5af991e0bf1..14ad3f4cd7a 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h @@ -65,6 +65,18 @@ BOOL RCTIsAttributedStringEffectivelySame( NSDictionary *insensitiveAttributes, const facebook::react::TextAttributes &baseTextAttributes); +/* + * The same, where `singleLine` says the control is a single-line text field: it lays its line out with its own line + * break mode and writing direction and without a line height (UIKit sets them in the text it shows), so those are not + * compared. + */ +BOOL RCTIsAttributedStringEffectivelySame( + NSAttributedString *text1, + NSAttributedString *text2, + NSDictionary *insensitiveAttributes, + const facebook::react::TextAttributes &baseTextAttributes, + BOOL singleLine); + static inline NSData *RCTWrapEventEmitter(const std::shared_ptr &eventEmitter) { auto eventEmitterPtr = new std::weak_ptr(eventEmitter); diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 95ff5a0d180..7c73d99b3fb 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -524,7 +524,8 @@ AttributedStringBox RCTAttributedStringBoxFromNSAttributedString(NSAttributedStr static BOOL RCTIsParagraphStyleEffectivelySame( NSParagraphStyle *style1, NSParagraphStyle *style2, - const TextAttributes &baseTextAttributes) + const TextAttributes &baseTextAttributes, + BOOL singleLine) { if (style1 == nil || style2 == nil) { return style1 == nil && style2 == nil; @@ -567,6 +568,17 @@ static BOOL RCTIsParagraphStyleEffectivelySame( } } + // A single-line text field lays its line out with its own line break mode and writing direction and without a line + // height (UIKit sets them in the text it shows): those are not compared. + if (singleLine) { + NSMutableParagraphStyle *mutableStyle2 = [style2 mutableCopy]; + style2 = mutableStyle2; + mutableStyle2.lineBreakMode = style1.lineBreakMode; + mutableStyle2.baseWritingDirection = style1.baseWritingDirection; + mutableStyle2.minimumLineHeight = style1.minimumLineHeight; + mutableStyle2.maximumLineHeight = style1.maximumLineHeight; + } + return [style1 isEqual:style2]; } @@ -575,14 +587,15 @@ static BOOL RCTIsAttributeEffectivelySame( NSDictionary *attributes1, NSDictionary *attributes2, NSDictionary *insensitiveAttributes, - const TextAttributes &baseTextAttributes) + const TextAttributes &baseTextAttributes, + BOOL singleLine) { id attribute1 = attributes1[attributeKey] ?: insensitiveAttributes[attributeKey]; id attribute2 = attributes2[attributeKey] ?: insensitiveAttributes[attributeKey]; // Normalize attributes which can inexact but still effectively the same if ([attributeKey isEqualToString:NSParagraphStyleAttributeName]) { - return RCTIsParagraphStyleEffectivelySame(attribute1, attribute2, baseTextAttributes); + return RCTIsParagraphStyleEffectivelySame(attribute1, attribute2, baseTextAttributes, singleLine); } // Otherwise rely on built-in comparison @@ -593,7 +606,8 @@ BOOL RCTIsAttributedStringEffectivelySame( NSAttributedString *text1, NSAttributedString *text2, NSDictionary *insensitiveAttributes, - const TextAttributes &baseTextAttributes) + const TextAttributes &baseTextAttributes, + BOOL singleLine) { if (![text1.string isEqualToString:text2.string]) { return NO; @@ -630,7 +644,8 @@ BOOL RCTIsAttributedStringEffectivelySame( text1Attributes, text2Attributes, insensitiveAttributes, - baseTextAttributes)) { + baseTextAttributes, + singleLine)) { areAttributesSame = NO; *text1Stop = YES; *text2Stop = YES; @@ -652,7 +667,8 @@ BOOL RCTIsAttributedStringEffectivelySame( text1Attributes, text2Attributes, insensitiveAttributes, - baseTextAttributes)) { + baseTextAttributes, + singleLine)) { areAttributesSame = NO; *text1Stop = YES; *text2Stop = YES; @@ -664,3 +680,12 @@ BOOL RCTIsAttributedStringEffectivelySame( return areAttributesSame; } + +BOOL RCTIsAttributedStringEffectivelySame( + NSAttributedString *text1, + NSAttributedString *text2, + NSDictionary *insensitiveAttributes, + const TextAttributes &baseTextAttributes) +{ + return RCTIsAttributedStringEffectivelySame(text1, text2, insensitiveAttributes, baseTextAttributes, NO); +} diff --git a/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js b/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js index a6bcda4ec18..4db93013cde 100644 --- a/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js +++ b/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js @@ -343,8 +343,138 @@ const styles = StyleSheet.create({ textAlign: 'right', width: 24, }, + typingInput: { + backgroundColor: '#ffffff', + borderColor: '#cccccc', + borderWidth: 1, + color: '#1c1c1e', + fontSize: 16, + height: 36, + marginBottom: 6, + paddingHorizontal: 6, + }, + typingLineHeight: { + lineHeight: 22, + }, + typingMultiline: { + height: 60, + }, + typingNested: { + color: '#0a63d6', + fontWeight: '600', + }, }); +// Type quickly into each empty field: every key must land, including the ones +// typed while the field's text makes a round trip through React state +// (react/react-native#58904). JS's copy of each value is shown below. In the +// multiline field, type "abc" then press "Append": it must read "abc def" +// once. The formatted field turns digits into "12-34" and never exceeds 5 +// characters. +function TypingRoundTripExample(): React.Node { + const [uncontrolled, setUncontrolled] = React.useState(''); + const [controlled, setControlled] = React.useState(''); + const [lineHeight, setLineHeight] = React.useState(''); + const [nested, setNested] = React.useState(''); + const [nestedMultiline, setNestedMultiline] = React.useState(''); + const [uppercase, setUppercase] = React.useState(''); + const [stripped, setStripped] = React.useState(''); + const [multiline, setMultiline] = React.useState(''); + const [formatted, setFormatted] = React.useState(''); + const common = { + autoCapitalize: 'none' as const, + autoCorrect: false, + style: styles.typingInput, + }; + return ( + + + + + + {nested} + + + {nestedMultiline} + + setUppercase(text.toUpperCase())} + /> + setStripped(text.replace(/\//g, ''))} + /> + +