[android, ios] Parse bold/italic/code in a text field.

This commit is contained in:
Igor Mandrigin 2019-06-13 10:39:05 +02:00
parent 43945c114e
commit 72d47ff50d
No known key found for this signature in database
GPG Key ID: 4A0EDDE26E66BC8B
7 changed files with 418 additions and 0 deletions

View File

@ -61,6 +61,9 @@ const viewConfig = {
maxFontSizeMultiplier: true,
disabled: true,
selectable: true,
parseBasicMarkdown: true,
markdownCodeBackgroundColor: true,
markdownCodeForegroundColor: true,
selectionColor: true,
adjustsFontSizeToFit: true,
minimumFontScale: true,
@ -136,6 +139,18 @@ class TouchableText extends React.Component<Props, State> {
selectionColor: processColor(props.selectionColor),
};
}
if (props.markdownCodeBackgroundColor != null) {
props = {
...props,
markdownCodeBackgroundColor: processColor(props.markdownCodeBackgroundColor),
};
}
if (props.markdownCodeForegroundColor != null) {
props = {
...props,
markdownCodeForegroundColor: processColor(props.markdownCodeForegroundColor),
};
}
if (__DEV__) {
if (Touchable.TOUCH_TARGET_DEBUG && props.onPress != null) {
props = {

View File

@ -20,6 +20,10 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, assign) BOOL adjustsFontSizeToFit;
@property (nonatomic, assign) CGFloat minimumFontScale;
@property (nonatomic, copy) RCTDirectEventBlock onTextLayout;
@property (nonatomic, assign) BOOL parseBasicMarkdown;
@property (atomic, copy) UIColor* markdownCodeBackgroundColor;
@property (atomic, copy) UIColor* markdownCodeForegroundColor;
- (void)uiManagerWillPerformMounting;

View File

@ -166,6 +166,190 @@
[attributedText addAttribute:NSBaselineOffsetAttributeName
value:@(baseLineOffset)
range:NSMakeRange(0, attributedText.length)];
if (!_parseBasicMarkdown) {
return;
}
UIFontDescriptorSymbolicTraits traitZones[attributedText.string.length];
for (int i = 0; i < attributedText.string.length; i++) {
traitZones[i] = 0;
}
[self parseMarkdownTag:@"```"
inText:attributedText
fontTraits:UIFontDescriptorTraitMonoSpace
fgColor:self.markdownCodeForegroundColor
bgColor:self.markdownCodeBackgroundColor
codeZones:traitZones];
[self parseMarkdownTag:@"`"
inText:attributedText
fontTraits:UIFontDescriptorTraitMonoSpace
fgColor:self.markdownCodeForegroundColor
bgColor:self.markdownCodeBackgroundColor
codeZones:traitZones];
[self parseMarkdownTag:@"*"
inText:attributedText
fontTraits:UIFontDescriptorTraitBold
fgColor:nil
bgColor:nil
codeZones:traitZones];
[self parseMarkdownTag:@"_"
inText:attributedText
fontTraits:UIFontDescriptorTraitItalic
fgColor:nil
bgColor:nil
codeZones:traitZones];
}
- (UIFont *)fontWithTraits:(UIFontDescriptorSymbolicTraits)traits fromFont:(UIFont *)font
{
if (traits == UIFontDescriptorTraitMonoSpace) {
return [UIFont fontWithName:@"Menlo" size:font.pointSize];
}
UIFontDescriptor *descriptor = [font.fontDescriptor fontDescriptorWithSymbolicTraits:font.fontDescriptor.symbolicTraits|traits];
if (!descriptor) {
return font;
}
return [UIFont fontWithDescriptor:descriptor size:font.pointSize];
}
-(bool)isWhitespaceOrControlCharacter:(unichar)character {
return character == ' ' ||
character == '\n' ||
character == [@"\u200B" characterAtIndex:0] ||
character == '_' ||
character == '*' ||
character == '`';
}
-(void)applyFontAttributeToString:(NSMutableAttributedString *)attributedText
inRange:(NSRange)range
fontTraits:(UIFontDescriptorSymbolicTraits)fontTraits
allTraits:(UIFontDescriptorSymbolicTraits *)allTraits {
UIFont *font = [attributedText attribute:NSFontAttributeName atIndex:range.location effectiveRange:nil];
if (!font) {
font = [UIFont systemFontOfSize:18];
}
UIFontDescriptorSymbolicTraits prevTraits = allTraits[range.location];
NSInteger rangeStart = range.location;
for (NSUInteger i = range.location; i < range.location + range.length; i++) {
UIFontDescriptorSymbolicTraits currentTraits = allTraits[i];
if (currentTraits != prevTraits) {
NSRange subrange = NSMakeRange(rangeStart, i - rangeStart);
[self applyTraitsToSubrangeOfString:attributedText
subrange:subrange
prevTraits:prevTraits
font:font
incomingTraits:fontTraits];
prevTraits = currentTraits;
rangeStart = i;
}
}
NSRange subrange = NSMakeRange(rangeStart, range.location + range.length - rangeStart);
[self applyTraitsToSubrangeOfString:attributedText
subrange:subrange
prevTraits:prevTraits
font:font
incomingTraits:fontTraits];
}
-(void)applyTraitsToSubrangeOfString:(NSMutableAttributedString *)attributedText
subrange:(NSRange)subrange
prevTraits:(UIFontDescriptorSymbolicTraits)prevTraits
font:(UIFont *)font
incomingTraits:(UIFontDescriptorSymbolicTraits)incomingTraits
{
// don't override monospace, ever
UIFontDescriptorSymbolicTraits traitsToApply =
prevTraits & UIFontDescriptorTraitMonoSpace ? prevTraits : prevTraits|incomingTraits;
font = [self fontWithTraits:traitsToApply fromFont:font];
[attributedText addAttribute:NSFontAttributeName value:font range:subrange];
}
-(void)parseMarkdownTag:(NSString *)tag
inText:(NSMutableAttributedString *)attributedText
fontTraits:(UIFontDescriptorSymbolicTraits)traits
fgColor:(UIColor *)fgColor
bgColor:(UIColor *)bgColor
codeZones:(UIFontDescriptorSymbolicTraits *)codeZones
{
NSInteger start = NSNotFound;
bool multilineCodeTag = [tag isEqualToString:@"```"];
for (NSInteger i = 0; i < attributedText.string.length - tag.length; i++) {
NSString *candidate = [attributedText.string substringWithRange:NSMakeRange(i, tag.length)];
bool isInCodeZone = codeZones[i] & UIFontDescriptorTraitMonoSpace;
if ([candidate isEqualToString:tag]) {
unichar nextCharacter = [attributedText.string characterAtIndex:i+tag.length];
bool followedByWhitespace = nextCharacter == ' ' || nextCharacter == '\n';
bool followedByControlCharacter = [self isWhitespaceOrControlCharacter:nextCharacter];
bool preceededByWhitespaceOrControlCharacter = i == 0 || [self isWhitespaceOrControlCharacter:[attributedText.string characterAtIndex:i - 1]];
if (start == NSNotFound && !isInCodeZone) {
if (attributedText.string.length - i > tag.length) {
if ((!followedByWhitespace || multilineCodeTag) && preceededByWhitespaceOrControlCharacter) {
start = i;
// the ``` tag needs to be outermost
if (multilineCodeTag) {
i += tag.length - 1;
}
}
}
} else if(start != NSNotFound && !isInCodeZone) {
if (i - start < 2) {
// multilineCodeTag should be outermost
if(!followedByWhitespace && !multilineCodeTag) {
start = i;
} else {
start = NSNotFound;
}
} else if(followedByWhitespace || (!multilineCodeTag && followedByControlCharacter)) {
NSRange range = NSMakeRange(start, i-start+tag.length);
// iOS doesn't support merging font traits natively, so we are doing it manually here
[self applyFontAttributeToString:attributedText inRange:range fontTraits:traits allTraits:codeZones];
if (fgColor) {
[attributedText addAttribute:NSForegroundColorAttributeName value:fgColor range:range];
}
if (bgColor) {
[attributedText addAttribute:NSBackgroundColorAttributeName value:bgColor range:range];
}
// replacing control characters with 0-width spaces to hide them
NSString *replacement = @"";
for (int j = 0; j < tag.length; j++) {
replacement = [replacement stringByAppendingString:@"\u200B"];
}
[attributedText replaceCharactersInRange:NSMakeRange(i, tag.length) withString:replacement];
[attributedText replaceCharactersInRange:NSMakeRange(start, tag.length) withString:replacement];
for (NSInteger j = start; j < i + tag.length; j++) {
codeZones[j] |= traits;
}
start = NSNotFound;
}
}
} else if ([candidate isEqualToString:@"\n"] && !multilineCodeTag) {
// resetting tags on line breaks (except ```)
start = NSNotFound;
}
}
}
- (NSAttributedString *)attributedTextWithMeasuredAttachmentsThatFitSize:(CGSize)size

View File

@ -37,6 +37,11 @@ RCT_EXPORT_SHADOW_PROPERTY(onTextLayout, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(selectable, BOOL)
RCT_EXPORT_SHADOW_PROPERTY(parseBasicMarkdown, BOOL)
RCT_EXPORT_SHADOW_PROPERTY(markdownCodeBackgroundColor, UIColor)
RCT_EXPORT_SHADOW_PROPERTY(markdownCodeForegroundColor, UIColor)
- (void)setBridge:(RCTBridge *)bridge
{
[super setBridge:bridge];

View File

@ -132,4 +132,9 @@ module.exports = {
* See https://facebook.github.io/react-native/docs/text.html#disabled
*/
disabled: PropTypes.bool,
parseBasicMarkdown: PropTypes.bool,
markdownCodeBackgroundColor: DeprecatedColorPropType,
markdownCodeForegroundColor: DeprecatedColorPropType,
};

View File

@ -183,4 +183,9 @@ export type TextProps = $ReadOnly<{|
* See https://facebook.github.io/react-native/docs/text.html#supperhighlighting
*/
suppressHighlighting?: ?boolean,
parseBasicMarkdown?: ?boolean,
markdownCodeBackgroundColor?: ?string,
markdownCodeForegroundColor?: ?string,
|}>;

View File

@ -41,6 +41,11 @@ import javax.annotation.Nullable;
@TargetApi(Build.VERSION_CODES.M)
public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
// Android 6.x doesn't have `java.util.Supplier` so we provide one on our own.
interface Supplier<T> {
public T get();
}
private static final String INLINE_IMAGE_PLACEHOLDER = "I";
public static final int UNSET = -1;
@ -120,6 +125,13 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
}
int end = sb.length();
if (end >= start) {
// Markdown parsing has to be done before setting the font size
// because it uses `ReactAbsoluteTextSpan` to hide formatting characters.
// if text size is set to the whole string before that, it will
// override size for these hidden characters and they will be displayed.
// and colors.
addMarkdownParsing(textShadowNode, ops, sb);
if (textShadowNode.mIsColorSet) {
ops.add(new SetSpanOperation(start, end, new ReactForegroundColorSpan(textShadowNode.mColor)));
}
@ -138,6 +150,8 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
new CustomLetterSpacingSpan(effectiveLetterSpacing)));
}
}
int effectiveFontSize = textAttributes.getEffectiveFontSize();
if (// `getEffectiveFontSize` always returns a value so don't need to check for anything like
// `Float.NaN`.
@ -192,6 +206,167 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
}
}
private static boolean isWhitespace(char character) {
if (character == '\u200b') {
return false;
}
return String.valueOf(character).trim().length() == 0;
}
private static boolean isControlChar(char character) {
return character == '*' || character == '`' || character == '_' || character == '\u200b';
}
protected static void parseMarkdownTag(String tag,
SpannableStringBuilder sb,
List<SetSpanOperation> ops,
// codeZones mark where do we format stuff as "code", we won't apply any formatting there.
boolean[] codeZones,
boolean isCode,
Supplier<ReactSpan>... spans) {
final String content = sb.toString();
int tagBeginIndex = -1;
final boolean isMultilineCodeTag = tag.compareTo("```") == 0;
for (int i = 0; i < content.length() - tag.length(); i++) {
final String candidate = content.substring(i, i + tag.length());
final boolean followedByWhitespace = content.length() - i < tag.length() || isWhitespace(content.charAt(i + tag.length()));
final boolean followedByControlCharacter = content.length() - i < tag.length() || isControlChar(content.charAt(i + tag.length()));
final boolean preceededByWhitespace = i == 0 || isWhitespace(content.charAt(i - 1));
final boolean preceededByControlCharacter = i > 0 && isControlChar(content.charAt(i - 1));
if (candidate.compareTo(tag) == 0) {
if (tagBeginIndex < 0 && !codeZones[i] && (isMultilineCodeTag || !followedByWhitespace) && (preceededByWhitespace || preceededByControlCharacter) ) {
tagBeginIndex = i;
// make sure that ``` is the OUTERMOST tag as opposed to everything else
if (isMultilineCodeTag) {
i += tag.length() - 1;
}
} else if (tagBeginIndex >= 0 && !codeZones[i]) {
if (i - tagBeginIndex < 2) {
if (!followedByWhitespace && !isMultilineCodeTag) {
tagBeginIndex = i;
} else {
tagBeginIndex = -1;
}
} else if (followedByWhitespace || (!isMultilineCodeTag && followedByControlCharacter)) {
hideCharacters(tagBeginIndex, tag.length(), sb);
hideCharacters(i, tag.length(), sb);
for (int j = 0; j < spans.length; j++) {
ops.add(new SetSpanOperation(tagBeginIndex, i + tag.length(), spans[j].get()));
}
if (isCode) {
for (int j = tagBeginIndex; j <= i; j++) {
codeZones[j] = true;
}
}
tagBeginIndex = -1;
}
}
// line break stops all tags except ```
} else if (candidate.compareTo("\n") == 0 && !isMultilineCodeTag) {
tagBeginIndex = -1;
}
}
}
protected static void addMarkdownParsing(ReactBaseTextShadowNode textShadowNode, List<SetSpanOperation> ops, SpannableStringBuilder sb) {
if (!textShadowNode.mParseBasicMarkdown) {
return;
}
if (sb.toString().length() <= 0) {
return;
}
boolean[] codeZones = new boolean[sb.toString().length()];
parseMarkdownTag("```", sb, ops, codeZones, true,
new Supplier<ReactSpan>() {
public ReactSpan get() {
return new ReactForegroundColorSpan(textShadowNode.mMarkdownCodeForegroundColor);
}
},
new Supplier<ReactSpan>() {
public ReactSpan get() {
return new ReactBackgroundColorSpan(textShadowNode.mMarkdownCodeBackgroundColor);
}
},
new Supplier<ReactSpan>() {
public ReactSpan get() {
return new CustomStyleSpan(
textShadowNode.mFontStyle,
textShadowNode.mFontWeight,
"monospace",
textShadowNode.getThemedContext().getAssets());
}
});
parseMarkdownTag("`", sb, ops, codeZones, true,
new Supplier<ReactSpan>() {
public ReactSpan get() {
return new ReactForegroundColorSpan(textShadowNode.mMarkdownCodeForegroundColor);
}
},
new Supplier<ReactSpan>() {
public ReactSpan get() {
return new ReactBackgroundColorSpan(textShadowNode.mMarkdownCodeBackgroundColor);
}
},
new Supplier<ReactSpan>() {
public ReactSpan get() {
return new CustomStyleSpan(
textShadowNode.mFontStyle,
textShadowNode.mFontWeight,
"monospace",
textShadowNode.getThemedContext().getAssets());
}
});
parseMarkdownTag("*", sb, ops, codeZones, false,
new Supplier<ReactSpan>() {
public ReactSpan get() {
return new CustomStyleSpan(
textShadowNode.mFontStyle,
Typeface.BOLD,
textShadowNode.mFontFamily,
textShadowNode.getThemedContext().getAssets());
}});
parseMarkdownTag("_", sb, ops, codeZones, false,
new Supplier<ReactSpan>() {
public ReactSpan get() {
return new CustomStyleSpan(
Typeface.ITALIC,
textShadowNode.mFontWeight,
textShadowNode.mFontFamily,
textShadowNode.getThemedContext().getAssets());
}
});
}
protected static void hideCharacters(int index, int length, SpannableStringBuilder sb) {
// To keep internal consistency, we just replace control characters with
// zero-width spaces. They won't be visible at all, but the number of
// chars will stay the same
for (int i = index; i < index + length; i++) {
sb.replace(i, i + 1, "\u200b");
}
}
protected static Spannable spannedFromShadowNode(
ReactBaseTextShadowNode textShadowNode, String text) {
SpannableStringBuilder sb = new SpannableStringBuilder();
@ -214,6 +389,7 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
textShadowNode.mContainsImages = false;
float heightOfTallestInlineImage = Float.NaN;
// While setting the Spans on the final text, we also check whether any of them are images.
int priority = 0;
for (SetSpanOperation op : ops) {
@ -306,6 +482,9 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
protected boolean mContainsImages = false;
protected float mHeightOfTallestInlineImage = Float.NaN;
protected boolean mParseBasicMarkdown = false;
protected int mMarkdownCodeForegroundColor = Color.BLACK;
protected int mMarkdownCodeBackgroundColor = Color.WHITE;
public ReactBaseTextShadowNode() {
mTextAttributes = new TextAttributes();
@ -553,4 +732,25 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
}
markUpdated();
}
@ReactProp(name = "parseBasicMarkdown")
public void setParseBasicMarkdown(boolean parseBasicMarkdown) {
mParseBasicMarkdown = parseBasicMarkdown;
}
@ReactProp(name = "markdownCodeBackgroundColor", defaultInt = Color.WHITE, customType = "Color")
public void setMarkdownCodeBackgroundColor(int markdownCodeBackgroundColor) {
if (markdownCodeBackgroundColor != mMarkdownCodeBackgroundColor) {
mMarkdownCodeBackgroundColor = markdownCodeBackgroundColor;
markUpdated();
}
}
@ReactProp(name = "markdownCodeForegroundColor", defaultInt = Color.BLACK, customType = "Color")
public void setMarkdownCodeForegroundColor(int markdownCodeForegroundColor) {
if (markdownCodeForegroundColor != mMarkdownCodeForegroundColor) {
mMarkdownCodeForegroundColor = markdownCodeForegroundColor;
markUpdated();
}
}
}