t3-code-android-nightly/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm

839 lines
32 KiB
Text

#import "T3MarkdownText.h"
#import "T3MarkdownTextShadowNode.h"
#import "T3MarkdownTextComponentDescriptor.h"
#import "T3MarkdownTextRun.h"
#import "T3ContextChip.h"
#import <React/RCTConversions.h>
#import <objc/runtime.h>
#import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h>
#import <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h>
#import <react/renderer/components/T3MarkdownTextSpec/Props.h>
#import <react/renderer/components/T3MarkdownTextSpec/RCTComponentViewHelpers.h>
#import <React/RCTFabricComponentsPlugins.h>
using namespace facebook::react;
@interface T3ContextChipAccessibilityElement : UIAccessibilityElement
@property(nonatomic, weak) T3MarkdownTextRun *run;
@end
@implementation T3ContextChipAccessibilityElement
- (BOOL)accessibilityActivate
{
if (self.run == nil) return NO;
[self.run onPress];
return YES;
}
@end
/** Preserve canonical references and their payload when copying a native text selection. */
@interface T3ContextCopyTextView : UITextView
@property(nonatomic, copy) NSDictionary *contextClipboardConfig;
@end
@implementation T3ContextCopyTextView
// Read-only text still supports selecting the entire document after selecting a word.
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
{
if (action == @selector(selectAll:)) {
return self.selectable && self.text.length > 0 && self.selectedRange.length < self.text.length;
}
return [super canPerformAction:action withSender:sender];
}
- (void)copy:(id)sender
{
NSRange selected = self.selectedRange;
NSArray *ranges = self.contextClipboardConfig[@"ranges"];
if (selected.location == NSNotFound || selected.length == 0 || NSMaxRange(selected) > self.text.length || ranges.count == 0) {
[super copy:sender];
return;
}
NSMutableString *text = [[self.text substringWithRange:selected] mutableCopy];
BOOL hasContext = NO;
for (NSDictionary *range in [ranges reverseObjectEnumerator]) {
NSUInteger start = [range[@"start"] unsignedIntegerValue];
NSUInteger end = [range[@"end"] unsignedIntegerValue];
if (end <= start || end > self.text.length) continue;
NSRange overlap = NSIntersectionRange(selected, NSMakeRange(start, end - start));
if (overlap.length == 0 || ![range[@"text"] isKindOfClass:NSString.class]) continue;
[text replaceCharactersInRange:NSMakeRange(overlap.location - selected.location, overlap.length) withString:range[@"text"]];
hasContext = YES;
}
if (!hasContext) { [super copy:sender]; return; }
[text replaceOccurrencesOfString:@"\uFFFC\u00A0" withString:@"" options:0 range:NSMakeRange(0, text.length)];
NSString *fragment = self.contextClipboardConfig[@"fragment"];
NSMutableDictionary *payload = [[NSJSONSerialization JSONObjectWithData:[fragment dataUsingEncoding:NSUTF8StringEncoding] options:NSJSONReadingMutableContainers error:nil] mutableCopy];
NSArray *records = payload[@"records"];
NSMutableArray *copied = [NSMutableArray array];
NSMutableSet *screenshots = [NSMutableSet set];
for (NSDictionary *record in records) {
if ([text containsString:[NSString stringWithFormat:@"/%@)", record[@"contextId"]]]) {
[copied addObject:record];
if ([record[@"screenshotContextId"] isKindOfClass:NSString.class]) [screenshots addObject:record[@"screenshotContextId"]];
}
}
for (NSDictionary *record in records) {
if ([screenshots containsObject:record[@"contextId"]] && ![copied containsObject:record]) [copied addObject:record];
}
payload[@"records"] = copied;
NSData *encoded = payload ? [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil] : nil;
NSMutableDictionary *item = [@{@"public.utf8-plain-text": text} mutableCopy];
if (encoded && copied.count > 0) {
NSString *raw = [[NSString alloc] initWithData:encoded encoding:NSUTF8StringEncoding];
NSString *attribute = [raw stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.alphanumericCharacterSet];
NSString *escaped = [[[text stringByReplacingOccurrencesOfString:@"&" withString:@"&amp;"] stringByReplacingOccurrencesOfString:@"<" withString:@"&lt;"] stringByReplacingOccurrencesOfString:@">" withString:@"&gt;"];
item[@"app.t3.context-fragment"] = encoded;
item[@"public.html"] = [[NSString stringWithFormat:@"<pre data-t3-context-fragment=\"%@\">%@</pre>", attribute, escaped] dataUsingEncoding:NSUTF8StringEncoding];
}
UIPasteboard.generalPasteboard.items = @[item];
}
@end
static void T3MarkdownTextApplyParagraphStyles(
NSMutableAttributedString *attributedString,
const std::vector<T3MarkdownTextParagraphStyleRange> &styleRanges)
{
for (const auto &styleRange : styleRanges) {
if (styleRange.length == 0 || styleRange.location >= attributedString.length) {
continue;
}
const NSRange markerRange = NSMakeRange(
styleRange.location,
MIN(styleRange.length, attributedString.length - styleRange.location));
const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange];
const NSParagraphStyle *existingStyle =
[attributedString attribute:NSParagraphStyleAttributeName
atIndex:paragraphRange.location
effectiveRange:nil];
NSMutableParagraphStyle *paragraphStyle =
existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new];
paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent;
paragraphStyle.headIndent = styleRange.headIndent;
paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing;
paragraphStyle.tabStops = @[
[[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft
location:styleRange.headIndent
options:@{}]
];
paragraphStyle.defaultTabInterval = styleRange.headIndent;
[attributedString addAttribute:NSParagraphStyleAttributeName
value:paragraphStyle
range:paragraphRange];
}
}
static void T3MarkdownTextApplyAttachments(
NSMutableAttributedString *attributedString,
const std::vector<T3MarkdownTextAttachmentRange> &attachmentRanges,
NSDictionary<NSString *, UIImage *> *images)
{
for (const auto &attachmentRange : attachmentRanges) {
if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) {
continue;
}
NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()];
NSTextAttachment *attachment = [[NSTextAttachment alloc] init];
UIImage *image = images[imageUri];
const BOOL isSymbol = [imageUri hasPrefix:@"sf:"];
if (isSymbol) {
image = [UIImage systemImageNamed:[imageUri substringFromIndex:3]];
}
NSDictionary *runAttributes =
[attributedString attributesAtIndex:attachmentRange.location effectiveRange:nil];
UIColor *foregroundColor = runAttributes[NSForegroundColorAttributeName];
if (image != nil && (isSymbol || attachmentRange.tintWithForeground)) {
image = [image imageWithTintColor:foregroundColor ?: UIColor.labelColor
renderingMode:UIImageRenderingModeAlwaysOriginal];
}
attachment.image = image ?: [[UIImage alloc] init];
const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange);
attachment.bounds = CGRectMake(
0,
T3MarkdownTextAttachmentBaselineOffset(attachmentRange),
attachmentSize,
attachmentSize);
NSDictionary *chip = T3ContextChipPayload(imageUri);
if (chip != nil) {
CGSize size = CGSizeMake(attachmentRange.chipWidth, attachmentRange.chipHeight);
attachment.bounds = T3ContextChipBounds(runAttributes[NSFontAttributeName], size);
NSString *iconUri = [chip[@"iconUri"] isKindOfClass:NSString.class] ? chip[@"iconUri"] : nil;
attachment.image = T3ContextChipImage(chip, size, iconUri ? images[iconUri] : nil);
}
const NSRange range = NSMakeRange(
attachmentRange.location,
MIN(attachmentRange.length, attributedString.length - attachmentRange.location));
[attributedString replaceCharactersInRange:range
withAttributedString:T3MarkdownTextAttachmentString(attachment, runAttributes)];
}
}
@protocol T3MarkdownOutsideTapTarget <NSObject>
- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView;
@end
@interface T3MarkdownOutsideTapCoordinator : NSObject <UIGestureRecognizerDelegate>
- (instancetype)initWithWindow:(UIWindow *)window;
- (void)addTarget:(id<T3MarkdownOutsideTapTarget>)target;
- (void)removeTarget:(id<T3MarkdownOutsideTapTarget>)target;
@end
static const void *T3MarkdownOutsideTapCoordinatorKey =
&T3MarkdownOutsideTapCoordinatorKey;
@implementation T3MarkdownOutsideTapCoordinator {
__weak UIWindow *_window;
UITapGestureRecognizer *_recognizer;
NSHashTable<id<T3MarkdownOutsideTapTarget>> *_targets;
}
- (instancetype)initWithWindow:(UIWindow *)window
{
if (self = [super init]) {
_window = window;
_targets = [NSHashTable weakObjectsHashTable];
_recognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(handleTap:)];
_recognizer.cancelsTouchesInView = NO;
_recognizer.delegate = self;
[window addGestureRecognizer:_recognizer];
}
return self;
}
- (void)addTarget:(id<T3MarkdownOutsideTapTarget>)target
{
[_targets addObject:target];
}
- (void)removeTarget:(id<T3MarkdownOutsideTapTarget>)target
{
[_targets removeObject:target];
if (_targets.count > 0) {
return;
}
UIWindow *window = _window;
[window removeGestureRecognizer:_recognizer];
if (objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey) == self) {
objc_setAssociatedObject(
window,
T3MarkdownOutsideTapCoordinatorKey,
nil,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
}
- (void)handleTap:(UITapGestureRecognizer *)sender
{
UIWindow *window = _window;
if (window == nil) {
return;
}
UIView *hitView = [window hitTest:[sender locationInView:window] withEvent:nil];
if (hitView == nil) {
return;
}
for (id<T3MarkdownOutsideTapTarget> target in _targets.allObjects) {
[target clearSelectionForOutsideTapWithHitView:hitView];
}
}
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
return YES;
}
@end
static T3MarkdownOutsideTapCoordinator *
T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
{
T3MarkdownOutsideTapCoordinator *coordinator =
objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey);
if (coordinator == nil) {
coordinator = [[T3MarkdownOutsideTapCoordinator alloc] initWithWindow:window];
objc_setAssociatedObject(
window,
T3MarkdownOutsideTapCoordinatorKey,
coordinator,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
return coordinator;
}
@interface T3MarkdownText () <RCTT3MarkdownTextViewProtocol, UIGestureRecognizerDelegate, UITextViewDelegate>
@end
@interface T3MarkdownText () <T3MarkdownOutsideTapTarget>
@end
@implementation T3MarkdownText {
UIView * _view;
T3ContextCopyTextView * _textView;
T3MarkdownTextShadowNode::ConcreteState::Shared _state;
__weak UIWindow * _outsideTapWindow;
BOOL _suppressSelectionChange;
NSMutableDictionary<NSString *, UIImage *> * _attachmentImages;
NSMutableSet<NSString *> * _pendingAttachmentUris;
UILongPressGestureRecognizer *_longPressGestureRecognizer;
UITapGestureRecognizer *_pressGestureRecognizer;
NSArray *_contextAccessibilityElements;
BOOL _textLayoutNeedsUpdate;
}
+ (ComponentDescriptorProvider)componentDescriptorProvider
{
return concreteComponentDescriptorProvider<T3MarkdownTextComponentDescriptor>();
}
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
static const auto defaultProps = std::make_shared<const T3MarkdownTextProps>();
_props = defaultProps;
_view = [[UIView alloc] init];
self.contentView = _view;
self.clipsToBounds = true;
_textView = [[T3ContextCopyTextView alloc] init];
_attachmentImages = [[NSMutableDictionary alloc] init];
_pendingAttachmentUris = [[NSMutableSet alloc] init];
_textView.scrollEnabled = false;
_textView.editable = false;
_textView.textContainerInset = UIEdgeInsetsZero;
_textView.textContainer.lineFragmentPadding = 0;
// The shadow node already sized this view to fit every line. A container
// that tracks the frame can come out a fraction of a point shorter after
// pixel rounding, and TextKit then drops the last line. Size it by hand
// with unbounded height and let the view's bounds clip.
_textView.textContainer.widthTracksTextView = NO;
_textView.textContainer.heightTracksTextView = NO;
_textView.delegate = self;
// Chat text supports selection and contextual actions, but not drag-and-drop.
_textView.textDragInteraction.enabled = NO;
_textView.linkTextAttributes = @{};
// Must match RCTTextLayoutManager, which measures with usesFontLeading = NO.
_textView.layoutManager.usesFontLeading = NO;
[self addSubview:_textView];
_longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self
action:@selector(handleLongPressIfNecessary:)];
_longPressGestureRecognizer.delegate = self;
_pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(handlePressIfNecessary:)];
_pressGestureRecognizer.delegate = self;
[_pressGestureRecognizer requireGestureRecognizerToFail:_longPressGestureRecognizer];
[_textView addGestureRecognizer:_pressGestureRecognizer];
[_textView addGestureRecognizer:_longPressGestureRecognizer];
}
return self;
}
- (void)didMoveToWindow
{
[super didMoveToWindow];
if (_outsideTapWindow == self.window) {
return;
}
if (_outsideTapWindow != nil) {
T3MarkdownOutsideTapCoordinator *coordinator =
objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey);
[coordinator removeTarget:self];
}
_outsideTapWindow = self.window;
if (_outsideTapWindow != nil) {
[T3MarkdownOutsideTapCoordinatorForWindow(_outsideTapWindow) addTarget:self];
}
}
- (void)dealloc
{
T3MarkdownOutsideTapCoordinator *coordinator =
objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey);
[coordinator removeTarget:self];
}
- (NSArray *)accessibilityElements
{
return _contextAccessibilityElements ?: [super accessibilityElements];
}
// See RCTParagraphComponentView
- (void)prepareForRecycle
{
[super prepareForRecycle];
T3MarkdownOutsideTapCoordinator *coordinator =
objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey);
[coordinator removeTarget:self];
_outsideTapWindow = nil;
_state.reset();
// Reset the frame to zero so that when it properly lays out on the next use
_textView.frame = CGRectZero;
_textView.attributedText = nil;
_contextAccessibilityElements = nil;
_textLayoutNeedsUpdate = YES;
}
- (void)layoutSubviews
{
[super layoutSubviews];
// Resize during UIKit's layout pass. Doing this in drawRect can leave the
// text view's drawing surface clipped to its previous width after a pane
// expands, even though TextKit has already rewrapped the lines.
if (!CGRectEqualToRect(_textView.frame, _view.frame)) {
_textView.frame = _view.frame;
_textLayoutNeedsUpdate = YES;
[self setNeedsDisplay];
}
}
- (void)drawRect:(CGRect)rect
{
if (!_state) {
return;
}
const auto &props = *std::static_pointer_cast<T3MarkdownTextProps const>(_props);
const auto attrString = _state->getData().attributedString;
NSMutableAttributedString *convertedAttrString =
[RCTNSAttributedStringFromAttributedString(attrString) mutableCopy];
T3MarkdownTextApplyParagraphStyles(
convertedAttrString,
_state->getData().paragraphStyleRanges);
T3MarkdownTextApplyAttachments(
convertedAttrString,
_state->getData().attachmentRanges,
_attachmentImages);
// Matches the shadow node so drawn lines sit where measurement put them.
RCTApplyBaselineOffset(convertedAttrString);
NSUInteger runLocation = 0;
for (UIView *child in self.subviews) {
if (![child isKindOfClass:[T3MarkdownTextRun class]]) {
continue;
}
T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child;
const NSRange runRange = NSMakeRange(runLocation, textChild.text.length);
runLocation = NSMaxRange(runRange);
if (![textChild hasContextMenu] || runRange.length == 0 ||
NSMaxRange(runRange) > convertedAttrString.length) {
continue;
}
NSURL *link = [NSURL URLWithString:
[NSString stringWithFormat:@"t3-markdown-run://%ld", (long)textChild.tag]];
if (link != nil) {
// A glyph must not be both a link and an attachment. UIKit caches them as
// different text-item classes and can send `attachment` to a cached link
// on a later tap. Attachment actions already use primaryActionForTextItem.
[convertedAttrString enumerateAttribute:NSAttachmentAttributeName
inRange:runRange
options:0
usingBlock:^(id attachment, NSRange range, BOOL *stop) {
if (attachment == nil) {
[convertedAttrString addAttribute:NSLinkAttributeName value:link range:range];
}
}];
}
}
[self loadAttachmentImages:_state->getData().attachmentRanges];
// Setting attributedText clears any active text selection, and re-assigning
// the frame triggers a layout flush that has the same effect. Bail out
// entirely when nothing actually changed so a JS-side state update made in
// response to onSelectionChange doesn't deselect what the user is selecting.
const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString];
const BOOL frameChanged = _textLayoutNeedsUpdate;
if (!textChanged && !frameChanged) {
return;
}
if (textChanged) {
// Reassigning attributedText clears any active selection. Save it and
// restore after, while suppressing the synthetic textViewDidChangeSelection
// events the clear-then-restore would otherwise produce — those would
// round-trip to JS and re-trigger this same path, causing a loop.
const NSRange savedRange = _textView.selectedRange;
_suppressSelectionChange = YES;
_textView.attributedText = convertedAttrString;
NSMutableString *accessibleText = [convertedAttrString.string mutableCopy];
for (auto it = _state->getData().attachmentRanges.rbegin();
it != _state->getData().attachmentRanges.rend(); ++it) {
NSDictionary *chip = T3ContextChipPayload([NSString stringWithUTF8String:it->imageUri.c_str()]);
if (chip != nil && it->location < accessibleText.length) {
[accessibleText replaceCharactersInRange:NSMakeRange(it->location, 1) withString:chip[@"label"]];
}
}
_textView.accessibilityLabel = accessibleText;
if (savedRange.length > 0 && NSMaxRange(savedRange) <= _textView.attributedText.length) {
_textView.selectedRange = savedRange;
}
_suppressSelectionChange = NO;
}
if (frameChanged) {
_textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX);
}
_textLayoutNeedsUpdate = NO;
// Text attachments have no native link element. Expose their existing runs
// at the measured glyph bounds, without inserting views into text layout.
NSMutableArray *accessibleElements = [NSMutableArray arrayWithObject:_textView];
for (UIView *child in self.subviews) {
if (![child isKindOfClass:T3MarkdownTextRun.class]) continue;
T3MarkdownTextRun *run = (T3MarkdownTextRun *)child;
run.contextChipInteractive = NO;
}
for (const auto &attachmentRange : _state->getData().attachmentRanges) {
NSDictionary *chip = T3ContextChipPayload(
[NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]);
if (![chip[@"interactive"] boolValue]) continue;
NSRange range = NSMakeRange(attachmentRange.location, 1);
T3MarkdownTextRun *run = [self childForCharacterRange:range];
if (!run || NSMaxRange(range) > convertedAttrString.length) continue;
NSRange glyphRange = [_textView.layoutManager glyphRangeForCharacterRange:range actualCharacterRange:nil];
CGRect bounds = [_textView.layoutManager boundingRectForGlyphRange:glyphRange
inTextContainer:_textView.textContainer];
bounds = CGRectOffset(bounds, _textView.textContainerInset.left, _textView.textContainerInset.top);
run.contextChipInteractive = YES;
T3ContextChipAccessibilityElement *element =
[[T3ContextChipAccessibilityElement alloc] initWithAccessibilityContainer:self];
element.run = run;
element.accessibilityLabel = chip[@"label"];
element.accessibilityTraits = UIAccessibilityTraitButton;
element.accessibilityFrameInContainerSpace = [_textView convertRect:bounds toView:self];
[accessibleElements addObject:element];
}
_contextAccessibilityElements = accessibleElements;
__block std::vector<std::string> lines;
const int maxLines = props.numberOfLines;
[_textView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, convertedAttrString.string.length) usingBlock:^(CGRect rect,
CGRect usedRect,
NSTextContainer * _Nonnull textContainer,
NSRange glyphRange,
BOOL * _Nonnull stop) {
const auto charRange = [self->_textView.layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nil];
const auto line = [self->_textView.text substringWithRange:charRange];
lines.push_back(line.UTF8String);
// enumerateLineFragments overshoots maximumNumberOfLines by one on iOS
// 18, so cap explicitly.
if (maxLines > 0 && lines.size() >= (size_t)maxLines) {
*stop = YES;
}
}];
if (_eventEmitter != nullptr) {
std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextEventEmitter>(_eventEmitter)
->onTextLayout(facebook::react::T3MarkdownTextEventEmitter::OnTextLayout{static_cast<int>(self.tag), lines});
};
}
- (void)loadAttachmentImages:(const std::vector<T3MarkdownTextAttachmentRange> &)attachmentRanges
{
for (const auto &attachmentRange : attachmentRanges) {
NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()];
if ([imageUri hasPrefix:@"sf:"]) {
continue;
}
NSDictionary *chip = T3ContextChipPayload(imageUri);
if (chip != nil) {
imageUri = [chip[@"iconUri"] isKindOfClass:NSString.class] ? chip[@"iconUri"] : nil;
if (imageUri.length == 0) continue;
}
if (_attachmentImages[imageUri] != nil || [_pendingAttachmentUris containsObject:imageUri]) {
continue;
}
NSURL *url = [NSURL URLWithString:imageUri];
if (url == nil) {
continue;
}
if (url.isFileURL) {
UIImage *image = [UIImage imageWithContentsOfFile:url.path];
if (image != nil) {
_attachmentImages[imageUri] = image;
dispatch_async(dispatch_get_main_queue(), ^{
[self refreshDisplayedAttachments];
});
}
continue;
}
[_pendingAttachmentUris addObject:imageUri];
[[[NSURLSession sharedSession] dataTaskWithURL:url
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
UIImage *image = data == nil ? nil : [UIImage imageWithData:data];
dispatch_async(dispatch_get_main_queue(), ^{
[self->_pendingAttachmentUris removeObject:imageUri];
if (image != nil) {
self->_attachmentImages[imageUri] = image;
[self refreshDisplayedAttachments];
}
});
}] resume];
}
}
- (void)refreshDisplayedAttachments
{
if (!_state || _textView.attributedText == nil) {
return;
}
NSMutableAttributedString *attributedText = [_textView.attributedText mutableCopy];
T3MarkdownTextApplyAttachments(
attributedText,
_state->getData().attachmentRanges,
_attachmentImages);
const NSRange savedRange = _textView.selectedRange;
_suppressSelectionChange = YES;
_textView.attributedText = attributedText;
if (savedRange.location != NSNotFound &&
NSMaxRange(savedRange) <= _textView.attributedText.length) {
_textView.selectedRange = savedRange;
}
_suppressSelectionChange = NO;
[_textView setNeedsDisplay];
}
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
{
const auto &oldViewProps = *std::static_pointer_cast<T3MarkdownTextProps const>(_props);
const auto &newViewProps = *std::static_pointer_cast<T3MarkdownTextProps const>(props);
if (oldViewProps.contextClipboardConfig != newViewProps.contextClipboardConfig) {
NSString *config = RCTNSStringFromString(newViewProps.contextClipboardConfig);
_textView.contextClipboardConfig = config.length ? [NSJSONSerialization JSONObjectWithData:[config dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil] : nil;
}
if (oldViewProps.numberOfLines != newViewProps.numberOfLines) {
_textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines;
}
if (oldViewProps.selectable != newViewProps.selectable) {
_textView.selectable = newViewProps.selectable;
}
if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) {
if (@available(iOS 11.0, *)) {
_textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling;
}
}
if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) {
if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) {
_textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingHead;
} else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) {
_textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingMiddle;
} else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) {
_textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingTail;
} else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Clip) {
_textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByClipping;
}
}
// I'm not sure if this is really the right way to handle this style. This means that the entire _view_ the text
// is in will have this background color applied. To apply it just to a particular part of a string, you'd need
// to do <Text><Text style={{backgroundColor: 'blue'}}>Hello</Text></Text>.
// This is how the base <Text> component works though, so we'll go with it for now. Can change later if we want.
if (oldViewProps.backgroundColor != newViewProps.backgroundColor) {
_textView.backgroundColor = RCTUIColorFromSharedColor(newViewProps.backgroundColor);
}
[super updateProps:props oldProps:oldProps];
}
// See RCTParagraphComponentView
- (void)updateState:(const facebook::react::State::Shared &)state oldState:(const facebook::react::State::Shared &)oldState
{
_state = std::static_pointer_cast<const T3MarkdownTextShadowNode::ConcreteState>(state);
[self setNeedsDisplay];
}
// MARK: - UIGestureRecognizerDelegate
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
return YES;
}
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
if (gestureRecognizer != _longPressGestureRecognizer &&
gestureRecognizer != _pressGestureRecognizer) {
return YES;
}
const auto location = [self getLocationOfPress:gestureRecognizer];
const auto child = [self getTouchChild:location];
return ![child hasContextMenu];
}
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
{
return YES;
}
- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView
{
if ([hitView isDescendantOfView:self]) {
return;
}
// Defer past the current event loop turn so any in-flight edit-menu action
// (Copy / Define / Look Up / …) reads the live selection before we clear it.
UITextView *textView = _textView;
dispatch_async(dispatch_get_main_queue(), ^{
UITextRange *range = textView.selectedTextRange;
if (range != nil && !range.isEmpty) {
textView.selectedTextRange = nil;
}
});
}
// MARK: - Touch handling
- (nullable T3MarkdownTextRun *)childForCharacterRange:(NSRange)characterRange
{
NSUInteger location = 0;
for (UIView *child in self.subviews) {
if (![child isKindOfClass:[T3MarkdownTextRun class]]) {
continue;
}
T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child;
const NSRange range = NSMakeRange(location, textChild.text.length);
if (NSIntersectionRange(range, characterRange).length > 0) {
return textChild;
}
location = NSMaxRange(range);
}
return nil;
}
- (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender
{
return [sender locationInView:_textView];
}
- (T3MarkdownTextRun*)getTouchChild:(CGPoint)location
{
const auto charIndex = [_textView.layoutManager characterIndexForPoint:location
inTextContainer:_textView.textContainer
fractionOfDistanceBetweenInsertionPoints:nil
];
int currIndex = -1;
for (UIView* child in self.subviews) {
if (![child isKindOfClass:[T3MarkdownTextRun class]]) {
continue;
}
T3MarkdownTextRun* textChild = (T3MarkdownTextRun*)child;
// This is UTF16 code units!!
currIndex += textChild.text.length;
if (charIndex <= currIndex) {
return textChild;
}
}
return nil;
}
- (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender
{
const auto location = [self getLocationOfPress:sender];
const auto child = [self getTouchChild:location];
if (child) {
[child onPress];
}
}
- (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender
{
if (sender.state != UIGestureRecognizerStateBegan) {
return;
}
const auto location = [self getLocationOfPress:sender];
const auto child = [self getTouchChild:location];
if (child) {
[child onLongPress];
}
}
// MARK: - UITextViewDelegate
- (nullable UIAction *)textView:(UITextView *)textView
primaryActionForTextItem:(UITextItem *)textItem
defaultAction:(UIAction *)defaultAction API_AVAILABLE(ios(17.0))
{
T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range];
if (![child hasContextMenu] && !child.contextChipInteractive) {
return defaultAction;
}
__weak T3MarkdownTextRun *weakChild = child;
return [UIAction actionWithHandler:^(__kindof UIAction *action) {
[weakChild onPress];
}];
}
- (nullable UITextItemMenuConfiguration *)textView:(UITextView *)textView
menuConfigurationForTextItem:(UITextItem *)textItem
defaultMenu:(UIMenu *)defaultMenu API_AVAILABLE(ios(17.0))
{
T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range];
UIMenu *menu = [child contextMenu];
if (child.contextChipInteractive && menu == nil) return nil;
return [UITextItemMenuConfiguration configurationWithMenu:menu ?: defaultMenu];
}
- (void)textViewDidChangeSelection:(UITextView *)textView
{
if (_suppressSelectionChange) {
return;
}
if (_eventEmitter == nullptr) {
return;
}
const NSRange selectedRange = textView.selectedRange;
if (selectedRange.location == NSNotFound) {
return;
}
// Fires on programmatic selection changes too (e.g. the outside-tap clear
// in handleOutsideTap:), so JS will see a synthetic empty-range event then.
std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextEventEmitter>(_eventEmitter)
->onSelectionChange(facebook::react::T3MarkdownTextEventEmitter::OnSelectionChange{
static_cast<int>(self.tag),
static_cast<int>(selectedRange.location),
static_cast<int>(selectedRange.location + selectedRange.length),
});
}
Class<RCTComponentViewProtocol> T3MarkdownTextCls(void)
{
return T3MarkdownText.class;
}
@end