Added rich text input support
Summary: public It is now possible to display and edit rich text inside a multiline `<textInput>` by nesting a `<Text>` node inside it. Note that this doesn't yet provide everything needed to build a full rich text editor (as there is no facility to capture or control the selected text range, or insert/remove text) but it does make it possible to apply token-based styling to text as the user types. See the 'Attributed text' example in the UIExplorer > TextInput demo for details. Reviewed By: javache Differential Revision: D2622493 fb-gh-sync-id: b6bc9a46005322c806934541966460edccb59e70
This commit is contained in:
parent
8dac41b7f0
commit
7779e06a7f
|
@ -120,6 +120,60 @@ class RewriteExample extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TokenizedTextExample extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {text: 'Hello #World'};
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
|
||||||
|
//define delimiter
|
||||||
|
let delimiter = /\s+/;
|
||||||
|
|
||||||
|
//split string
|
||||||
|
let _text = this.state.text;
|
||||||
|
let token, index, parts = [];
|
||||||
|
while (_text) {
|
||||||
|
delimiter.lastIndex = 0;
|
||||||
|
token = delimiter.exec(_text);
|
||||||
|
if (token === null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
index = token.index;
|
||||||
|
if (token[0].length === 0) {
|
||||||
|
index = 1;
|
||||||
|
}
|
||||||
|
parts.push(_text.substr(0, index));
|
||||||
|
parts.push(token[0]);
|
||||||
|
index = index + token[0].length;
|
||||||
|
_text = _text.slice(index);
|
||||||
|
}
|
||||||
|
parts.push(_text);
|
||||||
|
|
||||||
|
//highlight hashtags
|
||||||
|
parts = parts.map((text) => {
|
||||||
|
if (/^#/.test(text)) {
|
||||||
|
return <Text style={styles.hashtag}>{text}</Text>;
|
||||||
|
} else {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<TextInput
|
||||||
|
multiline={true}
|
||||||
|
style={styles.multiline}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
this.setState({text});
|
||||||
|
}}>
|
||||||
|
<Text>{parts}</Text>
|
||||||
|
</TextInput>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var BlurOnSubmitExample = React.createClass({
|
var BlurOnSubmitExample = React.createClass({
|
||||||
focusNextField(nextField) {
|
focusNextField(nextField) {
|
||||||
this.refs[nextField].focus()
|
this.refs[nextField].focus()
|
||||||
|
@ -232,6 +286,10 @@ var styles = StyleSheet.create({
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
width: 24,
|
width: 24,
|
||||||
},
|
},
|
||||||
|
hashtag: {
|
||||||
|
color: 'blue',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
exports.displayName = (undefined: ?string);
|
exports.displayName = (undefined: ?string);
|
||||||
|
@ -498,6 +556,12 @@ exports.examples = [
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Attributed text',
|
||||||
|
render: function() {
|
||||||
|
return <TokenizedTextExample />;
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Blur on submit',
|
title: 'Blur on submit',
|
||||||
render: function(): ReactElement { return <BlurOnSubmitExample />; },
|
render: function(): ReactElement { return <BlurOnSubmitExample />; },
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
#import "RCTConvert.h"
|
#import "RCTConvert.h"
|
||||||
#import "RCTEventDispatcher.h"
|
#import "RCTEventDispatcher.h"
|
||||||
|
#import "RCTText.h"
|
||||||
#import "RCTUtils.h"
|
#import "RCTUtils.h"
|
||||||
#import "UIView+React.h"
|
#import "UIView+React.h"
|
||||||
|
|
||||||
|
@ -38,6 +39,10 @@
|
||||||
UITextView *_placeholderView;
|
UITextView *_placeholderView;
|
||||||
UITextView *_textView;
|
UITextView *_textView;
|
||||||
NSInteger _nativeEventCount;
|
NSInteger _nativeEventCount;
|
||||||
|
RCTText *_richTextView;
|
||||||
|
NSAttributedString *_pendingAttributedText;
|
||||||
|
NSMutableArray<UIView<RCTComponent> *> *_subviews;
|
||||||
|
BOOL _blockTextShouldChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
|
- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
|
||||||
|
@ -53,6 +58,8 @@
|
||||||
_textView.backgroundColor = [UIColor clearColor];
|
_textView.backgroundColor = [UIColor clearColor];
|
||||||
_textView.scrollsToTop = NO;
|
_textView.scrollsToTop = NO;
|
||||||
_textView.delegate = self;
|
_textView.delegate = self;
|
||||||
|
|
||||||
|
_subviews = [NSMutableArray new];
|
||||||
[self addSubview:_textView];
|
[self addSubview:_textView];
|
||||||
}
|
}
|
||||||
return self;
|
return self;
|
||||||
|
@ -61,6 +68,90 @@
|
||||||
RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
|
RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
|
||||||
RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
|
RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
|
||||||
|
|
||||||
|
- (NSArray<UIView<RCTComponent> *> *)reactSubviews
|
||||||
|
{
|
||||||
|
return _subviews;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)insertReactSubview:(UIView<RCTComponent> *)subview atIndex:(NSInteger)index
|
||||||
|
{
|
||||||
|
if ([subview isKindOfClass:[RCTText class]]) {
|
||||||
|
if (_richTextView) {
|
||||||
|
RCTLogError(@"Tried to insert a second <Text> into <TextInput> - there can only be one.");
|
||||||
|
}
|
||||||
|
_richTextView = (RCTText *)subview;
|
||||||
|
[_subviews insertObject:_richTextView atIndex:index];
|
||||||
|
} else {
|
||||||
|
[_subviews insertObject:subview atIndex:index];
|
||||||
|
[self insertSubview:subview atIndex:index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)removeReactSubview:(UIView<RCTComponent> *)subview
|
||||||
|
{
|
||||||
|
if (_richTextView == subview) {
|
||||||
|
[_subviews removeObject:_richTextView];
|
||||||
|
_richTextView = nil;
|
||||||
|
} else {
|
||||||
|
[_subviews removeObject:subview];
|
||||||
|
[subview removeFromSuperview];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setMostRecentEventCount:(NSInteger)mostRecentEventCount
|
||||||
|
{
|
||||||
|
_mostRecentEventCount = mostRecentEventCount;
|
||||||
|
|
||||||
|
// Props are set after uiBlockToAmendWithShadowViewRegistry, which means that
|
||||||
|
// at the time performTextUpdate is called, _mostRecentEventCount will be
|
||||||
|
// behind _eventCount, with the result that performPendingTextUpdate will do
|
||||||
|
// nothing. For that reason we call it again here after mostRecentEventCount
|
||||||
|
// has been set.
|
||||||
|
[self performPendingTextUpdate];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)performTextUpdate
|
||||||
|
{
|
||||||
|
if (_richTextView) {
|
||||||
|
_pendingAttributedText = _richTextView.textStorage;
|
||||||
|
[self performPendingTextUpdate];
|
||||||
|
} else if (!self.text) {
|
||||||
|
_textView.attributedText = nil;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)performPendingTextUpdate
|
||||||
|
{
|
||||||
|
if (!_pendingAttributedText || _mostRecentEventCount < _nativeEventCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([_textView.attributedText isEqualToAttributedString:_pendingAttributedText]) {
|
||||||
|
_pendingAttributedText = nil; // Don't try again.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we update the attributed text, there might be pending autocorrections
|
||||||
|
// that will get accepted by default. In order for this to not garble our text,
|
||||||
|
// we temporarily block all textShouldChange events so they are not applied.
|
||||||
|
_blockTextShouldChange = YES;
|
||||||
|
|
||||||
|
// We compute the new selectedRange manually to make sure the cursor is at the
|
||||||
|
// end of the newly inserted/deleted text after update.
|
||||||
|
NSRange range = _textView.selectedRange;
|
||||||
|
CGPoint contentOffset = _textView.contentOffset;
|
||||||
|
|
||||||
|
_textView.attributedText = _pendingAttributedText;
|
||||||
|
_pendingAttributedText = nil;
|
||||||
|
_textView.selectedRange = range;
|
||||||
|
[_textView layoutIfNeeded];
|
||||||
|
_textView.contentOffset = contentOffset;
|
||||||
|
|
||||||
|
[self _setPlaceholderVisibility];
|
||||||
|
|
||||||
|
_blockTextShouldChange = NO;
|
||||||
|
}
|
||||||
|
|
||||||
- (void)updateFrames
|
- (void)updateFrames
|
||||||
{
|
{
|
||||||
// Adjust the insets so that they are as close as possible to single-line
|
// Adjust the insets so that they are as close as possible to single-line
|
||||||
|
@ -156,6 +247,10 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
|
||||||
|
|
||||||
- (BOOL)textView:(RCTUITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text
|
- (BOOL)textView:(RCTUITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text
|
||||||
{
|
{
|
||||||
|
if (_blockTextShouldChange) {
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
if (textView.textWasPasted) {
|
if (textView.textWasPasted) {
|
||||||
textView.textWasPasted = NO;
|
textView.textWasPasted = NO;
|
||||||
} else {
|
} else {
|
||||||
|
@ -309,9 +404,4 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
|
||||||
return [UIColor colorWithRed:0.0/255.0 green:0.0/255.0 blue:0.098/255.0 alpha:0.22];
|
return [UIColor colorWithRed:0.0/255.0 green:0.0/255.0 blue:0.098/255.0 alpha:0.22];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)performTextUpdate
|
|
||||||
{
|
|
||||||
// Not used (yet)
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
Loading…
Reference in New Issue