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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,12 @@ function InternalTextInput(props: TextInputProps): React.Node {

const setLocalRef = useCallback(
(instance: HostInstance | null) => {
if (__DEV__) {
console.log(
'[TextInput] setLocalRef called with:',
instance != null ? 'instance' : 'null',
);
}
// $FlowExpectedError[incompatible-type]
inputRef.current = instance;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ jest.unmock('../TextInput');

expect(inputElement.isFocused).toBeInstanceOf(Function); // Would have prevented S168585
expect(inputElement.clear).toBeInstanceOf(Function);
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 @@ -102,6 +102,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 @@ -477,6 +481,10 @@ - (void)prepareForRecycle
_lastStringStateWasUpdatedWith = nil;
_ignoreNextTextInputCall = NO;
_didMoveToWindow = NO;
#if TARGET_OS_OSX // [macOS
_ghostText = nil;
_ghostTextPosition = 0;
#endif // macOS]
[_backedTextInputView resignFirstResponder];
}

Expand Down Expand Up @@ -580,6 +588,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 Down Expand Up @@ -768,6 +787,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 @@ -844,7 +972,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
Loading