diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-test.js b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-test.js index a33757e1e15a..0a5fce1e640c 100644 --- a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-test.js +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-test.js @@ -65,6 +65,10 @@ jest.unmock('../TextInput'); expect(inputElement.isFocused).toBeInstanceOf(Function); // Would have prevented S168585 expect(inputElement.clear).toBeInstanceOf(Function); + // [macOS + expect(inputElement.setSelection).toBeInstanceOf(Function); + expect(inputElement.setGhostText).toBeInstanceOf(Function); + // macOS] // $FlowFixMe[method-unbinding] expect(inputElement.focus).toBeInstanceOf(jest.fn().constructor); // $FlowFixMe[method-unbinding] diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index 3ddc6016b5de..6a4040366fed 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -328,10 +328,19 @@ - (void)setAttributedText:(NSAttributedString *)attributedText #if !TARGET_OS_OSX // [macOS] [super setAttributedText:attributedText]; #else // [macOS - // Break undo coalescing when the text is changed by JS (e.g. autocomplete). - [self breakUndoCoalescing]; - // Avoid Exception thrown while executing UI block: *** -[NSBigMutableString replaceCharactersInRange:withString:]: nil argument - [self.textStorage setAttributedString:attributedText ?: [NSAttributedString new]]; + if (self.ghostTextChanging) { + // Ghost text changes should not be on the undo stack. Disable undo + // registration around the text storage mutation so Cmd+Z skips over + // ghost text insertions/removals. + [self.undoManager disableUndoRegistration]; + [self.textStorage setAttributedString:attributedText ?: [NSAttributedString new]]; + [self.undoManager enableUndoRegistration]; + } else { + // Break undo coalescing when the text is changed by JS (e.g. autocomplete). + [self breakUndoCoalescing]; + // Avoid Exception thrown while executing UI block: *** -[NSBigMutableString replaceCharactersInRange:withString:]: nil argument + [self.textStorage setAttributedString:attributedText ?: [NSAttributedString new]]; + } #endif // macOS] [self textDidChange]; } 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 c4bfeb1b9f9a..5613469eb3d0 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -103,6 +103,10 @@ @implementation RCTTextInputComponentView { BOOL _hasInputAccessoryView; CGSize _previousContentSize; +#if TARGET_OS_OSX // [macOS + NSString *_ghostText; + NSInteger _ghostTextPosition; +#endif // macOS] } #pragma mark - UIView overrides @@ -514,6 +518,10 @@ - (void)prepareForRecycle _lastStringStateWasUpdatedWith = nil; _ignoreNextTextInputCall = NO; _didMoveToWindow = NO; +#if TARGET_OS_OSX // [macOS + _ghostText = nil; + _ghostTextPosition = 0; +#endif // macOS] [_backedTextInputView resignFirstResponder]; } @@ -538,6 +546,10 @@ - (BOOL)textInputShouldEndEditing - (void)textInputDidEndEditing { +#if TARGET_OS_OSX // [macOS + [self setGhostText:nil]; +#endif // macOS] + if (_eventEmitter) { static_cast(*_eventEmitter).onEndEditing([self _textInputMetrics]); static_cast(*_eventEmitter).onBlur([self _textInputMetrics]); @@ -572,6 +584,12 @@ - (void)textInputDidReturn - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range { +#if TARGET_OS_OSX // [macOS + // Clear ghost text before the text change so the undo manager's snapshot + // of the pre-edit state never contains ghost text. + [self setGhostText:nil]; +#endif // macOS] + const auto &props = static_cast(*_props); if (!_backedTextInputView.textWasPasted) { @@ -617,6 +635,17 @@ - (void)textInputDidChange return; } +#if TARGET_OS_OSX // [macOS + if (_ghostText != nil) { + NSAttributedString *attributedStringWithoutGhostText = [self removingGhostTextFromString:_backedTextInputView.attributedText strict:NO]; + if (attributedStringWithoutGhostText != nil && ![attributedStringWithoutGhostText isEqual:_backedTextInputView.attributedText]) { + _backedTextInputView.attributedText = attributedStringWithoutGhostText; + } + _ghostText = nil; + _ghostTextPosition = 0; + } +#endif // macOS] + if (_ignoreNextTextInputCall && [_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) { _ignoreNextTextInputCall = NO; return; @@ -632,6 +661,14 @@ - (void)textInputDidChange - (void)textInputDidChangeSelection { +#if TARGET_OS_OSX // [macOS + // Clear ghost text on any user selection change, matching Paper behavior. + // This prevents the user from selecting ghost text. + if (_ghostText != nil && !_comingFromJS && !_backedTextInputView.ghostTextChanging) { + [self setGhostText:nil]; + } +#endif // macOS] + if (_comingFromJS) { return; } @@ -805,6 +842,115 @@ - (void)scrollViewDidScroll:(RCTUIScrollView *)scrollView // [macOS] } } +#if TARGET_OS_OSX // [macOS +#pragma mark - Ghost Text + +- (NSDictionary *)ghostTextAttributes +{ + NSMutableDictionary *textAttributes = + [_backedTextInputView.defaultTextAttributes mutableCopy] ?: [NSMutableDictionary new]; + + [textAttributes setValue:_backedTextInputView.placeholderColor ?: [RCTPlatformColor placeholderTextColor] + forKey:NSForegroundColorAttributeName]; + + return textAttributes; +} + +- (void)setGhostText:(NSString *)ghostText +{ + NSRange selectedRange = [_backedTextInputView selectedTextRange]; + NSInteger selectionStart = selectedRange.location; + NSInteger selectionEnd = selectedRange.location + selectedRange.length; + NSString *newGhostText = ghostText.length > 0 ? ghostText : nil; + + if (selectionStart != selectionEnd) { + newGhostText = nil; + } + + if ((_ghostText == nil && newGhostText == nil) || [_ghostText isEqual:newGhostText]) { + return; + } + + if (_backedTextInputView.ghostTextChanging) { + // look out for nested callbacks -- this can happen for example when selection changes in response to + // attributed text changing. Such callbacks are initiated by Apple, or we could suppress this other ways. + return; + } + + _backedTextInputView.ghostTextChanging = YES; + + if (_ghostText != nil) { + // When setGhostText: is called after making a standard edit, the ghost text may already be gone + BOOL ghostTextMayAlreadyBeGone = newGhostText == nil; + NSAttributedString *attributedStringWithoutGhostText = [self removingGhostTextFromString:_backedTextInputView.attributedText strict:!ghostTextMayAlreadyBeGone]; + + if (attributedStringWithoutGhostText != nil) { + _backedTextInputView.attributedText = attributedStringWithoutGhostText; + [_backedTextInputView setSelectedTextRange:NSMakeRange(selectionStart, selectionEnd - selectionStart) notifyDelegate:NO]; + } + } + + _ghostText = [newGhostText copy]; + _ghostTextPosition = selectionStart; + + if (_ghostText != nil) { + NSMutableAttributedString *attributedString = [_backedTextInputView.attributedText mutableCopy]; + NSAttributedString *ghostAttributedString = [[NSAttributedString alloc] initWithString:_ghostText + attributes:self.ghostTextAttributes]; + + [attributedString insertAttributedString:ghostAttributedString atIndex:_ghostTextPosition]; + _backedTextInputView.attributedText = attributedString; + [_backedTextInputView setSelectedTextRange:NSMakeRange(_ghostTextPosition, 0) notifyDelegate:NO]; + } + + _backedTextInputView.ghostTextChanging = NO; +} + +/** + * Attempts to remove the ghost text from a provided string given our current state. + * + * If `strict` mode is enabled, this method assumes the ghost text exists exactly + * where we expect it to be. We assert and return `nil` if we don't find the expected ghost text. + * It's the responsibility of the caller to make sure the result isn't `nil`. + * + * If disabled, we allow for the possibility that the ghost text has already been removed, + * which can happen if a delegate callback is trying to remove ghost text after invoking `setAttributedText:`. + */ +- (NSAttributedString *)removingGhostTextFromString:(NSAttributedString *)string strict:(BOOL)strict +{ + if (_ghostText == nil) { + return string; + } + + NSRange ghostTextRange = NSMakeRange(_ghostTextPosition, _ghostText.length); + NSMutableAttributedString *attributedString = [string mutableCopy]; + + if ([attributedString length] < NSMaxRange(ghostTextRange)) { + if (strict) { + RCTAssert(false, @"Ghost text not fully present in text view text"); + return nil; + } else { + return string; + } + } + + NSString *actualGhostText = [[attributedString attributedSubstringFromRange:ghostTextRange] string]; + + if (![actualGhostText isEqual:_ghostText]) { + if (strict) { + RCTAssert(false, @"Ghost text does not match text view text"); + return nil; + } else { + return string; + } + } + + [attributedString deleteCharactersInRange:ghostTextRange]; + return attributedString; +} + +#endif // macOS] + #pragma mark - Native Commands - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args @@ -842,7 +988,13 @@ - (void)blur [_backedTextInputView resignFirstResponder]; #else // [macOS NSWindow *window = [_backedTextInputView window]; - if ([window firstResponder] == _backedTextInputView.responder) { + // On macOS, when an NSTextField is focused, the window's firstResponder is the + // field editor (an NSTextView), not the text field itself. Check currentEditor + // to determine if the text field is actively being edited. + if ([_backedTextInputView isKindOfClass:[NSTextField class]] && + [(NSTextField *)_backedTextInputView currentEditor] != nil) { + [window makeFirstResponder:nil]; + } else if ([window firstResponder] == _backedTextInputView.responder) { [window makeFirstResponder:nil]; } #endif // macOS] @@ -881,7 +1033,7 @@ - (void)setTextAndSelection:(NSInteger)eventCount #else // [macOS NSInteger startPosition = MIN(start, end); NSInteger endPosition = MAX(start, end); - [_backedTextInputView setSelectedTextRange:NSMakeRange(startPosition, endPosition - startPosition) notifyDelegate:YES]; + [_backedTextInputView setSelectedTextRange:NSMakeRange(startPosition, endPosition - startPosition) notifyDelegate:NO]; #endif // macOS] _comingFromJS = NO; } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h index fe3376a573cc..196c6b8a3b6a 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h @@ -18,6 +18,9 @@ NS_ASSUME_NONNULL_BEGIN value:(NSString *__nullable)value start:(NSInteger)start end:(NSInteger)end; +#if TARGET_OS_OSX // [macOS +- (void)setGhostText:(NSString *__nullable)ghostText; +#endif // macOS] @end RCT_EXTERN inline void @@ -96,6 +99,23 @@ RCTTextInputHandleCommand(id componentView, const NSSt return; } +#if TARGET_OS_OSX // [macOS + if ([commandName isEqualToString:@"setGhostText"]) { +#if RCT_DEBUG + if ([args count] != 1) { + RCTLogError( + @"%@ command %@ received %d arguments, expected %d.", @"TextInput", commandName, (int)[args count], 1); + return; + } +#endif + + NSObject *arg0 = args[0]; + NSString *value = [arg0 isKindOfClass:[NSNull class]] ? nil : (NSString *)arg0; + [componentView setGhostText:value]; + return; + } +#endif // macOS] + #if RCT_DEBUG RCTLogError(@"%@ received command %@, which is not a supported command.", @"TextInput", commandName); #endif