Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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];
}

Expand All @@ -538,6 +546,10 @@ - (BOOL)textInputShouldEndEditing

- (void)textInputDidEndEditing
{
#if TARGET_OS_OSX // [macOS
[self setGhostText:nil];
#endif // macOS]

if (_eventEmitter) {
static_cast<const TextInputEventEmitter &>(*_eventEmitter).onEndEditing([self _textInputMetrics]);
static_cast<const TextInputEventEmitter &>(*_eventEmitter).onBlur([self _textInputMetrics]);
Expand Down Expand Up @@ -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<const TextInputProps &>(*_props);

if (!_backedTextInputView.textWasPasted) {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -805,6 +842,115 @@ - (void)scrollViewDidScroll:(RCTUIScrollView *)scrollView // [macOS]
}
}

#if TARGET_OS_OSX // [macOS
#pragma mark - Ghost Text

- (NSDictionary<NSAttributedStringKey, id> *)ghostTextAttributes
{
NSMutableDictionary<NSAttributedStringKey, id> *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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -96,6 +99,23 @@ RCTTextInputHandleCommand(id<RCTTextInputViewProtocol> 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
Expand Down
Loading