@@ -103,6 +103,10 @@ @implementation RCTTextInputComponentView {
103103
104104 BOOL _hasInputAccessoryView;
105105 CGSize _previousContentSize;
106+ #if TARGET_OS_OSX // [macOS
107+ NSString *_ghostText;
108+ NSInteger _ghostTextPosition;
109+ #endif // macOS]
106110}
107111
108112#pragma mark - UIView overrides
@@ -514,6 +518,10 @@ - (void)prepareForRecycle
514518 _lastStringStateWasUpdatedWith = nil ;
515519 _ignoreNextTextInputCall = NO ;
516520 _didMoveToWindow = NO ;
521+ #if TARGET_OS_OSX // [macOS
522+ _ghostText = nil ;
523+ _ghostTextPosition = 0 ;
524+ #endif // macOS]
517525 [_backedTextInputView resignFirstResponder ];
518526}
519527
@@ -538,6 +546,10 @@ - (BOOL)textInputShouldEndEditing
538546
539547- (void )textInputDidEndEditing
540548{
549+ #if TARGET_OS_OSX // [macOS
550+ [self setGhostText: nil ];
551+ #endif // macOS]
552+
541553 if (_eventEmitter) {
542554 static_cast <const TextInputEventEmitter &>(*_eventEmitter).onEndEditing ([self _textInputMetrics ]);
543555 static_cast <const TextInputEventEmitter &>(*_eventEmitter).onBlur ([self _textInputMetrics ]);
@@ -572,6 +584,12 @@ - (void)textInputDidReturn
572584
573585- (NSString *)textInputShouldChangeText : (NSString *)text inRange : (NSRange )range
574586{
587+ #if TARGET_OS_OSX // [macOS
588+ // Clear ghost text before the text change so the undo manager's snapshot
589+ // of the pre-edit state never contains ghost text.
590+ [self setGhostText: nil ];
591+ #endif // macOS]
592+
575593 const auto &props = static_cast <const TextInputProps &>(*_props);
576594
577595 if (!_backedTextInputView.textWasPasted ) {
@@ -617,6 +635,17 @@ - (void)textInputDidChange
617635 return ;
618636 }
619637
638+ #if TARGET_OS_OSX // [macOS
639+ if (_ghostText != nil ) {
640+ NSAttributedString *attributedStringWithoutGhostText = [self removingGhostTextFromString: _backedTextInputView.attributedText strict: NO ];
641+ if (attributedStringWithoutGhostText != nil && ![attributedStringWithoutGhostText isEqual: _backedTextInputView.attributedText]) {
642+ _backedTextInputView.attributedText = attributedStringWithoutGhostText;
643+ }
644+ _ghostText = nil ;
645+ _ghostTextPosition = 0 ;
646+ }
647+ #endif // macOS]
648+
620649 if (_ignoreNextTextInputCall && [_lastStringStateWasUpdatedWith isEqual: _backedTextInputView.attributedText]) {
621650 _ignoreNextTextInputCall = NO ;
622651 return ;
@@ -632,6 +661,14 @@ - (void)textInputDidChange
632661
633662- (void )textInputDidChangeSelection
634663{
664+ #if TARGET_OS_OSX // [macOS
665+ // Clear ghost text on any user selection change, matching Paper behavior.
666+ // This prevents the user from selecting ghost text.
667+ if (_ghostText != nil && !_comingFromJS && !_backedTextInputView.ghostTextChanging ) {
668+ [self setGhostText: nil ];
669+ }
670+ #endif // macOS]
671+
635672 if (_comingFromJS) {
636673 return ;
637674 }
@@ -805,6 +842,115 @@ - (void)scrollViewDidScroll:(RCTUIScrollView *)scrollView // [macOS]
805842 }
806843}
807844
845+ #if TARGET_OS_OSX // [macOS
846+ #pragma mark - Ghost Text
847+
848+ - (NSDictionary <NSAttributedStringKey, id> *)ghostTextAttributes
849+ {
850+ NSMutableDictionary <NSAttributedStringKey , id > *textAttributes =
851+ [_backedTextInputView.defaultTextAttributes mutableCopy ] ?: [NSMutableDictionary new ];
852+
853+ [textAttributes setValue: _backedTextInputView.placeholderColor ?: [RCTPlatformColor placeholderTextColor ]
854+ forKey: NSForegroundColorAttributeName ];
855+
856+ return textAttributes;
857+ }
858+
859+ - (void )setGhostText : (NSString *)ghostText
860+ {
861+ NSRange selectedRange = [_backedTextInputView selectedTextRange ];
862+ NSInteger selectionStart = selectedRange.location ;
863+ NSInteger selectionEnd = selectedRange.location + selectedRange.length ;
864+ NSString *newGhostText = ghostText.length > 0 ? ghostText : nil ;
865+
866+ if (selectionStart != selectionEnd) {
867+ newGhostText = nil ;
868+ }
869+
870+ if ((_ghostText == nil && newGhostText == nil ) || [_ghostText isEqual: newGhostText]) {
871+ return ;
872+ }
873+
874+ if (_backedTextInputView.ghostTextChanging ) {
875+ // look out for nested callbacks -- this can happen for example when selection changes in response to
876+ // attributed text changing. Such callbacks are initiated by Apple, or we could suppress this other ways.
877+ return ;
878+ }
879+
880+ _backedTextInputView.ghostTextChanging = YES ;
881+
882+ if (_ghostText != nil ) {
883+ // When setGhostText: is called after making a standard edit, the ghost text may already be gone
884+ BOOL ghostTextMayAlreadyBeGone = newGhostText == nil ;
885+ NSAttributedString *attributedStringWithoutGhostText = [self removingGhostTextFromString: _backedTextInputView.attributedText strict: !ghostTextMayAlreadyBeGone];
886+
887+ if (attributedStringWithoutGhostText != nil ) {
888+ _backedTextInputView.attributedText = attributedStringWithoutGhostText;
889+ [_backedTextInputView setSelectedTextRange: NSMakeRange (selectionStart, selectionEnd - selectionStart) notifyDelegate: NO ];
890+ }
891+ }
892+
893+ _ghostText = [newGhostText copy ];
894+ _ghostTextPosition = selectionStart;
895+
896+ if (_ghostText != nil ) {
897+ NSMutableAttributedString *attributedString = [_backedTextInputView.attributedText mutableCopy ];
898+ NSAttributedString *ghostAttributedString = [[NSAttributedString alloc ] initWithString: _ghostText
899+ attributes: self .ghostTextAttributes];
900+
901+ [attributedString insertAttributedString: ghostAttributedString atIndex: _ghostTextPosition];
902+ _backedTextInputView.attributedText = attributedString;
903+ [_backedTextInputView setSelectedTextRange: NSMakeRange (_ghostTextPosition, 0 ) notifyDelegate: NO ];
904+ }
905+
906+ _backedTextInputView.ghostTextChanging = NO ;
907+ }
908+
909+ /* *
910+ * Attempts to remove the ghost text from a provided string given our current state.
911+ *
912+ * If `strict` mode is enabled, this method assumes the ghost text exists exactly
913+ * where we expect it to be. We assert and return `nil` if we don't find the expected ghost text.
914+ * It's the responsibility of the caller to make sure the result isn't `nil`.
915+ *
916+ * If disabled, we allow for the possibility that the ghost text has already been removed,
917+ * which can happen if a delegate callback is trying to remove ghost text after invoking `setAttributedText:`.
918+ */
919+ - (NSAttributedString *)removingGhostTextFromString : (NSAttributedString *)string strict : (BOOL )strict
920+ {
921+ if (_ghostText == nil ) {
922+ return string;
923+ }
924+
925+ NSRange ghostTextRange = NSMakeRange (_ghostTextPosition, _ghostText.length );
926+ NSMutableAttributedString *attributedString = [string mutableCopy ];
927+
928+ if ([attributedString length ] < NSMaxRange (ghostTextRange)) {
929+ if (strict) {
930+ RCTAssert (false , @" Ghost text not fully present in text view text" );
931+ return nil ;
932+ } else {
933+ return string;
934+ }
935+ }
936+
937+ NSString *actualGhostText = [[attributedString attributedSubstringFromRange: ghostTextRange] string ];
938+
939+ if (![actualGhostText isEqual: _ghostText]) {
940+ if (strict) {
941+ RCTAssert (false , @" Ghost text does not match text view text" );
942+ return nil ;
943+ } else {
944+ return string;
945+ }
946+ }
947+
948+ [attributedString deleteCharactersInRange: ghostTextRange];
949+ return attributedString;
950+ }
951+
952+ #endif // macOS]
953+
808954#pragma mark - Native Commands
809955
810956- (void )handleCommand : (const NSString *)commandName args : (const NSArray *)args
@@ -842,7 +988,13 @@ - (void)blur
842988 [_backedTextInputView resignFirstResponder ];
843989#else // [macOS
844990 NSWindow *window = [_backedTextInputView window ];
845- if ([window firstResponder ] == _backedTextInputView.responder ) {
991+ // On macOS, when an NSTextField is focused, the window's firstResponder is the
992+ // field editor (an NSTextView), not the text field itself. Check currentEditor
993+ // to determine if the text field is actively being edited.
994+ if ([_backedTextInputView isKindOfClass: [NSTextField class ]] &&
995+ [(NSTextField *)_backedTextInputView currentEditor ] != nil ) {
996+ [window makeFirstResponder: nil ];
997+ } else if ([window firstResponder ] == _backedTextInputView.responder ) {
846998 [window makeFirstResponder: nil ];
847999 }
8481000#endif // macOS]
@@ -881,7 +1033,7 @@ - (void)setTextAndSelection:(NSInteger)eventCount
8811033#else // [macOS
8821034 NSInteger startPosition = MIN (start, end);
8831035 NSInteger endPosition = MAX (start, end);
884- [_backedTextInputView setSelectedTextRange: NSMakeRange (startPosition, endPosition - startPosition) notifyDelegate: YES ];
1036+ [_backedTextInputView setSelectedTextRange: NSMakeRange (startPosition, endPosition - startPosition) notifyDelegate: NO ];
8851037#endif // macOS]
8861038 _comingFromJS = NO ;
8871039}
0 commit comments