diff --git a/.maestro/enrichedText/flows/empty_list_elements_display.yaml b/.maestro/enrichedText/flows/empty_list_elements_display.yaml index 6344898c..d0a23a8b 100644 --- a/.maestro/enrichedText/flows/empty_list_elements_display.yaml +++ b/.maestro/enrichedText/flows/empty_list_elements_display.yaml @@ -1,6 +1,4 @@ appId: swmansion.enriched.example -tags: - - android-only --- # Validates that lists with empty items are parsed and displayed correctly - launchApp diff --git a/.maestro/enrichedText/flows/inline_styles_display.yaml b/.maestro/enrichedText/flows/inline_styles_display.yaml index 32c21b08..d9046d9e 100644 --- a/.maestro/enrichedText/flows/inline_styles_display.yaml +++ b/.maestro/enrichedText/flows/inline_styles_display.yaml @@ -1,6 +1,4 @@ appId: swmansion.enriched.example -tags: - - android-only --- # Validates that inline styles are displayed correctly - launchApp diff --git a/.maestro/enrichedText/flows/link_press.yaml b/.maestro/enrichedText/flows/link_press.yaml index 7b232aad..c3a39e89 100644 --- a/.maestro/enrichedText/flows/link_press.yaml +++ b/.maestro/enrichedText/flows/link_press.yaml @@ -1,6 +1,4 @@ appId: swmansion.enriched.example -tags: - - android-only --- # Validates that link press events are triggered correctly - launchApp diff --git a/.maestro/enrichedText/flows/mention_press.yaml b/.maestro/enrichedText/flows/mention_press.yaml index 0133f49c..cc1ec2ea 100644 --- a/.maestro/enrichedText/flows/mention_press.yaml +++ b/.maestro/enrichedText/flows/mention_press.yaml @@ -1,6 +1,4 @@ appId: swmansion.enriched.example -tags: - - android-only --- # Validates that mention press events are triggered correctly - launchApp diff --git a/.maestro/enrichedText/flows/paragraph_styles_display.yaml b/.maestro/enrichedText/flows/paragraph_styles_display.yaml index 434e4101..f0d0230f 100644 --- a/.maestro/enrichedText/flows/paragraph_styles_display.yaml +++ b/.maestro/enrichedText/flows/paragraph_styles_display.yaml @@ -1,6 +1,4 @@ appId: swmansion.enriched.example -tags: - - android-only --- # Validates that paragraph styles are displayed correctly - launchApp diff --git a/.maestro/enrichedText/screenshots/ios/custom_styles_display.png b/.maestro/enrichedText/screenshots/ios/custom_styles_display.png new file mode 100644 index 00000000..2ac807c3 Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/custom_styles_display.png differ diff --git a/.maestro/enrichedText/screenshots/ios/empty_list_elements_display.png b/.maestro/enrichedText/screenshots/ios/empty_list_elements_display.png new file mode 100644 index 00000000..03dcbd82 Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/empty_list_elements_display.png differ diff --git a/.maestro/enrichedText/screenshots/ios/inline_styles_display.png b/.maestro/enrichedText/screenshots/ios/inline_styles_display.png new file mode 100644 index 00000000..974d833f Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/inline_styles_display.png differ diff --git a/.maestro/enrichedText/screenshots/ios/link_press.png b/.maestro/enrichedText/screenshots/ios/link_press.png new file mode 100644 index 00000000..4a664d37 Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/link_press.png differ diff --git a/.maestro/enrichedText/screenshots/ios/mention_press.png b/.maestro/enrichedText/screenshots/ios/mention_press.png new file mode 100644 index 00000000..0ec98e5f Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/mention_press.png differ diff --git a/.maestro/enrichedText/screenshots/ios/paragraph_styles_display.png b/.maestro/enrichedText/screenshots/ios/paragraph_styles_display.png new file mode 100644 index 00000000..a36a2a93 Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/paragraph_styles_display.png differ diff --git a/apps/example/src/components/TextRenderer.tsx b/apps/example/src/components/TextRenderer.tsx index 2cef09cc..74e2e0ab 100644 --- a/apps/example/src/components/TextRenderer.tsx +++ b/apps/example/src/components/TextRenderer.tsx @@ -52,7 +52,7 @@ const styles = StyleSheet.create({ borderRadius: 8, }, text: { - fontSize: 16, + fontSize: 18, color: 'black', marginTop: 4, fontFamily: 'Nunito-Regular', diff --git a/ios/EnrichedTextInputView.h b/ios/EnrichedTextInputView.h index 1ea44ad9..432c8c1d 100644 --- a/ios/EnrichedTextInputView.h +++ b/ios/EnrichedTextInputView.h @@ -1,7 +1,8 @@ #pragma once #import "AttributesManager.h" #import "BaseStyleProtocol.h" -#import "InputConfig.h" +#import "EnrichedConfig.h" +#import "EnrichedViewHost.h" #import "InputParser.h" #import "InputTextView.h" #import "LinkData.h" @@ -15,11 +16,11 @@ NS_ASSUME_NONNULL_BEGIN @interface EnrichedTextInputView - : RCTViewComponentView { + : RCTViewComponentView { @public InputTextView *textView; @public - InputConfig *config; + EnrichedConfig *config; @public InputParser *parser; @public @@ -44,8 +45,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)anyTextMayHaveBeenModified; - (void)scheduleRelayoutIfNeeded; - (BOOL)handleStyleBlocksAndConflicts:(StyleType)type range:(NSRange)range; -- (NSArray *)getPresentStyleTypesFrom:(NSArray *)types - range:(NSRange)range; + @end NS_ASSUME_NONNULL_END diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 66c22512..fa8d1fe8 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1,4 +1,5 @@ #import "EnrichedTextInputView.h" +#import "AttachmentLayoutUtils.h" #import "CoreText/CoreText.h" #import "DotReplacementUtils.h" #import "ImageAttachment.h" @@ -8,6 +9,7 @@ #import "RCTFabricComponentsPlugins.h" #import "StringExtension.h" #import "StyleHeaders.h" +#import "StyleUtils.h" #import "TextBlockTapGestureRecognizer.h" #import "UIView+React.h" #import "WordsUtils.h" @@ -56,6 +58,8 @@ @implementation EnrichedTextInputView { NSString *_submitBehavior; } +@synthesize blockEmitting = blockEmitting; + // MARK: - Component utils + (ComponentDescriptorProvider)componentDescriptorProvider { @@ -71,6 +75,36 @@ + (BOOL)shouldBeRecycled { return NO; } +// MARK: - EnrichedViewHost protocol + +- (UITextView *)textView { + return textView; +} + +- (EnrichedConfig *)config { + return config; +} + +- (NSDictionary *)stylesDict { + return stylesDict; +} + +- (AttributesManager *)attributesManager { + return attributesManager; +} + +- (NSMutableDictionary *> *)conflictingStyles { + return conflictingStyles; +} + +- (NSMutableDictionary *> *)blockingStyles { + return blockingStyles; +} + +- (NSMutableDictionary *)defaultTypingAttributes { + return defaultTypingAttributes; +} + // MARK: - Init - (instancetype)initWithFrame:(CGRect)frame { @@ -103,149 +137,9 @@ - (void)setDefaults { defaultTypingAttributes = [[NSMutableDictionary alloc] init]; - stylesDict = @{ - @([BoldStyle getType]) : [[BoldStyle alloc] initWithInput:self], - @([ItalicStyle getType]) : [[ItalicStyle alloc] initWithInput:self], - @([UnderlineStyle getType]) : [[UnderlineStyle alloc] initWithInput:self], - @([StrikethroughStyle getType]) : - [[StrikethroughStyle alloc] initWithInput:self], - @([InlineCodeStyle getType]) : [[InlineCodeStyle alloc] initWithInput:self], - @([LinkStyle getType]) : [[LinkStyle alloc] initWithInput:self], - @([MentionStyle getType]) : [[MentionStyle alloc] initWithInput:self], - @([H1Style getType]) : [[H1Style alloc] initWithInput:self], - @([H2Style getType]) : [[H2Style alloc] initWithInput:self], - @([H3Style getType]) : [[H3Style alloc] initWithInput:self], - @([H4Style getType]) : [[H4Style alloc] initWithInput:self], - @([H5Style getType]) : [[H5Style alloc] initWithInput:self], - @([H6Style getType]) : [[H6Style alloc] initWithInput:self], - @([UnorderedListStyle getType]) : - [[UnorderedListStyle alloc] initWithInput:self], - @([OrderedListStyle getType]) : - [[OrderedListStyle alloc] initWithInput:self], - @([CheckboxListStyle getType]) : - [[CheckboxListStyle alloc] initWithInput:self], - @([BlockQuoteStyle getType]) : [[BlockQuoteStyle alloc] initWithInput:self], - @([CodeBlockStyle getType]) : [[CodeBlockStyle alloc] initWithInput:self], - @([ImageStyle getType]) : [[ImageStyle alloc] initWithInput:self] - }; - - conflictingStyles = [@{ - @([BoldStyle getType]) : @[], - @([ItalicStyle getType]) : @[], - @([UnderlineStyle getType]) : @[], - @([StrikethroughStyle getType]) : @[], - @([InlineCodeStyle getType]) : - @[ @([LinkStyle getType]), @([MentionStyle getType]) ], - @([LinkStyle getType]) : @[ - @([InlineCodeStyle getType]), @([LinkStyle getType]), - @([MentionStyle getType]) - ], - @([MentionStyle getType]) : - @[ @([InlineCodeStyle getType]), @([LinkStyle getType]) ], - @([H1Style getType]) : @[ - @([H2Style getType]), @([H3Style getType]), @([H4Style getType]), - @([H5Style getType]), @([H6Style getType]), - @([UnorderedListStyle getType]), @([OrderedListStyle getType]), - @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]), - @([CheckboxListStyle getType]) - ], - @([H2Style getType]) : @[ - @([H1Style getType]), @([H3Style getType]), @([H4Style getType]), - @([H5Style getType]), @([H6Style getType]), - @([UnorderedListStyle getType]), @([OrderedListStyle getType]), - @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]), - @([CheckboxListStyle getType]) - ], - @([H3Style getType]) : @[ - @([H1Style getType]), @([H2Style getType]), @([H4Style getType]), - @([H5Style getType]), @([H6Style getType]), - @([UnorderedListStyle getType]), @([OrderedListStyle getType]), - @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]), - @([CheckboxListStyle getType]) - ], - @([H4Style getType]) : @[ - @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), - @([H5Style getType]), @([H6Style getType]), - @([UnorderedListStyle getType]), @([OrderedListStyle getType]), - @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]), - @([CheckboxListStyle getType]) - ], - @([H5Style getType]) : @[ - @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), - @([H4Style getType]), @([H6Style getType]), - @([UnorderedListStyle getType]), @([OrderedListStyle getType]), - @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]), - @([CheckboxListStyle getType]) - ], - @([H6Style getType]) : @[ - @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), - @([H4Style getType]), @([H5Style getType]), - @([UnorderedListStyle getType]), @([OrderedListStyle getType]), - @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]), - @([CheckboxListStyle getType]) - ], - @([UnorderedListStyle getType]) : @[ - @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), - @([H4Style getType]), @([H5Style getType]), @([H6Style getType]), - @([OrderedListStyle getType]), @([BlockQuoteStyle getType]), - @([CodeBlockStyle getType]), @([CheckboxListStyle getType]) - ], - @([OrderedListStyle getType]) : @[ - @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), - @([H4Style getType]), @([H5Style getType]), @([H6Style getType]), - @([UnorderedListStyle getType]), @([BlockQuoteStyle getType]), - @([CodeBlockStyle getType]), @([CheckboxListStyle getType]) - ], - @([CheckboxListStyle getType]) : @[ - @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), - @([H4Style getType]), @([H5Style getType]), @([H6Style getType]), - @([UnorderedListStyle getType]), @([OrderedListStyle getType]), - @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]) - ], - @([BlockQuoteStyle getType]) : @[ - @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), - @([H4Style getType]), @([H5Style getType]), @([H6Style getType]), - @([UnorderedListStyle getType]), @([OrderedListStyle getType]), - @([CodeBlockStyle getType]), @([CheckboxListStyle getType]) - ], - @([CodeBlockStyle getType]) : @[ - @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), - @([H4Style getType]), @([H5Style getType]), @([H6Style getType]), - @([BoldStyle getType]), @([UnderlineStyle getType]), - @([ItalicStyle getType]), @([StrikethroughStyle getType]), - @([UnorderedListStyle getType]), @([OrderedListStyle getType]), - @([BlockQuoteStyle getType]), @([InlineCodeStyle getType]), - @([MentionStyle getType]), @([LinkStyle getType]), - @([CheckboxListStyle getType]) - ], - @([ImageStyle getType]) : - @[ @([LinkStyle getType]), @([MentionStyle getType]) ] - } mutableCopy]; - - blockingStyles = [@{ - @([BoldStyle getType]) : @[ @([CodeBlockStyle getType]) ], - @([ItalicStyle getType]) : @[ @([CodeBlockStyle getType]) ], - @([UnderlineStyle getType]) : @[ @([CodeBlockStyle getType]) ], - @([StrikethroughStyle getType]) : @[ @([CodeBlockStyle getType]) ], - @([InlineCodeStyle getType]) : - @[ @([CodeBlockStyle getType]), @([ImageStyle getType]) ], - @([LinkStyle getType]) : - @[ @([CodeBlockStyle getType]), @([ImageStyle getType]) ], - @([MentionStyle getType]) : - @[ @([CodeBlockStyle getType]), @([ImageStyle getType]) ], - @([H1Style getType]) : @[], - @([H2Style getType]) : @[], - @([H3Style getType]) : @[], - @([H4Style getType]) : @[], - @([H5Style getType]) : @[], - @([H6Style getType]) : @[], - @([UnorderedListStyle getType]) : @[], - @([OrderedListStyle getType]) : @[], - @([CheckboxListStyle getType]) : @[], - @([BlockQuoteStyle getType]) : @[], - @([CodeBlockStyle getType]) : @[], - @([ImageStyle getType]) : @[ @([InlineCodeStyle getType]) ] - } mutableCopy]; + stylesDict = [StyleUtils stylesDictForHost:self isInput:YES]; + conflictingStyles = [[StyleUtils conflictMap] mutableCopy]; + blockingStyles = [[StyleUtils blockingMap] mutableCopy]; parser = [[InputParser alloc] initWithInput:self]; _attachmentViews = [[NSMutableDictionary alloc] init]; @@ -301,13 +195,13 @@ - (void)updateProps:(Props::Shared const &)props // initial config if (config == nullptr) { isFirstMount = YES; - config = [[InputConfig alloc] init]; + config = [[EnrichedConfig alloc] init]; } // any style prop changes: // firstly we create the new config for the changes - InputConfig *newConfig = [config copy]; + EnrichedConfig *newConfig = [config copy]; if (newViewProps.color != oldViewProps.color) { if (isColorMeaningful(newViewProps.color)) { @@ -369,11 +263,11 @@ - (void)updateProps:(Props::Shared const &)props // Update style blocks and conflicts for bold if (newViewProps.htmlStyle.h1.bold) { - [self addStyleBlock:H1 to:Bold]; - [self addStyleConflict:Bold to:H1]; + [StyleUtils addStyleBlock:H1 to:Bold forHost:self]; + [StyleUtils addStyleConflict:Bold to:H1 forHost:self]; } else { - [self removeStyleBlock:H1 from:Bold]; - [self removeStyleConflict:Bold from:H1]; + [StyleUtils removeStyleBlock:H1 from:Bold forHost:self]; + [StyleUtils removeStyleConflict:Bold from:H1 forHost:self]; } stylePropChanged = YES; @@ -390,11 +284,11 @@ - (void)updateProps:(Props::Shared const &)props // Update style blocks and conflicts for bold if (newViewProps.htmlStyle.h2.bold) { - [self addStyleBlock:H2 to:Bold]; - [self addStyleConflict:Bold to:H2]; + [StyleUtils addStyleBlock:H2 to:Bold forHost:self]; + [StyleUtils addStyleConflict:Bold to:H2 forHost:self]; } else { - [self removeStyleBlock:H2 from:Bold]; - [self removeStyleConflict:Bold from:H2]; + [StyleUtils removeStyleBlock:H2 from:Bold forHost:self]; + [StyleUtils removeStyleConflict:Bold from:H2 forHost:self]; } stylePropChanged = YES; @@ -411,11 +305,11 @@ - (void)updateProps:(Props::Shared const &)props // Update style blocks and conflicts for bold if (newViewProps.htmlStyle.h3.bold) { - [self addStyleBlock:H3 to:Bold]; - [self addStyleConflict:Bold to:H3]; + [StyleUtils addStyleBlock:H3 to:Bold forHost:self]; + [StyleUtils addStyleConflict:Bold to:H3 forHost:self]; } else { - [self removeStyleBlock:H3 from:Bold]; - [self removeStyleConflict:Bold from:H3]; + [StyleUtils removeStyleBlock:H3 from:Bold forHost:self]; + [StyleUtils removeStyleConflict:Bold from:H3 forHost:self]; } stylePropChanged = YES; @@ -432,11 +326,11 @@ - (void)updateProps:(Props::Shared const &)props // Update style blocks and conflicts for bold if (newViewProps.htmlStyle.h4.bold) { - [self addStyleBlock:H4 to:Bold]; - [self addStyleConflict:Bold to:H4]; + [StyleUtils addStyleBlock:H4 to:Bold forHost:self]; + [StyleUtils addStyleConflict:Bold to:H4 forHost:self]; } else { - [self removeStyleBlock:H4 from:Bold]; - [self removeStyleConflict:Bold from:H4]; + [StyleUtils removeStyleBlock:H4 from:Bold forHost:self]; + [StyleUtils removeStyleConflict:Bold from:H4 forHost:self]; } stylePropChanged = YES; @@ -453,11 +347,11 @@ - (void)updateProps:(Props::Shared const &)props // Update style blocks and conflicts for bold if (newViewProps.htmlStyle.h5.bold) { - [self addStyleBlock:H5 to:Bold]; - [self addStyleConflict:Bold to:H5]; + [StyleUtils addStyleBlock:H5 to:Bold forHost:self]; + [StyleUtils addStyleConflict:Bold to:H5 forHost:self]; } else { - [self removeStyleBlock:H5 from:Bold]; - [self removeStyleConflict:Bold from:H5]; + [StyleUtils removeStyleBlock:H5 from:Bold forHost:self]; + [StyleUtils removeStyleConflict:Bold from:H5 forHost:self]; } stylePropChanged = YES; @@ -474,11 +368,11 @@ - (void)updateProps:(Props::Shared const &)props // Update style blocks and conflicts for bold if (newViewProps.htmlStyle.h6.bold) { - [self addStyleBlock:H6 to:Bold]; - [self addStyleConflict:Bold to:H6]; + [StyleUtils addStyleBlock:H6 to:Bold forHost:self]; + [StyleUtils addStyleConflict:Bold to:H6 forHost:self]; } else { - [self removeStyleBlock:H6 from:Bold]; - [self removeStyleConflict:Bold from:H6]; + [StyleUtils removeStyleBlock:H6 from:Bold forHost:self]; + [StyleUtils removeStyleConflict:Bold from:H6 forHost:self]; } stylePropChanged = YES; @@ -1233,38 +1127,6 @@ - (bool)textInputShouldSubmitOnReturn { [_submitBehavior isEqualToString:@"submit"]; } -- (void)addStyleBlock:(StyleType)blocking to:(StyleType)blocked { - NSMutableArray *blocksArr = [blockingStyles[@(blocked)] mutableCopy]; - if (![blocksArr containsObject:@(blocking)]) { - [blocksArr addObject:@(blocking)]; - blockingStyles[@(blocked)] = blocksArr; - } -} - -- (void)removeStyleBlock:(StyleType)blocking from:(StyleType)blocked { - NSMutableArray *blocksArr = [blockingStyles[@(blocked)] mutableCopy]; - if ([blocksArr containsObject:@(blocking)]) { - [blocksArr removeObject:@(blocking)]; - blockingStyles[@(blocked)] = blocksArr; - } -} - -- (void)addStyleConflict:(StyleType)conflicting to:(StyleType)conflicted { - NSMutableArray *conflictsArr = [conflictingStyles[@(conflicted)] mutableCopy]; - if (![conflictsArr containsObject:@(conflicting)]) { - [conflictsArr addObject:@(conflicting)]; - conflictingStyles[@(conflicted)] = conflictsArr; - } -} - -- (void)removeStyleConflict:(StyleType)conflicting from:(StyleType)conflicted { - NSMutableArray *conflictsArr = [conflictingStyles[@(conflicted)] mutableCopy]; - if ([conflictsArr containsObject:@(conflicting)]) { - [conflictsArr removeObject:@(conflicting)]; - conflictingStyles[@(conflicted)] = conflictsArr; - } -} - // MARK: - Native commands and events - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { @@ -1363,7 +1225,10 @@ - (void)focus { - (void)setValue:(NSString *)value { NSString *initiallyProcessedHtml = [parser initiallyProcessHtml:value]; if (initiallyProcessedHtml == nullptr) { - // just plain text + // reset the text first and reset typing attributes + textView.text = @""; + textView.typingAttributes = defaultTypingAttributes; + // set new text textView.text = value; } else { // we've got some seemingly proper html @@ -1648,55 +1513,10 @@ - (void)startMentionWithIndicator:(NSString *)indicator { } } -// returns false when style shouldn't be applied and true when it can be - (BOOL)handleStyleBlocksAndConflicts:(StyleType)type range:(NSRange)range { - // handle blocking styles: if any is present we do not apply the toggled style - NSArray *blocking = - [self getPresentStyleTypesFrom:blockingStyles[@(type)] range:range]; - if (blocking.count != 0) { - return NO; - } - - // handle conflicting styles: remove styles within the range - NSArray *conflicting = - [self getPresentStyleTypesFrom:conflictingStyles[@(type)] range:range]; - if (conflicting.count != 0) { - for (NSNumber *type in conflicting) { - StyleBase *style = stylesDict[type]; - - if ([style isParagraph]) { - // for paragraph styles we can just call remove since it will pick up - // proper paragraph range - [style remove:range withDirtyRange:YES]; - } else { - // for inline styles we have to differentiate betweeen normal and typing - // attributes removal - range.length >= 1 ? [style remove:range withDirtyRange:YES] - : [style removeTyping]; - } - } - } - return YES; -} - -- (NSArray *)getPresentStyleTypesFrom:(NSArray *)types - range:(NSRange)range { - NSMutableArray *resultArray = - [[NSMutableArray alloc] init]; - for (NSNumber *type in types) { - StyleBase *style = stylesDict[type]; - - if (range.length >= 1) { - if ([style any:range]) { - [resultArray addObject:type]; - } - } else { - if ([style detect:range]) { - [resultArray addObject:type]; - } - } - } - return resultArray; + return [StyleUtils handleStyleBlocksAndConflicts:type + range:range + forHost:self]; } - (void)manageSelectionBasedChanges { @@ -2149,7 +1969,7 @@ - (void)onTextBlockTap:(TextBlockTapGestureRecognizer *)gr { if (checkboxStyle) { NSUInteger charIndex = (NSUInteger)gr.characterIndex; - [checkboxStyle toggleCheckedAt:charIndex]; + [checkboxStyle toggleCheckedAt:charIndex withDirtyRange:YES]; [self anyTextMayHaveBeenModified]; NSString *fullText = textView.textStorage.string; @@ -2205,129 +2025,19 @@ - (void)textStorage:(NSTextStorage *)textStorage // MARK: - Media attachments delegate - (void)mediaAttachmentDidUpdate:(NSTextAttachment *)attachment { - NSTextStorage *storage = textView.textStorage; - NSRange fullRange = NSMakeRange(0, storage.length); - - __block NSRange foundRange = NSMakeRange(NSNotFound, 0); - - [storage enumerateAttribute:NSAttachmentAttributeName - inRange:fullRange - options:0 - usingBlock:^(id value, NSRange range, BOOL *stop) { - if (value == attachment) { - foundRange = range; - *stop = YES; - } - }]; - - if (foundRange.location == NSNotFound) { - return; - } - - [storage edited:NSTextStorageEditedAttributes - range:foundRange - changeInLength:0]; - - dispatch_async(dispatch_get_main_queue(), ^{ - [self layoutAttachments]; - }); + [AttachmentLayoutUtils handleAttachmentUpdate:attachment + textView:textView + onLayoutBlock:^{ + [self layoutAttachments]; + }]; } // MARK: - Image/GIF Overlay Management - (void)layoutAttachments { - NSTextStorage *storage = textView.textStorage; - NSMutableDictionary *activeAttachmentViews = - [NSMutableDictionary dictionary]; - - // Iterate over the entire text to find ImageAttachments - [storage enumerateAttribute:NSAttachmentAttributeName - inRange:NSMakeRange(0, storage.length) - options:0 - usingBlock:^(id value, NSRange range, BOOL *stop) { - if ([value isKindOfClass:[ImageAttachment class]]) { - ImageAttachment *attachment = (ImageAttachment *)value; - - CGRect rect = [self frameForAttachment:attachment - atRange:range]; - - // Get or Create the UIImageView for this specific - // attachment key - NSValue *key = - [NSValue valueWithNonretainedObject:attachment]; - UIImageView *imgView = _attachmentViews[key]; - - if (!imgView) { - // It doesn't exist yet, create it - imgView = [[UIImageView alloc] initWithFrame:rect]; - imgView.contentMode = UIViewContentModeScaleAspectFit; - imgView.tintColor = [UIColor labelColor]; - - // Add it directly to the TextView - [textView addSubview:imgView]; - } - - // Update position (in case text moved/scrolled) - if (!CGRectEqualToRect(imgView.frame, rect)) { - imgView.frame = rect; - } - UIImage *targetImage = - attachment.storedAnimatedImage ?: attachment.image; - - // Only set if different to avoid resetting the animation - // loop - if (imgView.image != targetImage) { - imgView.image = targetImage; - } - - // Ensure it is visible on top - imgView.hidden = NO; - [textView bringSubviewToFront:imgView]; - - activeAttachmentViews[key] = imgView; - // Remove from the old map so we know it has been claimed - [_attachmentViews removeObjectForKey:key]; - } - }]; - - // Everything remaining in _attachmentViews is dead or off-screen - for (UIImageView *danglingView in _attachmentViews.allValues) { - [danglingView removeFromSuperview]; - } - _attachmentViews = activeAttachmentViews; -} - -- (CGRect)frameForAttachment:(ImageAttachment *)attachment - atRange:(NSRange)range { - NSLayoutManager *layoutManager = textView.layoutManager; - NSTextContainer *textContainer = textView.textContainer; - NSTextStorage *storage = textView.textStorage; - - NSRange glyphRange = [layoutManager glyphRangeForCharacterRange:range - actualCharacterRange:NULL]; - CGRect glyphRect = [layoutManager boundingRectForGlyphRange:glyphRange - inTextContainer:textContainer]; - - CGRect lineRect = - [layoutManager lineFragmentRectForGlyphAtIndex:glyphRange.location - effectiveRange:NULL]; - CGSize attachmentSize = attachment.bounds.size; - - UIFont *font = [storage attribute:NSFontAttributeName - atIndex:range.location - effectiveRange:NULL]; - if (!font) { - font = [config primaryFont]; - } - - // Calculate (Baseline Alignment) - CGFloat targetY = - CGRectGetMaxY(lineRect) + font.descender - attachmentSize.height; - CGRect rect = - CGRectMake(glyphRect.origin.x + textView.textContainerInset.left, - targetY + textView.textContainerInset.top, - attachmentSize.width, attachmentSize.height); - - return CGRectIntegral(rect); + _attachmentViews = + [AttachmentLayoutUtils layoutAttachmentsInTextView:textView + config:config + existingViews:_attachmentViews]; } @end diff --git a/ios/EnrichedTextView.h b/ios/EnrichedTextView.h new file mode 100644 index 00000000..6a090fe5 --- /dev/null +++ b/ios/EnrichedTextView.h @@ -0,0 +1,33 @@ +#pragma once +#import "EnrichedConfig.h" +#import "EnrichedViewHost.h" +#import "MediaAttachment.h" +#import "MentionParams.h" +#import +#import + +#ifndef EnrichedTextViewNativeComponent_h +#define EnrichedTextViewNativeComponent_h + +NS_ASSUME_NONNULL_BEGIN + +@interface EnrichedTextView + : RCTViewComponentView { +@public + UITextView *textView; +@public + EnrichedConfig *config; +@public + NSDictionary *stylesDict; + NSMutableDictionary *> *conflictingStyles; + NSMutableDictionary *> *blockingStyles; + NSMutableDictionary *defaultTypingAttributes; +} +- (CGSize)measureSize:(CGFloat)maxWidth; +- (void)emitOnLinkPressEvent:(NSString *)url; +- (void)emitOnMentionPressEvent:(MentionParams *)mention; +@end + +NS_ASSUME_NONNULL_END + +#endif /* EnrichedTextViewNativeComponent_h */ diff --git a/ios/EnrichedTextView.mm b/ios/EnrichedTextView.mm new file mode 100644 index 00000000..53ed0202 --- /dev/null +++ b/ios/EnrichedTextView.mm @@ -0,0 +1,880 @@ +#import "EnrichedTextView.h" +#import "AttachmentLayoutUtils.h" +#import "EnrichedTextStyleHeaders.h" +#import "EnrichedTextTouchHandler.h" +#import "EnrichedTouchableTextView.h" +#import "HtmlParser.h" +#import "LayoutManagerExtension.h" +#import "LinkData.h" +#import "MentionParams.h" +#import "MentionStyleProps.h" +#import "RCTFabricComponentsPlugins.h" +#import "StringExtension.h" +#import "StyleUtils.h" +#import "TextDecorationLineEnum.h" +#import "ZeroWidthSpaceUtils.h" +#import +#import +#import +#import +#import +#import + +using namespace facebook::react; + +@interface EnrichedTextView () +@end + +@implementation EnrichedTextView { + EnrichedTextViewShadowNode::ConcreteState::Shared _state; + NSMutableDictionary *_attachmentViews; + EnrichedTextTouchHandler *_touchHandler; +} + +@synthesize blockEmitting = _blockEmitting; + +// MARK: - Component utils + ++ (ComponentDescriptorProvider)componentDescriptorProvider { + return concreteComponentDescriptorProvider(); +} + +Class EnrichedTextViewCls(void) { + return EnrichedTextView.class; +} + ++ (BOOL)shouldBeRecycled { + return NO; +} + +// MARK: - Init + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = + std::make_shared(); + _props = defaultProps; + _attachmentViews = [[NSMutableDictionary alloc] init]; + defaultTypingAttributes = [[NSMutableDictionary alloc] init]; + [self setupTextView]; + [self setupStyles]; + self.contentView = textView; + } + return self; +} + +- (void)setupTextView { + EnrichedTouchableTextView *tv = [[EnrichedTouchableTextView alloc] init]; + _touchHandler = [[EnrichedTextTouchHandler alloc] initWithView:self]; + tv.touchHandler = _touchHandler; + textView = tv; + + textView.backgroundColor = UIColor.clearColor; + textView.textContainerInset = UIEdgeInsetsMake(0, 0, 0, 0); + textView.textContainer.lineFragmentPadding = 0; + textView.editable = NO; + textView.scrollEnabled = NO; + textView.adjustsFontForContentSizeCategory = YES; + textView.layoutManager.input = self; +} + +- (void)setupStyles { + stylesDict = [StyleUtils stylesDictForHost:self isInput:NO]; + conflictingStyles = [[StyleUtils conflictMap] mutableCopy]; + blockingStyles = [[StyleUtils blockingMap] mutableCopy]; +} + +// MARK: - EnrichedViewHost protocol + +- (UITextView *)textView { + return textView; +} + +- (EnrichedConfig *)config { + return config; +} + +- (NSDictionary *)stylesDict { + return stylesDict; +} + +- (AttributesManager *)attributesManager { + return nil; +} + +- (NSMutableDictionary *> *)conflictingStyles { + return conflictingStyles; +} + +- (NSMutableDictionary *> *)blockingStyles { + return blockingStyles; +} + +- (NSMutableDictionary *)defaultTypingAttributes { + return defaultTypingAttributes; +} + +// MARK: - Props + +- (void)updateProps:(Props::Shared const &)props + oldProps:(Props::Shared const &)oldProps { + const auto &oldViewProps = + *std::static_pointer_cast(_props); + const auto &newViewProps = + *std::static_pointer_cast(props); + BOOL isFirstMount = NO; + BOOL stylePropChanged = NO; + BOOL textChanged = NO; + + if (config == nullptr) { + isFirstMount = YES; + config = [[EnrichedConfig alloc] init]; + } + + EnrichedConfig *newConfig = [config copy]; + + // color + if (newViewProps.color != oldViewProps.color) { + if (isColorMeaningful(newViewProps.color)) { + UIColor *uiColor = RCTUIColorFromSharedColor(newViewProps.color); + [newConfig setPrimaryColor:uiColor]; + } else { + [newConfig setPrimaryColor:nullptr]; + } + stylePropChanged = YES; + } + + // fontSize + if (newViewProps.fontSize != oldViewProps.fontSize) { + if (newViewProps.fontSize) { + NSNumber *fontSize = @(newViewProps.fontSize); + [newConfig setPrimaryFontSize:fontSize]; + } else { + [newConfig setPrimaryFontSize:nullptr]; + } + stylePropChanged = YES; + } + + // fontWeight + if (newViewProps.fontWeight != oldViewProps.fontWeight) { + if (!newViewProps.fontWeight.empty()) { + [newConfig + setPrimaryFontWeight:[NSString + fromCppString:newViewProps.fontWeight]]; + } else { + [newConfig setPrimaryFontWeight:nullptr]; + } + stylePropChanged = YES; + } + + // fontFamily + if (newViewProps.fontFamily != oldViewProps.fontFamily) { + if (!newViewProps.fontFamily.empty()) { + [newConfig + setPrimaryFontFamily:[NSString + fromCppString:newViewProps.fontFamily]]; + } else { + [newConfig setPrimaryFontFamily:nullptr]; + } + stylePropChanged = YES; + } + + // fontStyle + if (newViewProps.fontStyle != oldViewProps.fontStyle) { + // TODO: Implement fontStyle setter on EnrichedConfig + // stylePropChanged = YES; + } + + // htmlStyle headings + if (newViewProps.htmlStyle.h1.fontSize != + oldViewProps.htmlStyle.h1.fontSize) { + [newConfig setH1FontSize:newViewProps.htmlStyle.h1.fontSize]; + stylePropChanged = YES; + } + + if (newViewProps.htmlStyle.h1.bold != oldViewProps.htmlStyle.h1.bold) { + [newConfig setH1Bold:newViewProps.htmlStyle.h1.bold]; + + // Update style blocks and conflicts for bold + if (newViewProps.htmlStyle.h1.bold) { + [StyleUtils addStyleBlock:H1 to:Bold forHost:self]; + [StyleUtils addStyleConflict:Bold to:H1 forHost:self]; + } else { + [StyleUtils removeStyleBlock:H1 from:Bold forHost:self]; + [StyleUtils removeStyleConflict:Bold from:H1 forHost:self]; + } + + stylePropChanged = YES; + } + + if (newViewProps.htmlStyle.h2.fontSize != + oldViewProps.htmlStyle.h2.fontSize) { + [newConfig setH2FontSize:newViewProps.htmlStyle.h2.fontSize]; + stylePropChanged = YES; + } + + if (newViewProps.htmlStyle.h2.bold != oldViewProps.htmlStyle.h2.bold) { + [newConfig setH2Bold:newViewProps.htmlStyle.h2.bold]; + + // Update style blocks and conflicts for bold + if (newViewProps.htmlStyle.h2.bold) { + [StyleUtils addStyleBlock:H2 to:Bold forHost:self]; + [StyleUtils addStyleConflict:Bold to:H2 forHost:self]; + } else { + [StyleUtils removeStyleBlock:H2 from:Bold forHost:self]; + [StyleUtils removeStyleConflict:Bold from:H2 forHost:self]; + } + + stylePropChanged = YES; + } + + if (newViewProps.htmlStyle.h3.fontSize != + oldViewProps.htmlStyle.h3.fontSize) { + [newConfig setH3FontSize:newViewProps.htmlStyle.h3.fontSize]; + stylePropChanged = YES; + } + + if (newViewProps.htmlStyle.h3.bold != oldViewProps.htmlStyle.h3.bold) { + [newConfig setH3Bold:newViewProps.htmlStyle.h3.bold]; + + // Update style blocks and conflicts for bold + if (newViewProps.htmlStyle.h3.bold) { + [StyleUtils addStyleBlock:H3 to:Bold forHost:self]; + [StyleUtils addStyleConflict:Bold to:H3 forHost:self]; + } else { + [StyleUtils removeStyleBlock:H3 from:Bold forHost:self]; + [StyleUtils removeStyleConflict:Bold from:H3 forHost:self]; + } + + stylePropChanged = YES; + } + + if (newViewProps.htmlStyle.h4.fontSize != + oldViewProps.htmlStyle.h4.fontSize) { + [newConfig setH4FontSize:newViewProps.htmlStyle.h4.fontSize]; + stylePropChanged = YES; + } + + if (newViewProps.htmlStyle.h4.bold != oldViewProps.htmlStyle.h4.bold) { + [newConfig setH4Bold:newViewProps.htmlStyle.h4.bold]; + + // Update style blocks and conflicts for bold + if (newViewProps.htmlStyle.h4.bold) { + [StyleUtils addStyleBlock:H4 to:Bold forHost:self]; + [StyleUtils addStyleConflict:Bold to:H4 forHost:self]; + } else { + [StyleUtils removeStyleBlock:H4 from:Bold forHost:self]; + [StyleUtils removeStyleConflict:Bold from:H4 forHost:self]; + } + + stylePropChanged = YES; + } + + if (newViewProps.htmlStyle.h5.fontSize != + oldViewProps.htmlStyle.h5.fontSize) { + [newConfig setH5FontSize:newViewProps.htmlStyle.h5.fontSize]; + stylePropChanged = YES; + } + + if (newViewProps.htmlStyle.h5.bold != oldViewProps.htmlStyle.h5.bold) { + [newConfig setH5Bold:newViewProps.htmlStyle.h5.bold]; + + // Update style blocks and conflicts for bold + if (newViewProps.htmlStyle.h5.bold) { + [StyleUtils addStyleBlock:H5 to:Bold forHost:self]; + [StyleUtils addStyleConflict:Bold to:H5 forHost:self]; + } else { + [StyleUtils removeStyleBlock:H5 from:Bold forHost:self]; + [StyleUtils removeStyleConflict:Bold from:H5 forHost:self]; + } + + stylePropChanged = YES; + } + + if (newViewProps.htmlStyle.h6.fontSize != + oldViewProps.htmlStyle.h6.fontSize) { + [newConfig setH6FontSize:newViewProps.htmlStyle.h6.fontSize]; + stylePropChanged = YES; + } + + if (newViewProps.htmlStyle.h6.bold != oldViewProps.htmlStyle.h6.bold) { + [newConfig setH6Bold:newViewProps.htmlStyle.h6.bold]; + + // Update style blocks and conflicts for bold + if (newViewProps.htmlStyle.h6.bold) { + [StyleUtils addStyleBlock:H6 to:Bold forHost:self]; + [StyleUtils addStyleConflict:Bold to:H6 forHost:self]; + } else { + [StyleUtils removeStyleBlock:H6 from:Bold forHost:self]; + [StyleUtils removeStyleConflict:Bold from:H6 forHost:self]; + } + + stylePropChanged = YES; + } + + // blockquote + if (newViewProps.htmlStyle.blockquote.borderColor != + oldViewProps.htmlStyle.blockquote.borderColor) { + if (isColorMeaningful(newViewProps.htmlStyle.blockquote.borderColor)) { + [newConfig setBlockquoteBorderColor:RCTUIColorFromSharedColor( + newViewProps.htmlStyle.blockquote + .borderColor)]; + stylePropChanged = YES; + } + } + if (newViewProps.htmlStyle.blockquote.borderWidth != + oldViewProps.htmlStyle.blockquote.borderWidth) { + [newConfig + setBlockquoteBorderWidth:newViewProps.htmlStyle.blockquote.borderWidth]; + stylePropChanged = YES; + } + if (newViewProps.htmlStyle.blockquote.gapWidth != + oldViewProps.htmlStyle.blockquote.gapWidth) { + [newConfig + setBlockquoteGapWidth:newViewProps.htmlStyle.blockquote.gapWidth]; + stylePropChanged = YES; + } + if (newViewProps.htmlStyle.blockquote.color != + oldViewProps.htmlStyle.blockquote.color || + isFirstMount) { + if (isColorMeaningful(newViewProps.htmlStyle.blockquote.color)) { + [newConfig + setBlockquoteColor:RCTUIColorFromSharedColor( + newViewProps.htmlStyle.blockquote.color)]; + } else { + [newConfig setBlockquoteColor:[newConfig primaryColor]]; + } + stylePropChanged = YES; + } + + // inline code + if (newViewProps.htmlStyle.code.color != oldViewProps.htmlStyle.code.color) { + if (isColorMeaningful(newViewProps.htmlStyle.code.color)) { + [newConfig setInlineCodeFgColor:RCTUIColorFromSharedColor( + newViewProps.htmlStyle.code.color)]; + stylePropChanged = YES; + } + } + if (newViewProps.htmlStyle.code.backgroundColor != + oldViewProps.htmlStyle.code.backgroundColor) { + if (isColorMeaningful(newViewProps.htmlStyle.code.backgroundColor)) { + [newConfig setInlineCodeBgColor:RCTUIColorFromSharedColor( + newViewProps.htmlStyle.code + .backgroundColor)]; + stylePropChanged = YES; + } + } + + // codeblock + if (newViewProps.htmlStyle.codeblock.color != + oldViewProps.htmlStyle.codeblock.color) { + if (isColorMeaningful(newViewProps.htmlStyle.codeblock.color)) { + [newConfig + setCodeBlockFgColor:RCTUIColorFromSharedColor( + newViewProps.htmlStyle.codeblock.color)]; + stylePropChanged = YES; + } + } + if (newViewProps.htmlStyle.codeblock.backgroundColor != + oldViewProps.htmlStyle.codeblock.backgroundColor) { + if (isColorMeaningful(newViewProps.htmlStyle.codeblock.backgroundColor)) { + [newConfig setCodeBlockBgColor:RCTUIColorFromSharedColor( + newViewProps.htmlStyle.codeblock + .backgroundColor)]; + stylePropChanged = YES; + } + } + if (newViewProps.htmlStyle.codeblock.borderRadius != + oldViewProps.htmlStyle.codeblock.borderRadius) { + [newConfig + setCodeBlockBorderRadius:newViewProps.htmlStyle.codeblock.borderRadius]; + stylePropChanged = YES; + } + + // ordered list + if (newViewProps.htmlStyle.ol.gapWidth != + oldViewProps.htmlStyle.ol.gapWidth) { + [newConfig setOrderedListGapWidth:newViewProps.htmlStyle.ol.gapWidth]; + stylePropChanged = YES; + } + if (newViewProps.htmlStyle.ol.marginLeft != + oldViewProps.htmlStyle.ol.marginLeft) { + [newConfig setOrderedListMarginLeft:newViewProps.htmlStyle.ol.marginLeft]; + stylePropChanged = YES; + } + if (newViewProps.htmlStyle.ol.markerFontWeight != + oldViewProps.htmlStyle.ol.markerFontWeight || + isFirstMount) { + if (!newViewProps.htmlStyle.ol.markerFontWeight.empty()) { + [newConfig + setOrderedListMarkerFontWeight: + [NSString + fromCppString:newViewProps.htmlStyle.ol.markerFontWeight]]; + } else { + [newConfig setOrderedListMarkerFontWeight:[newConfig primaryFontWeight]]; + } + stylePropChanged = YES; + } + if (newViewProps.htmlStyle.ol.markerColor != + oldViewProps.htmlStyle.ol.markerColor || + isFirstMount) { + if (isColorMeaningful(newViewProps.htmlStyle.ol.markerColor)) { + [newConfig + setOrderedListMarkerColor:RCTUIColorFromSharedColor( + newViewProps.htmlStyle.ol.markerColor)]; + } else { + [newConfig setOrderedListMarkerColor:[newConfig primaryColor]]; + } + stylePropChanged = YES; + } + + // unordered list + if (newViewProps.htmlStyle.ul.bulletColor != + oldViewProps.htmlStyle.ul.bulletColor) { + if (isColorMeaningful(newViewProps.htmlStyle.ul.bulletColor)) { + [newConfig setUnorderedListBulletColor:RCTUIColorFromSharedColor( + newViewProps.htmlStyle.ul + .bulletColor)]; + stylePropChanged = YES; + } + } + if (newViewProps.htmlStyle.ul.bulletSize != + oldViewProps.htmlStyle.ul.bulletSize) { + [newConfig setUnorderedListBulletSize:newViewProps.htmlStyle.ul.bulletSize]; + stylePropChanged = YES; + } + if (newViewProps.htmlStyle.ul.gapWidth != + oldViewProps.htmlStyle.ul.gapWidth) { + [newConfig setUnorderedListGapWidth:newViewProps.htmlStyle.ul.gapWidth]; + stylePropChanged = YES; + } + if (newViewProps.htmlStyle.ul.marginLeft != + oldViewProps.htmlStyle.ul.marginLeft) { + [newConfig setUnorderedListMarginLeft:newViewProps.htmlStyle.ul.marginLeft]; + stylePropChanged = YES; + } + + // link + if (newViewProps.htmlStyle.a.color != oldViewProps.htmlStyle.a.color) { + if (isColorMeaningful(newViewProps.htmlStyle.a.color)) { + [newConfig setLinkColor:RCTUIColorFromSharedColor( + newViewProps.htmlStyle.a.color)]; + stylePropChanged = YES; + } + } + if (newViewProps.htmlStyle.a.pressColor != + oldViewProps.htmlStyle.a.pressColor) { + if (isColorMeaningful(newViewProps.htmlStyle.a.pressColor)) { + [newConfig setLinkPressColor:RCTUIColorFromSharedColor( + newViewProps.htmlStyle.a.pressColor)]; + stylePropChanged = YES; + } + } + if (newViewProps.htmlStyle.a.textDecorationLine != + oldViewProps.htmlStyle.a.textDecorationLine) { + NSString *objcString = + [NSString fromCppString:newViewProps.htmlStyle.a.textDecorationLine]; + if ([objcString isEqualToString:DecorationUnderline]) { + [newConfig setLinkDecorationLine:DecorationUnderline]; + } else { + [newConfig setLinkDecorationLine:DecorationNone]; + } + stylePropChanged = YES; + } + + // checkbox list + if (newViewProps.htmlStyle.ulCheckbox.boxSize != + oldViewProps.htmlStyle.ulCheckbox.boxSize) { + [newConfig + setCheckboxListBoxSize:newViewProps.htmlStyle.ulCheckbox.boxSize]; + stylePropChanged = YES; + } + if (newViewProps.htmlStyle.ulCheckbox.gapWidth != + oldViewProps.htmlStyle.ulCheckbox.gapWidth) { + [newConfig + setCheckboxListGapWidth:newViewProps.htmlStyle.ulCheckbox.gapWidth]; + stylePropChanged = YES; + } + if (newViewProps.htmlStyle.ulCheckbox.marginLeft != + oldViewProps.htmlStyle.ulCheckbox.marginLeft) { + [newConfig + setCheckboxListMarginLeft:newViewProps.htmlStyle.ulCheckbox.marginLeft]; + stylePropChanged = YES; + } + if (newViewProps.htmlStyle.ulCheckbox.boxColor != + oldViewProps.htmlStyle.ulCheckbox.boxColor) { + if (isColorMeaningful(newViewProps.htmlStyle.ulCheckbox.boxColor)) { + [newConfig setCheckboxListBoxColor:RCTUIColorFromSharedColor( + newViewProps.htmlStyle.ulCheckbox + .boxColor)]; + stylePropChanged = YES; + } + } + + // mention + folly::dynamic oldMentionStyle = oldViewProps.htmlStyle.mention; + folly::dynamic newMentionStyle = newViewProps.htmlStyle.mention; + if (oldMentionStyle != newMentionStyle) { + bool newSingleProps = NO; + + for (const auto &obj : newMentionStyle.items()) { + if (obj.second.isInt() || obj.second.isString()) { + newSingleProps = YES; + break; + } else if (obj.second.isObject()) { + newSingleProps = NO; + break; + } + } + + if (newSingleProps) { + [newConfig setMentionStyleProps: + [MentionStyleProps + getSinglePropsFromFollyDynamic:newMentionStyle]]; + } else { + [newConfig setMentionStyleProps: + [MentionStyleProps + getComplexPropsFromFollyDynamic:newMentionStyle]]; + } + + stylePropChanged = YES; + } + + // text prop + if (newViewProps.text != oldViewProps.text || isFirstMount) { + textChanged = YES; + } + + // ellipsizeMode + if (newViewProps.ellipsizeMode != oldViewProps.ellipsizeMode || + isFirstMount) { + NSString *mode = [NSString fromCppString:newViewProps.ellipsizeMode]; + if ([mode isEqualToString:@"head"]) { + textView.textContainer.lineBreakMode = NSLineBreakByTruncatingHead; + } else if ([mode isEqualToString:@"middle"]) { + textView.textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle; + } else if ([mode isEqualToString:@"clip"]) { + textView.textContainer.lineBreakMode = NSLineBreakByClipping; + } else { + textView.textContainer.lineBreakMode = NSLineBreakByTruncatingTail; + } + } + + // numberOfLines + if (newViewProps.numberOfLines != oldViewProps.numberOfLines || + isFirstMount) { + textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines; + } + + // selectable + if (newViewProps.selectable != oldViewProps.selectable || isFirstMount) { + textView.selectable = newViewProps.selectable; + } + + // selectionColor + if (newViewProps.selectionColor != oldViewProps.selectionColor) { + if (isColorMeaningful(newViewProps.selectionColor)) { + textView.tintColor = + RCTUIColorFromSharedColor(newViewProps.selectionColor); + } + } + + if (stylePropChanged) { + config = newConfig; + } + + [self syncDefaultTypingAttributesFromConfig]; + + if (textChanged || stylePropChanged) { + [self renderText:[NSString fromCppString:newViewProps.text]]; + } + + [super updateProps:props oldProps:oldProps]; + [self tryUpdatingHeight]; +} + +- (void)syncDefaultTypingAttributesFromConfig { + defaultTypingAttributes[NSForegroundColorAttributeName] = + [config primaryColor]; + defaultTypingAttributes[NSFontAttributeName] = [config primaryFont]; + defaultTypingAttributes[NSUnderlineColorAttributeName] = + [config primaryColor]; + defaultTypingAttributes[NSStrikethroughColorAttributeName] = + [config primaryColor]; + NSMutableParagraphStyle *pStyle = [[NSMutableParagraphStyle alloc] init]; + pStyle.minimumLineHeight = [config scaledPrimaryLineHeight]; + defaultTypingAttributes[NSParagraphStyleAttributeName] = pStyle; + textView.typingAttributes = defaultTypingAttributes; +} + +// MARK: - Rendering + +- (void)renderText:(NSString *)html { + if (html.length == 0) { + [textView.textStorage + setAttributedString:[[NSAttributedString alloc] initWithString:@""]]; + return; + } + + NSString *normalized = [HtmlParser initiallyProcessHtml:html + useHtmlNormalizer:YES]; + if (normalized == nil) { + [textView.textStorage + setAttributedString:[[NSAttributedString alloc] + initWithString:html + attributes:defaultTypingAttributes]]; + return; + } + + NSArray *result = [HtmlParser getTextAndStylesFromHtml:normalized]; + NSString *plainText = result[0]; + NSArray *processedStyles = result[1]; + + NSMutableAttributedString *body = [[NSMutableAttributedString alloc] + initWithString:plainText + attributes:defaultTypingAttributes]; + [textView.textStorage setAttributedString:body]; + [self applyProcessedStyles:processedStyles]; + [self layoutAttachments]; +} + +- (void)applyProcessedStyles:(NSArray *)processedStyles { + // Some paragraph styles (codeblock, blockquote, etc.) insert \u200B + // into empty lines, mutating NSTextStorage length. We need to + // shift subsequent ranges by this offset. + NSInteger zeroWidthSpaceOffset = 0; + + // Inline styles collected during the first pass so their applyStyling: can + // be re-run after all paragraph styles have applied their visual attributes. + // Each entry is @[style, adjustedRange]. + NSMutableArray *pendingInlineApply = [NSMutableArray array]; + + // Paragraph styles call applyStyling: immediately; inline styles + // defer it so that paragraph visual attributes are already in + // place when inline styles override them. + for (NSArray *arr in processedStyles) { + NSNumber *styleType = (NSNumber *)arr[0]; + StylePair *stylePair = (StylePair *)arr[1]; + StyleBase *style = stylesDict[styleType]; + if (style == nullptr) + continue; + + NSRange parsedRange = [stylePair.rangeValue rangeValue]; + NSUInteger textLengthBeforeStyleApplied = + textView.textStorage.string.length; + + // Range must be taking zeroWidthSpaceOffset into consideration + // because processed styles ranges are relative to only the new text while + // we need absolute ranges relative to the whole existing text + NSRange styleRange = NSMakeRange( + zeroWidthSpaceOffset + parsedRange.location, parsedRange.length); + + if (![StyleUtils handleStyleBlocksAndConflicts:[[style class] getType] + range:styleRange + forHost:self]) { + continue; + } + + if ([styleType isEqualToNumber:@([LinkStyle getType])]) { + LinkData *linkData = (LinkData *)stylePair.styleValue; + [((LinkStyle *)style) applyLinkMetaWithData:linkData range:styleRange]; + } else if ([styleType isEqualToNumber:@([MentionStyle getType])]) { + MentionParams *params = (MentionParams *)stylePair.styleValue; + [((MentionStyle *)style) applyMentionMeta:params range:styleRange]; + } else if ([styleType isEqualToNumber:@([ImageStyle getType])]) { + ImageData *imgData = (ImageData *)stylePair.styleValue; + [((ImageStyle *)style) addImageAtRange:styleRange + imageData:imgData + withSelection:NO + withDirtyRange:NO]; + } else if ([styleType isEqualToNumber:@([CheckboxListStyle getType])]) { + NSDictionary *checkboxStates = (NSDictionary *)stylePair.styleValue; + CheckboxListStyle *cbStyle = (CheckboxListStyle *)style; + + [cbStyle addWithChecked:NO + range:styleRange + withTyping:NO + withDirtyRange:NO]; + + if (checkboxStates && checkboxStates.count > 0) { + for (NSNumber *key in checkboxStates) { + NSUInteger checkboxPosition = + zeroWidthSpaceOffset + [key unsignedIntegerValue]; + BOOL isChecked = [checkboxStates[key] boolValue]; + + if (isChecked) { + [cbStyle toggleCheckedAt:checkboxPosition withDirtyRange:NO]; + } + } + } + } else { + [style add:styleRange withTyping:NO withDirtyRange:NO]; + } + + [ZeroWidthSpaceUtils addSpacesIfNeededinInput:self inRange:styleRange]; + + NSInteger delta = + textView.textStorage.string.length - textLengthBeforeStyleApplied; + + // Use an adjusted range so that applyStyling covers any ZWS characters that + // were just inserted by addSpacesIfNeededinInput:inRange:. Without this, a + // style applied to an empty range {0,0} would call applyStyling on {0,0} + // even after a ZWS was inserted. + NSRange adjustedStyleRange = NSMakeRange( + styleRange.location, styleRange.length + (NSUInteger)MAX(0LL, delta)); + + if ([style isParagraph]) { + [style applyStyling:adjustedStyleRange]; + } else { + [pendingInlineApply + addObject:@[ style, [NSValue valueWithRange:adjustedStyleRange] ]]; + } + + // Image shifts are already handled by _precedingImageCount during tag + // finalization. + if (delta != 0 && ![styleType isEqualToNumber:@([ImageStyle getType])]) { + zeroWidthSpaceOffset += delta; + } + } + + // Apply visual styling for inline styles + for (NSArray *entry in pendingInlineApply) { + StyleBase *style = entry[0]; + NSRange adjustedStyleRange = [((NSValue *)entry[1]) rangeValue]; + [style applyStyling:adjustedStyleRange]; + } +} + +// MARK: - Measuring and state + +- (CGSize)measureSize:(CGFloat)maxWidth { + if (textView.textStorage.length == 0) { + return CGSizeMake(maxWidth, 0); + } + + NSMutableAttributedString *currentStr = [[NSMutableAttributedString alloc] + initWithAttributedString:textView.textStorage]; + + // edge case: input with only a zero width space should still be of a height + // of a single line, so we add a mock "I" character + if ([currentStr length] == 1 && + [[currentStr.string substringWithRange:NSMakeRange(0, 1)] + isEqualToString:@"\u200B"]) { + [currentStr + appendAttributedString:[[NSAttributedString alloc] + initWithString:@"I" + attributes:defaultTypingAttributes]]; + } + + // edge case: trailing newlines aren't counted towards height calculations, so + // we add a mock "I" character + if (currentStr.length > 0) { + unichar lastChar = + [currentStr.string characterAtIndex:currentStr.length - 1]; + if ([[NSCharacterSet newlineCharacterSet] characterIsMember:lastChar]) { + [currentStr + appendAttributedString:[[NSAttributedString alloc] + initWithString:@"I" + attributes:defaultTypingAttributes]]; + } + } + + CGRect boundingBox = + [currentStr boundingRectWithSize:CGSizeMake(maxWidth, CGFLOAT_MAX) + options:NSStringDrawingUsesLineFragmentOrigin | + NSStringDrawingUsesFontLeading + context:nullptr]; + + return CGSizeMake(maxWidth, ceil(boundingBox.size.height)); +} + +- (void)updateState:(State::Shared const &)state + oldState:(State::Shared const &)oldState { + _state = + std::static_pointer_cast( + state); + + if (oldState == nullptr) { + [self tryUpdatingHeight]; + } +} + +- (void)tryUpdatingHeight { + if (_state == nullptr) { + return; + } + auto selfRef = wrapManagedObjectWeakly(self); + _state->updateState(EnrichedTextViewState(selfRef)); +} + +- (std::shared_ptr)getEventEmitter { + if (_eventEmitter != nullptr) { + auto emitter = + static_cast(*_eventEmitter); + return std::make_shared(emitter); + } + return nullptr; +} + +- (void)emitOnLinkPressEvent:(NSString *)url { + if (!url) + return; + auto emitter = [self getEventEmitter]; + if (emitter != nullptr) { + emitter->onLinkPress({.url = [url toCppString]}); + } +} + +- (void)emitOnMentionPressEvent:(MentionParams *)mention { + auto emitter = [self getEventEmitter]; + if (emitter != nullptr) { + folly::dynamic attrsObj = folly::dynamic::object; + if (mention.attributes != nil) { + NSData *data = + [mention.attributes dataUsingEncoding:NSUTF8StringEncoding]; + NSError *error = nil; + NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data + options:0 + error:&error]; + if (error != nil) { + NSLog(@"[EnrichedTextView] Failed to parse mention attributes JSON: %@", + error); + return; + } + + for (NSString *key in dict) { + id val = dict[key]; + if ([val isKindOfClass:[NSString class]]) { + attrsObj[[key toCppString]] = [val toCppString]; + } + } + } + + emitter->onMentionPress({ + .text = mention.text ? [mention.text toCppString] : std::string{}, + .indicator = + mention.indicator ? [mention.indicator toCppString] : std::string{}, + .attributes = attrsObj, + }); + } +} + +// MARK: - Media attachments delegate + +- (void)mediaAttachmentDidUpdate:(MediaAttachment *)attachment { + [AttachmentLayoutUtils handleAttachmentUpdate:attachment + textView:textView + onLayoutBlock:^{ + [self layoutAttachments]; + [self tryUpdatingHeight]; + }]; +} + +- (void)layoutAttachments { + _attachmentViews = + [AttachmentLayoutUtils layoutAttachmentsInTextView:textView + config:config + existingViews:_attachmentViews]; +} + +@end diff --git a/ios/EnrichedTextViewManager.mm b/ios/EnrichedTextViewManager.mm new file mode 100644 index 00000000..2e65c1c3 --- /dev/null +++ b/ios/EnrichedTextViewManager.mm @@ -0,0 +1,13 @@ +#import +#import + +@interface EnrichedTextViewManager : RCTViewManager +@end + +@implementation EnrichedTextViewManager + +RCT_EXPORT_MODULE(EnrichedTextView) + +RCT_EXPORT_VIEW_PROPERTY(text, NSString) + +@end diff --git a/ios/config/InputConfig.h b/ios/config/EnrichedConfig.h similarity index 96% rename from ios/config/InputConfig.h rename to ios/config/EnrichedConfig.h index 73685213..44a7c910 100644 --- a/ios/config/InputConfig.h +++ b/ios/config/EnrichedConfig.h @@ -4,7 +4,7 @@ #import "TextDecorationLineEnum.h" #import -@interface InputConfig : NSObject +@interface EnrichedConfig : NSObject - (instancetype)init; - (UIColor *)primaryColor; - (void)setPrimaryColor:(UIColor *)newValue; @@ -101,4 +101,8 @@ - (void)setCheckboxListBoxColor:(UIColor *)newValue; - (UIImage *)checkboxCheckedImage; - (UIImage *)checkboxUncheckedImage; + +// MARK: - Text only props +- (UIColor *)linkPressColor; +- (void)setLinkPressColor:(UIColor *)newValue; @end diff --git a/ios/config/InputConfig.mm b/ios/config/EnrichedConfig.mm similarity index 97% rename from ios/config/InputConfig.mm rename to ios/config/EnrichedConfig.mm index 6bc55d7f..327bddd5 100644 --- a/ios/config/InputConfig.mm +++ b/ios/config/EnrichedConfig.mm @@ -1,7 +1,7 @@ -#import +#import #import -@implementation InputConfig { +@implementation EnrichedConfig { UIColor *_primaryColor; NSNumber *_primaryFontSize; CGFloat _primaryLineHeight; @@ -54,6 +54,9 @@ @implementation InputConfig { UIColor *_checkboxListBoxColor; UIImage *_checkboxCheckedImage; UIImage *_checkboxUncheckedImage; + + // text only + UIColor *_linkPressColor; } - (instancetype)init { @@ -65,7 +68,7 @@ - (instancetype)init { } - (id)copyWithZone:(NSZone *)zone { - InputConfig *copy = [[[self class] allocWithZone:zone] init]; + EnrichedConfig *copy = [[[self class] allocWithZone:zone] init]; copy->_primaryColor = [_primaryColor copy]; copy->_primaryFontSize = [_primaryFontSize copy]; copy->_primaryLineHeight = _primaryLineHeight; @@ -115,6 +118,9 @@ - (id)copyWithZone:(NSZone *)zone { copy->_checkboxListBoxColor = [_checkboxListBoxColor copy]; copy->_checkboxCheckedImage = _checkboxCheckedImage; copy->_checkboxUncheckedImage = _checkboxUncheckedImage; + + // text only + copy->_linkPressColor = [_linkPressColor copy]; return copy; } @@ -661,4 +667,14 @@ - (UIImage *)generateCheckboxImage:(BOOL)isChecked { return result; } +// MARK: - Text only props + +- (UIColor *)linkPressColor { + return _linkPressColor; +} + +- (void)setLinkPressColor:(UIColor *)newValue { + _linkPressColor = newValue; +} + @end diff --git a/ios/extensions/LayoutManagerExtension.mm b/ios/extensions/LayoutManagerExtension.mm index 58f4620a..5a9b1797 100644 --- a/ios/extensions/LayoutManagerExtension.mm +++ b/ios/extensions/LayoutManagerExtension.mm @@ -1,6 +1,6 @@ #import "LayoutManagerExtension.h" #import "ColorExtension.h" -#import "EnrichedTextInputView.h" +#import "EnrichedViewHost.h" #import "RangeUtils.h" #import "StyleHeaders.h" #import "WeakBox.h" @@ -49,28 +49,23 @@ - (void)my_drawBackgroundForGlyphRange:(NSRange)glyphRange atPoint:(CGPoint)origin { [self my_drawBackgroundForGlyphRange:glyphRange atPoint:origin]; - EnrichedTextInputView *typedInput = (EnrichedTextInputView *)self.input; - if (typedInput == nullptr) { + id host = self.input; + if (host == nullptr) { return; } NSRange visibleCharRange = [self characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; - [self drawBlockQuotes:typedInput - origin:origin - visibleCharRange:visibleCharRange]; - [self drawLists:typedInput origin:origin visibleCharRange:visibleCharRange]; - [self drawCodeBlocks:typedInput - origin:origin - visibleCharRange:visibleCharRange]; + [self drawBlockQuotes:host origin:origin visibleCharRange:visibleCharRange]; + [self drawLists:host origin:origin visibleCharRange:visibleCharRange]; + [self drawCodeBlocks:host origin:origin visibleCharRange:visibleCharRange]; } -- (void)drawCodeBlocks:(EnrichedTextInputView *)typedInput +- (void)drawCodeBlocks:(id)host origin:(CGPoint)origin visibleCharRange:(NSRange)visibleCharRange { - CodeBlockStyle *codeBlockStyle = - typedInput->stylesDict[@([CodeBlockStyle getType])]; + CodeBlockStyle *codeBlockStyle = host.stylesDict[@([CodeBlockStyle getType])]; if (codeBlockStyle == nullptr) { return; } @@ -78,9 +73,9 @@ - (void)drawCodeBlocks:(EnrichedTextInputView *)typedInput NSArray *allCodeBlocks = [codeBlockStyle all:visibleCharRange]; NSArray *mergedCodeBlocks = [self mergeContiguousStylePairs:allCodeBlocks]; - UIColor *bgColor = [[typedInput->config codeBlockBgColor] - colorWithAlphaIfNotTransparent:0.4]; - CGFloat radius = [typedInput->config codeBlockBorderRadius]; + UIColor *bgColor = + [[host.config codeBlockBgColor] colorWithAlphaIfNotTransparent:0.4]; + CGFloat radius = [host.config codeBlockBorderRadius]; [bgColor setFill]; for (StylePair *pair in mergedCodeBlocks) { @@ -89,7 +84,7 @@ - (void)drawCodeBlocks:(EnrichedTextInputView *)typedInput continue; NSArray *paragraphs = - [RangeUtils getSeparateParagraphsRangesIn:typedInput->textView + [RangeUtils getSeparateParagraphsRangesIn:host.textView range:blockCharacterRange]; if (paragraphs.count == 0) continue; @@ -204,11 +199,10 @@ - (void)drawCodeBlocks:(EnrichedTextInputView *)typedInput return mergedPairs; } -- (void)drawBlockQuotes:(EnrichedTextInputView *)typedInput +- (void)drawBlockQuotes:(id)host origin:(CGPoint)origin visibleCharRange:(NSRange)visibleCharRange { - BlockQuoteStyle *bqStyle = - typedInput->stylesDict[@([BlockQuoteStyle getType])]; + BlockQuoteStyle *bqStyle = host.stylesDict[@([BlockQuoteStyle getType])]; if (bqStyle == nullptr) { return; } @@ -216,7 +210,7 @@ - (void)drawBlockQuotes:(EnrichedTextInputView *)typedInput NSArray *allBlockquotes = [bqStyle all:visibleCharRange]; for (StylePair *pair in allBlockquotes) { - NSRange paragraphRange = [typedInput->textView.textStorage.string + NSRange paragraphRange = [host.textView.textStorage.string paragraphRangeForRange:[pair.rangeValue rangeValue]]; NSRange paragraphGlyphRange = [self glyphRangeForCharacterRange:paragraphRange @@ -232,48 +226,47 @@ - (void)drawBlockQuotes:(EnrichedTextInputView *)typedInput CGFloat x = paddingLeft; CGFloat y = paddingTop + rect.origin.y; CGFloat width = - [typedInput - ->config blockquoteBorderWidth]; + [host.config blockquoteBorderWidth]; CGFloat height = rect.size.height; CGRect lineRect = CGRectMake(x, y, width, height); - [[typedInput->config blockquoteBorderColor] + [[host.config blockquoteBorderColor] setFill]; UIRectFill(lineRect); }]; } } -- (void)drawLists:(EnrichedTextInputView *)typedInput +- (void)drawLists:(id)host origin:(CGPoint)origin visibleCharRange:(NSRange)visibleCharRange { UnorderedListStyle *ulStyle = - typedInput->stylesDict[@([UnorderedListStyle getType])]; - OrderedListStyle *olStyle = - typedInput->stylesDict[@([OrderedListStyle getType])]; - CheckboxListStyle *cbStyle = - typedInput->stylesDict[@([CheckboxListStyle getType])]; - if (ulStyle == nullptr || olStyle == nullptr || cbStyle == nullptr) { - return; - } + host.stylesDict[@([UnorderedListStyle getType])]; + OrderedListStyle *olStyle = host.stylesDict[@([OrderedListStyle getType])]; + CheckboxListStyle *cbStyle = host.stylesDict[@([CheckboxListStyle getType])]; NSMutableArray *allLists = [[NSMutableArray alloc] init]; - [allLists addObjectsFromArray:[ulStyle all:visibleCharRange]]; - [allLists addObjectsFromArray:[olStyle all:visibleCharRange]]; - [allLists addObjectsFromArray:[cbStyle all:visibleCharRange]]; + if (ulStyle != nullptr) { + [allLists addObjectsFromArray:[ulStyle all:visibleCharRange]]; + } + if (olStyle != nullptr) { + [allLists addObjectsFromArray:[olStyle all:visibleCharRange]]; + } + if (cbStyle != nullptr) { + [allLists addObjectsFromArray:[cbStyle all:visibleCharRange]]; + } for (StylePair *pair in allLists) { NSParagraphStyle *pStyle = (NSParagraphStyle *)pair.styleValue; NSDictionary *markerAttributes = @{ - NSFontAttributeName : [typedInput->config orderedListMarkerFont], - NSForegroundColorAttributeName : - [typedInput->config orderedListMarkerColor] + NSFontAttributeName : [host.config orderedListMarkerFont], + NSForegroundColorAttributeName : [host.config orderedListMarkerColor] }; NSArray *paragraphs = - [RangeUtils getSeparateParagraphsRangesIn:typedInput->textView + [RangeUtils getSeparateParagraphsRangesIn:host.textView range:[pair.rangeValue rangeValue]]; for (NSValue *paragraph in paragraphs) { @@ -290,11 +283,10 @@ - (void)drawLists:(EnrichedTextInputView *)typedInput NSUInteger charIdx = [self characterIndexForGlyphAtIndex: lineGlyphRange.location]; - UIFont *font = - [typedInput->textView.textStorage - attribute:NSFontAttributeName - atIndex:charIdx - effectiveRange:nil]; + UIFont *font = [host.textView.textStorage + attribute:NSFontAttributeName + atIndex:charIdx + effectiveRange:nil]; CGRect textUsedRect = [self getTextAlignedUsedRect:usedRect font:font]; @@ -307,9 +299,9 @@ - (void)drawLists:(EnrichedTextInputView *)typedInput isEqualToString: @"EnrichedOrderedList"]) { NSString *marker = [self - getDecimalMarkerForList:typedInput + getDecimalMarkerForList:host charIndex:charIdx]; - [self drawDecimal:typedInput + [self drawDecimal:host marker:marker markerAttributes:markerAttributes origin:origin @@ -318,13 +310,13 @@ - (void)drawLists:(EnrichedTextInputView *)typedInput isEqualToString: @"EnrichedUnorderedLis" @"t"]) { - [self drawBullet:typedInput + [self drawBullet:host origin:origin usedRect:textUsedRect]; } else if ([markerFormat hasPrefix: @"EnrichedCheckbox"]) { - [self drawCheckbox:typedInput + [self drawCheckbox:host markerFormat:markerFormat origin:origin usedRect:textUsedRect]; @@ -337,16 +329,15 @@ - (void)drawLists:(EnrichedTextInputView *)typedInput } } -- (NSString *)getDecimalMarkerForList:(EnrichedTextInputView *)input +- (NSString *)getDecimalMarkerForList:(id)host charIndex:(NSUInteger)index { - NSString *fullText = input->textView.textStorage.string; + NSString *fullText = host.textView.textStorage.string; NSInteger itemNumber = 1; NSRange currentParagraph = [fullText paragraphRangeForRange:NSMakeRange(index, 0)]; if (currentParagraph.location > 0) { - OrderedListStyle *olStyle = - input->stylesDict[@([OrderedListStyle getType])]; + OrderedListStyle *olStyle = host.stylesDict[@([OrderedListStyle getType])]; NSInteger prevParagraphsCount = 0; NSInteger recentParagraphLocation = @@ -392,16 +383,16 @@ - (CGRect)getTextAlignedUsedRect:(CGRect)usedRect font:(UIFont *)font { return usedRect; } -- (void)drawCheckbox:(EnrichedTextInputView *)typedInput +- (void)drawCheckbox:(id)host markerFormat:(NSString *)markerFormat origin:(CGPoint)origin usedRect:(CGRect)usedRect { BOOL isChecked = [markerFormat isEqualToString:@"EnrichedCheckbox1"]; - UIImage *image = isChecked ? typedInput->config.checkboxCheckedImage - : typedInput->config.checkboxUncheckedImage; - CGFloat gapWidth = [typedInput->config checkboxListGapWidth]; - CGFloat boxSize = [typedInput->config checkboxListBoxSize]; + UIImage *image = isChecked ? host.config.checkboxCheckedImage + : host.config.checkboxUncheckedImage; + CGFloat gapWidth = [host.config checkboxListGapWidth]; + CGFloat boxSize = [host.config checkboxListBoxSize]; CGFloat centerY = CGRectGetMidY(usedRect) + origin.y; CGFloat boxX = origin.x + usedRect.origin.x - gapWidth - boxSize; @@ -410,18 +401,18 @@ - (void)drawCheckbox:(EnrichedTextInputView *)typedInput [image drawAtPoint:CGPointMake(boxX, boxY)]; } -- (void)drawBullet:(EnrichedTextInputView *)typedInput +- (void)drawBullet:(id)host origin:(CGPoint)origin usedRect:(CGRect)usedRect { - CGFloat gapWidth = [typedInput->config unorderedListGapWidth]; - CGFloat bulletSize = [typedInput->config unorderedListBulletSize]; + CGFloat gapWidth = [host.config unorderedListGapWidth]; + CGFloat bulletSize = [host.config unorderedListBulletSize]; CGFloat bulletX = origin.x + usedRect.origin.x - gapWidth - bulletSize / 2; CGFloat centerY = CGRectGetMidY(usedRect) + origin.y; CGContextRef context = UIGraphicsGetCurrentContext(); CGContextSaveGState(context); { - [[typedInput->config unorderedListBulletColor] setFill]; + [[host.config unorderedListBulletColor] setFill]; CGContextAddArc(context, bulletX, centerY, bulletSize / 2, 0, 2 * M_PI, YES); CGContextFillPath(context); @@ -429,12 +420,12 @@ - (void)drawBullet:(EnrichedTextInputView *)typedInput CGContextRestoreGState(context); } -- (void)drawDecimal:(EnrichedTextInputView *)typedInput +- (void)drawDecimal:(id)host marker:(NSString *)marker markerAttributes:(NSDictionary *)markerAttributes origin:(CGPoint)origin usedRect:(CGRect)usedRect { - CGFloat gapWidth = [typedInput->config orderedListGapWidth]; + CGFloat gapWidth = [host.config orderedListGapWidth]; CGSize markerSize = [marker sizeWithAttributes:markerAttributes]; CGFloat markerX = usedRect.origin.x - gapWidth - markerSize.width / 2; CGFloat centerY = CGRectGetMidY(usedRect) + origin.y; diff --git a/ios/htmlParser/HtmlParser.h b/ios/htmlParser/HtmlParser.h new file mode 100644 index 00000000..7453828c --- /dev/null +++ b/ios/htmlParser/HtmlParser.h @@ -0,0 +1,8 @@ +#pragma once +#import + +@interface HtmlParser : NSObject ++ (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html + useHtmlNormalizer:(BOOL)useHtmlNormalizer; ++ (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml; +@end diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm new file mode 100644 index 00000000..ee6919e6 --- /dev/null +++ b/ios/htmlParser/HtmlParser.mm @@ -0,0 +1,787 @@ +#import "HtmlParser.h" +#import "ImageData.h" +#import "LinkData.h" +#import "MentionParams.h" +#import "StringExtension.h" +#import "StyleHeaders.h" +#import "StylePair.h" + +#include "GumboParser.hpp" + +@implementation HtmlParser + ++ (BOOL)isBlockTag:(NSString *)tagName { + return [tagName isEqualToString:@"ul"] || [tagName isEqualToString:@"ol"] || + [tagName isEqualToString:@"blockquote"] || + [tagName isEqualToString:@"codeblock"]; +} + +/** + * Prepares HTML for the parser by stripping extraneous whitespace and newlines + * from structural tags, while preserving them within text content. + * + * APPROACH: + * This function treats the HTML as having two distinct states: + * 1. Structure Mode (Depth == 0): We are inside or between container tags (like + * blockquote, ul, codeblock). In this mode whitespace and newlines are + * considered layout artifacts and are REMOVED to prevent the parser from + * creating unwanted spaces. + * 2. Content Mode (Depth > 0): We are inside a text-containing tag (like p, + * b, li). In this mode, all whitespace is PRESERVED exactly as is, ensuring + * that sentences and inline formatting remain readable. + * + * The function iterates character-by-character, using a depth counter to track + * nesting levels of the specific tags defined in `textTags`. + * + * IMPORTANT: + * The `textTags` set acts as a whitelist for "Content Mode". If you add support + * for a new HTML tag that contains visible text (e.g., h4, h5, h6), + * you MUST add it to the `textTags` set below. + */ ++ (NSString *)stripExtraWhiteSpacesAndNewlines:(NSString *)html { + NSSet *textTags = [NSSet setWithObjects:@"p", @"h1", @"h2", @"h3", @"h4", + @"h5", @"h6", @"li", @"b", @"a", @"s", + @"mention", @"code", @"u", @"i", nil]; + + NSMutableString *output = [NSMutableString stringWithCapacity:html.length]; + NSMutableString *currentTagBuffer = [NSMutableString string]; + NSCharacterSet *whitespaceAndNewlineSet = + [NSCharacterSet whitespaceAndNewlineCharacterSet]; + + BOOL isReadingTag = NO; + NSInteger textDepth = 0; + + for (NSUInteger i = 0; i < html.length; i++) { + unichar c = [html characterAtIndex:i]; + + if (c == '<') { + isReadingTag = YES; + [currentTagBuffer setString:@""]; + [output appendString:@"<"]; + } else if (c == '>') { + isReadingTag = NO; + [output appendString:@">"]; + + NSString *fullTag = [currentTagBuffer lowercaseString]; + + NSString *cleanName = [fullTag + stringByTrimmingCharactersInSet: + [NSCharacterSet characterSetWithCharactersInString:@"/"]]; + NSArray *parts = + [cleanName componentsSeparatedByCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + NSString *tagName = parts.firstObject; + + if (![textTags containsObject:tagName]) { + continue; + } + + if ([fullTag hasPrefix:@"/"]) { + textDepth--; + if (textDepth < 0) + textDepth = 0; + } else { + // Opening tag (e.g.

) -> Enter Text Mode + // (Ignore self-closing tags like if they happen to be in the + // list) + if (![fullTag hasSuffix:@"/"]) { + textDepth++; + } + } + } else { + if (isReadingTag) { + [currentTagBuffer appendFormat:@"%C", c]; + [output appendFormat:@"%C", c]; + continue; + } + + if (textDepth > 0) { + [output appendFormat:@"%C", c]; + } else { + if (![whitespaceAndNewlineSet characterIsMember:c]) { + [output appendFormat:@"%C", c]; + } + } + } + } + + return output; +} + ++ (NSString *)stringByAddingNewlinesToTag:(NSString *)tag + inString:(NSString *)html + leading:(BOOL)leading + trailing:(BOOL)trailing { + NSString *str = [html copy]; + if (leading) { + NSString *formattedTag = [NSString stringWithFormat:@">%@", tag]; + NSString *formattedNewTag = [NSString stringWithFormat:@">\n%@", tag]; + str = [str stringByReplacingOccurrencesOfString:formattedTag + withString:formattedNewTag]; + } + if (trailing) { + NSString *formattedTag = [NSString stringWithFormat:@"%@<", tag]; + NSString *formattedNewTag = [NSString stringWithFormat:@"%@\n<", tag]; + str = [str stringByReplacingOccurrencesOfString:formattedTag + withString:formattedNewTag]; + } + return str; +} + +#pragma mark - External HTML normalization + +/** + * Normalizes external HTML (from Google Docs, Word, web pages, etc.) into our + * canonical tag subset using the Gumbo-based C++ normalizer. + * + * Converts: strong → b, em → i, span style="font-weight:bold" → b, + * strips unknown tags while preserving text + */ ++ (NSString *_Nullable)normalizeExternalHtml:(NSString *_Nonnull)html { + std::string result = + GumboParser::normalizeHtml(std::string([html UTF8String])); + if (result.empty()) + return nil; + return [NSString stringWithUTF8String:result.c_str()]; +} + ++ (void)finalizeTagEntry:(NSMutableString *)tagName + ongoingTags:(NSMutableDictionary *)ongoingTags + initiallyProcessedTags:(NSMutableArray *)processedTags + plainText:(NSMutableString *)plainText + precedingImageCount:(NSInteger *)precedingImageCount { + NSMutableArray *tagEntry = [[NSMutableArray alloc] init]; + + NSArray *tagData = ongoingTags[tagName]; + NSInteger tagLocation = [((NSNumber *)tagData[0]) intValue]; + NSInteger openImageCount = [((NSNumber *)tagData[1]) intValue]; + NSInteger currentImageCount = *precedingImageCount; + + // 'plainText' doesn't contain image placeholders yet, but the final + // NSTextStorage will, so each image adds one character that ranges here + // must account for. 'openImageCount' (captured when the tag opened) shifts + // the start past images finalized BEFORE this tag, while the diff against + // 'currentImageCount' extends the length to cover images finalized INSIDE + // it. + NSRange tagRange = NSMakeRange(tagLocation + openImageCount, + (plainText.length - tagLocation) + + (currentImageCount - openImageCount)); + + [tagEntry addObject:[tagName copy]]; + [tagEntry addObject:[NSValue valueWithRange:tagRange]]; + if (tagData.count > 2) { + [tagEntry addObject:[(NSString *)tagData[2] copy]]; + } + + [processedTags addObject:tagEntry]; + [ongoingTags removeObjectForKey:tagName]; + + if ([tagName isEqualToString:@"img"]) { + (*precedingImageCount)++; + } +} + ++ (BOOL)isUlCheckboxList:(NSString *)params { + return ([params containsString:@"data-type=\"checkbox\""] || + [params containsString:@"data-type='checkbox'"]); +} + ++ (NSDictionary *)prepareCheckboxListStyleValue:(NSValue *)rangeValue + checkboxStates:(NSDictionary *)checkboxStates { + NSRange range = [rangeValue rangeValue]; + NSMutableDictionary *statesInRange = [[NSMutableDictionary alloc] init]; + + for (NSNumber *key in checkboxStates) { + NSUInteger pos = [key unsignedIntegerValue]; + if (pos >= range.location && pos < range.location + range.length) { + [statesInRange setObject:checkboxStates[key] forKey:key]; + } + } + + return statesInRange; +} + ++ (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html + useHtmlNormalizer:(BOOL)useHtmlNormalizer { + NSString *htmlWithoutSpaces = [self stripExtraWhiteSpacesAndNewlines:html]; + NSString *fixedHtml = nullptr; + + if (htmlWithoutSpaces.length >= 13) { + NSString *firstSix = + [htmlWithoutSpaces substringWithRange:NSMakeRange(0, 6)]; + NSString *lastSeven = [htmlWithoutSpaces + substringWithRange:NSMakeRange(htmlWithoutSpaces.length - 7, 7)]; + + if ([firstSix isEqualToString:@""] && + [lastSeven isEqualToString:@""]) { + // remove html tags, might be with newlines or without them + fixedHtml = [htmlWithoutSpaces copy]; + // firstly remove newlined html tags if any: + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n" + withString:@""]; + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n" + withString:@""]; + // fallback; remove html tags without their newlines + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"" + withString:@""]; + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"" + withString:@""]; + } else if (useHtmlNormalizer) { + // External HTML (from Google Docs, Word, web pages, etc.) + // Run through the Gumbo-based normalizer to convert arbitrary HTML + // into our canonical tag subset. + NSString *normalized = [self normalizeExternalHtml:html]; + if (normalized != nil) { + fixedHtml = normalized; + } + } + + // Additionally, try getting the content from between body tags if there are + // some: + + // Firstly make sure there are no newlines between them. + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n" + withString:@""]; + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n" + withString:@""]; + // Then, if there actually are body tags, use the content between them. + NSRange openingBodyRange = [htmlWithoutSpaces rangeOfString:@""]; + NSRange closingBodyRange = [htmlWithoutSpaces rangeOfString:@""]; + if (openingBodyRange.length != 0 && closingBodyRange.length != 0) { + NSInteger newStart = openingBodyRange.location + 6; + NSInteger newEnd = closingBodyRange.location - 1; + fixedHtml = [htmlWithoutSpaces + substringWithRange:NSMakeRange(newStart, newEnd - newStart + 1)]; + } + } + + // second processing - try fixing htmls with wrong newlines' setup + if (fixedHtml != nullptr) { + // add
tag wherever needed + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"

" + withString:@"
"]; + + // remove

tags inside of

  • + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"
  • " + withString:@"

  • "]; + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"

  • " + withString:@""]; + + // change
    to
    + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"
    " + withString:@"
    "]; + + // remove

    tags around
    + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"


    " + withString:@"
    "]; + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"

    " + withString:@"
    "]; + + // add
    tags inside empty blockquote and codeblock tags + fixedHtml = [fixedHtml + stringByReplacingOccurrencesOfString:@"
    " + withString:@"

    "]; + fixedHtml = [fixedHtml + stringByReplacingOccurrencesOfString:@"" + withString:@"
    "]; + + // remove empty ul and ol tags + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"
      " + withString:@""]; + fixedHtml = [fixedHtml + stringByReplacingOccurrencesOfString:@"
        " + withString:@""]; + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"
          " + withString:@""]; + + // tags that have to be in separate lines + fixedHtml = [self stringByAddingNewlinesToTag:@"
          " + inString:fixedHtml + leading:YES + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"
            " + inString:fixedHtml + leading:YES + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"
          " + inString:fixedHtml + leading:YES + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"
            " + inString:fixedHtml + leading:YES + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"
          " + inString:fixedHtml + leading:YES + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"
          " + inString:fixedHtml + leading:YES + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"
          " + inString:fixedHtml + leading:YES + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"" + inString:fixedHtml + leading:YES + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"" + inString:fixedHtml + leading:YES + trailing:YES]; + + // line opening tags + fixedHtml = [self stringByAddingNewlinesToTag:@"

          " + inString:fixedHtml + leading:YES + trailing:NO]; + fixedHtml = [self stringByAddingNewlinesToTag:@"

        1. " + inString:fixedHtml + leading:YES + trailing:NO]; + fixedHtml = [self stringByAddingNewlinesToTag:@"
        2. " + inString:fixedHtml + leading:YES + trailing:NO]; + fixedHtml = [self stringByAddingNewlinesToTag:@"

          " + inString:fixedHtml + leading:YES + trailing:NO]; + fixedHtml = [self stringByAddingNewlinesToTag:@"

          " + inString:fixedHtml + leading:YES + trailing:NO]; + fixedHtml = [self stringByAddingNewlinesToTag:@"

          " + inString:fixedHtml + leading:YES + trailing:NO]; + fixedHtml = [self stringByAddingNewlinesToTag:@"

          " + inString:fixedHtml + leading:YES + trailing:NO]; + fixedHtml = [self stringByAddingNewlinesToTag:@"

          " + inString:fixedHtml + leading:YES + trailing:NO]; + fixedHtml = [self stringByAddingNewlinesToTag:@"
          " + inString:fixedHtml + leading:YES + trailing:NO]; + + // line closing tags + fixedHtml = [self stringByAddingNewlinesToTag:@"

          " + inString:fixedHtml + leading:NO + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"
        3. " + inString:fixedHtml + leading:NO + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"

          " + inString:fixedHtml + leading:NO + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"" + inString:fixedHtml + leading:NO + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"" + inString:fixedHtml + leading:NO + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"" + inString:fixedHtml + leading:NO + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"" + inString:fixedHtml + leading:NO + trailing:YES]; + fixedHtml = [self stringByAddingNewlinesToTag:@"" + inString:fixedHtml + leading:NO + trailing:YES]; + + // this is more like a hack but for some reason the last
          in + //
          and are not properly changed into zero width + // space so we do that manually here + fixedHtml = [fixedHtml + stringByReplacingOccurrencesOfString:@"
          \n
          " + withString:@"

          \u200B

          \n"]; + fixedHtml = [fixedHtml + stringByReplacingOccurrencesOfString:@"
          \n" + withString:@"

          \u200B

          \n"]; + + // The same like above for (blockquote and codeblock) this is more like a + // hack but for some reason the last
        4. in
            and
              are not + // properly changed into zero width space so we do that manually here + // TODO: investigate this further, issue is already described here: + // https://github.com/software-mansion/react-native-enriched/issues/505 + fixedHtml = [fixedHtml + stringByReplacingOccurrencesOfString:@"
            1. \n
          " + withString:@"
        5. \u200B
        6. \n"]; + fixedHtml = [fixedHtml + stringByReplacingOccurrencesOfString:@"
        7. \n" + withString:@"
        8. \u200B
        9. \n"]; + + // replace "
          " at the end with "
          \n" if input is not empty to properly + // handle last
          in html + if ([fixedHtml hasSuffix:@"
          "] && fixedHtml.length != 4) { + fixedHtml = [fixedHtml stringByAppendingString:@"\n"]; + } + } + + return fixedHtml; +} + ++ (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml { + NSMutableString *plainText = [[NSMutableString alloc] initWithString:@""]; + NSMutableDictionary *ongoingTags = [[NSMutableDictionary alloc] init]; + NSMutableArray *initiallyProcessedTags = [[NSMutableArray alloc] init]; + NSMutableDictionary *checkboxStates = [[NSMutableDictionary alloc] init]; + BOOL insideCheckboxList = NO; + NSInteger precedingImageCount = 0; + BOOL insideTag = NO; + BOOL gettingTagName = NO; + BOOL gettingTagParams = NO; + BOOL closingTag = NO; + NSMutableString *currentTagName = + [[NSMutableString alloc] initWithString:@""]; + NSMutableString *currentTagParams = + [[NSMutableString alloc] initWithString:@""]; + NSDictionary *htmlEntitiesDict = + [NSString getEscapedCharactersInfoFrom:fixedHtml]; + + // firstly, extract text and initially processed tags + for (int i = 0; i < fixedHtml.length; i++) { + NSString *currentCharacterStr = + [fixedHtml substringWithRange:NSMakeRange(i, 1)]; + unichar currentCharacterChar = [fixedHtml characterAtIndex:i]; + + if (currentCharacterChar == '<') { + // opening the tag, mark that we are inside and getting its name + insideTag = YES; + gettingTagName = YES; + } else if (currentCharacterChar == '>') { + // finishing some tag, no longer marked as inside or getting its + // name/params + insideTag = NO; + gettingTagName = NO; + gettingTagParams = NO; + + BOOL isSelfClosing = NO; + + // Check if params ended with '/' (e.g. ) + if ([currentTagParams hasSuffix:@"/"]) { + [currentTagParams + deleteCharactersInRange:NSMakeRange(currentTagParams.length - 1, + 1)]; + isSelfClosing = YES; + } + + if ([currentTagName isEqualToString:@"p"] || + [currentTagName isEqualToString:@"br"]) { + // do nothing, we don't include these tags in styles + } else if ([currentTagName isEqualToString:@"li"]) { + // Only track checkbox state if we're inside a checkbox list + if (insideCheckboxList && !closingTag) { + BOOL isChecked = [currentTagParams containsString:@"checked"]; + checkboxStates[@(plainText.length)] = @(isChecked); + } + } else if (!closingTag) { + // we finish opening tag - get its location, the current + // precedingImageCount and optionally params and put them under tag name + // key in ongoingTags. Storing the open-time image count lets + // finalizeTagEntry: correctly shift the start and extend the length + // so the range covers any images finalized between open and close. + NSMutableArray *tagArr = [[NSMutableArray alloc] init]; + [tagArr addObject:[NSNumber numberWithInteger:plainText.length]]; + [tagArr addObject:[NSNumber numberWithInteger:precedingImageCount]]; + if (currentTagParams.length > 0) { + [tagArr addObject:[currentTagParams copy]]; + } + ongoingTags[currentTagName] = tagArr; + + // Check if this is a checkbox list + if ([currentTagName isEqualToString:@"ul"] && + [self isUlCheckboxList:currentTagParams]) { + insideCheckboxList = YES; + } + + // skip one newline if it was added after opening tags that are in + // separate lines + if ([self isBlockTag:currentTagName] && i + 1 < fixedHtml.length && + [[NSCharacterSet newlineCharacterSet] + characterIsMember:[fixedHtml characterAtIndex:i + 1]]) { + i += 1; + } + + if (isSelfClosing) { + [self finalizeTagEntry:currentTagName + ongoingTags:ongoingTags + initiallyProcessedTags:initiallyProcessedTags + plainText:plainText + precedingImageCount:&precedingImageCount]; + } + } else { + // we finish closing tags - pack tag name, tag range and optionally tag + // params into an entry that goes inside initiallyProcessedTags + + // Check if we're closing a checkbox list by looking at the params + if ([currentTagName isEqualToString:@"ul"] && + [self isUlCheckboxList:currentTagParams]) { + insideCheckboxList = NO; + } + + BOOL isBlockTag = [self isBlockTag:currentTagName]; + + // skip one newline if it was added before some closing tags that are + // in separate lines + if (isBlockTag && plainText.length > 0 && + [[NSCharacterSet newlineCharacterSet] + characterIsMember:[plainText + characterAtIndex:plainText.length - 1]]) { + plainText = [[plainText + substringWithRange:NSMakeRange(0, plainText.length - 1)] + mutableCopy]; + } + + [self finalizeTagEntry:currentTagName + ongoingTags:ongoingTags + initiallyProcessedTags:initiallyProcessedTags + plainText:plainText + precedingImageCount:&precedingImageCount]; + } + // post-tag cleanup + closingTag = NO; + currentTagName = [[NSMutableString alloc] initWithString:@""]; + currentTagParams = [[NSMutableString alloc] initWithString:@""]; + } else { + if (!insideTag) { + // no tags logic - just append the right text + + // html entity on the index; use unescaped character and forward + // iterator accordingly + NSArray *entityInfo = htmlEntitiesDict[@(i)]; + if (entityInfo != nullptr) { + NSString *escaped = entityInfo[0]; + NSString *unescaped = entityInfo[1]; + [plainText appendString:unescaped]; + // the iterator will forward by 1 itself + i += escaped.length - 1; + } else { + [plainText appendString:currentCharacterStr]; + } + } else { + if (gettingTagName) { + if (currentCharacterChar == ' ') { + // no longer getting tag name - switch to params + gettingTagName = NO; + gettingTagParams = YES; + } else if (currentCharacterChar == '/') { + // mark that the tag is closing + closingTag = YES; + } else { + // append next tag char + [currentTagName appendString:currentCharacterStr]; + } + } else if (gettingTagParams) { + // append next tag params char + [currentTagParams appendString:currentCharacterStr]; + } + } + } + } + + // process tags into proper StyleType + StylePair values + NSMutableArray *processedStyles = [[NSMutableArray alloc] init]; + + for (NSArray *arr in initiallyProcessedTags) { + NSString *tagName = (NSString *)arr[0]; + NSValue *tagRangeValue = (NSValue *)arr[1]; + NSMutableString *params = [[NSMutableString alloc] initWithString:@""]; + if (arr.count > 2) { + [params appendString:(NSString *)arr[2]]; + } + + NSMutableArray *styleArr = [[NSMutableArray alloc] init]; + StylePair *stylePair = [[StylePair alloc] init]; + + if ([tagName isEqualToString:@"b"]) { + [styleArr addObject:@([BoldStyle getType])]; + } else if ([tagName isEqualToString:@"i"]) { + [styleArr addObject:@([ItalicStyle getType])]; + } else if ([tagName isEqualToString:@"img"]) { + NSRegularExpression *srcRegex = + [NSRegularExpression regularExpressionWithPattern:@"src=\"([^\"]+)\"" + options:0 + error:nullptr]; + NSTextCheckingResult *match = + [srcRegex firstMatchInString:params + options:0 + range:NSMakeRange(0, params.length)]; + + if (match == nullptr) { + continue; + } + + NSRange srcRange = match.range; + [styleArr addObject:@([ImageStyle getType])]; + // cut only the uri from the src="..." string + NSString *uri = + [params substringWithRange:NSMakeRange(srcRange.location + 5, + srcRange.length - 6)]; + ImageData *imageData = [[ImageData alloc] init]; + imageData.uri = uri; + + NSRegularExpression *widthRegex = [NSRegularExpression + regularExpressionWithPattern:@"width=\"([0-9.]+)\"" + options:0 + error:nil]; + NSTextCheckingResult *widthMatch = + [widthRegex firstMatchInString:params + options:0 + range:NSMakeRange(0, params.length)]; + + if (widthMatch) { + NSString *widthString = + [params substringWithRange:[widthMatch rangeAtIndex:1]]; + imageData.width = [widthString floatValue]; + } + + NSRegularExpression *heightRegex = [NSRegularExpression + regularExpressionWithPattern:@"height=\"([0-9.]+)\"" + options:0 + error:nil]; + NSTextCheckingResult *heightMatch = + [heightRegex firstMatchInString:params + options:0 + range:NSMakeRange(0, params.length)]; + + if (heightMatch) { + NSString *heightString = + [params substringWithRange:[heightMatch rangeAtIndex:1]]; + imageData.height = [heightString floatValue]; + } + + stylePair.styleValue = imageData; + } else if ([tagName isEqualToString:@"u"]) { + [styleArr addObject:@([UnderlineStyle getType])]; + } else if ([tagName isEqualToString:@"s"]) { + [styleArr addObject:@([StrikethroughStyle getType])]; + } else if ([tagName isEqualToString:@"code"]) { + [styleArr addObject:@([InlineCodeStyle getType])]; + } else if ([tagName isEqualToString:@"a"]) { + NSRegularExpression *hrefRegex = + [NSRegularExpression regularExpressionWithPattern:@"href=\".+\"" + options:0 + error:nullptr]; + NSTextCheckingResult *match = + [hrefRegex firstMatchInString:params + options:0 + range:NSMakeRange(0, params.length)]; + + if (match == nullptr) { + // same as on Android, no href (or empty href) equals no link style + continue; + } + + NSRange hrefRange = match.range; + [styleArr addObject:@([LinkStyle getType])]; + // cut only the url from the href="..." string + NSString *url = + [params substringWithRange:NSMakeRange(hrefRange.location + 6, + hrefRange.length - 7)]; + NSString *text = [plainText substringWithRange:tagRangeValue.rangeValue]; + + LinkData *linkData = [[LinkData alloc] init]; + linkData.url = url; + linkData.text = text; + linkData.isManual = ![text isEqualToString:url]; + + stylePair.styleValue = linkData; + } else if ([tagName isEqualToString:@"mention"]) { + [styleArr addObject:@([MentionStyle getType])]; + // extract html expression into dict using some regex + NSMutableDictionary *paramsDict = [[NSMutableDictionary alloc] init]; + NSString *pattern = @"(\\w+)=(['\"])(.*?)\\2"; + NSRegularExpression *regex = + [NSRegularExpression regularExpressionWithPattern:pattern + options:0 + error:nil]; + + [regex enumerateMatchesInString:params + options:0 + range:NSMakeRange(0, params.length) + usingBlock:^(NSTextCheckingResult *_Nullable result, + NSMatchingFlags flags, + BOOL *_Nonnull stop) { + if (result.numberOfRanges == 4) { + NSString *key = [params + substringWithRange:[result rangeAtIndex:1]]; + NSString *value = [params + substringWithRange:[result rangeAtIndex:3]]; + paramsDict[key] = value; + } + }]; + + MentionParams *mentionParams = [[MentionParams alloc] init]; + mentionParams.text = paramsDict[@"text"]; + mentionParams.indicator = paramsDict[@"indicator"]; + + [paramsDict removeObjectsForKeys:@[ @"text", @"indicator" ]]; + NSError *error; + NSData *attrsData = [NSJSONSerialization dataWithJSONObject:paramsDict + options:0 + error:&error]; + NSString *formattedAttrsString = + [[NSString alloc] initWithData:attrsData + encoding:NSUTF8StringEncoding]; + mentionParams.attributes = formattedAttrsString; + + stylePair.styleValue = mentionParams; + } else if ([tagName isEqualToString:@"h1"]) { + [styleArr addObject:@([H1Style getType])]; + } else if ([tagName isEqualToString:@"h2"]) { + [styleArr addObject:@([H2Style getType])]; + } else if ([tagName isEqualToString:@"h3"]) { + [styleArr addObject:@([H3Style getType])]; + } else if ([tagName isEqualToString:@"h4"]) { + [styleArr addObject:@([H4Style getType])]; + } else if ([tagName isEqualToString:@"h5"]) { + [styleArr addObject:@([H5Style getType])]; + } else if ([tagName isEqualToString:@"h6"]) { + [styleArr addObject:@([H6Style getType])]; + } else if ([tagName isEqualToString:@"ul"]) { + if ([self isUlCheckboxList:params]) { + [styleArr addObject:@([CheckboxListStyle getType])]; + stylePair.styleValue = + [self prepareCheckboxListStyleValue:tagRangeValue + checkboxStates:checkboxStates]; + } else { + [styleArr addObject:@([UnorderedListStyle getType])]; + } + } else if ([tagName isEqualToString:@"ol"]) { + [styleArr addObject:@([OrderedListStyle getType])]; + } else if ([tagName isEqualToString:@"blockquote"]) { + [styleArr addObject:@([BlockQuoteStyle getType])]; + } else if ([tagName isEqualToString:@"codeblock"]) { + [styleArr addObject:@([CodeBlockStyle getType])]; + } else { + // some other external tags like span just don't get put into the + // processed styles + continue; + } + + stylePair.rangeValue = tagRangeValue; + [styleArr addObject:stylePair]; + [processedStyles addObject:styleArr]; + } + + return @[ plainText, processedStyles ]; +} + +@end diff --git a/ios/inputParser/InputParser.mm b/ios/inputParser/InputParser.mm index ad08bb9c..1dbc264e 100644 --- a/ios/inputParser/InputParser.mm +++ b/ios/inputParser/InputParser.mm @@ -1,30 +1,21 @@ #import "InputParser.h" #import "EnrichedTextInputView.h" +#import "HtmlParser.h" #import "StringExtension.h" #import "StyleHeaders.h" #import "TextInsertionUtils.h" -#import "UIView+React.h" - -#include "GumboParser.hpp" +#import @implementation InputParser { EnrichedTextInputView __weak *_input; - NSInteger _precedingImageCount; } - (instancetype)initWithInput:(id)input { self = [super init]; _input = (EnrichedTextInputView *)input; - _precedingImageCount = 0; return self; } -- (BOOL)isBlockTag:(NSString *)tagName { - return [tagName isEqualToString:@"ul"] || [tagName isEqualToString:@"ol"] || - [tagName isEqualToString:@"blockquote"] || - [tagName isEqualToString:@"codeblock"]; -} - - (NSString *)parseToHtmlFromRange:(NSRange)range { NSInteger offset = range.location; NSString *text = @@ -592,12 +583,14 @@ - (NSString *)tagContentForStyle:(NSNumber *)style } - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { + NSArray *processingResult = [HtmlParser getTextAndStylesFromHtml:html]; + // reset the text first and reset typing attributes _input->textView.text = @""; _input->textView.typingAttributes = _input->defaultTypingAttributes; @try { - NSArray *processingResult = [self getTextAndStylesFromHtml:html]; + NSArray *processingResult = [HtmlParser getTextAndStylesFromHtml:html]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; @@ -620,7 +613,7 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { @try { - NSArray *processingResult = [self getTextAndStylesFromHtml:html]; + NSArray *processingResult = [HtmlParser getTextAndStylesFromHtml:html]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; @@ -648,7 +641,7 @@ - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { - (void)insertFromHtml:(NSString *_Nonnull)html location:(NSInteger)location { @try { - NSArray *processingResult = [self getTextAndStylesFromHtml:html]; + NSArray *processingResult = [HtmlParser getTextAndStylesFromHtml:html]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; @@ -718,7 +711,8 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles ImageData *imgData = (ImageData *)stylePair.styleValue; [((ImageStyle *)baseStyle) addImageAtRange:styleRange imageData:imgData - withSelection:NO]; + withSelection:NO + withDirtyRange:YES]; } else if ([styleType isEqualToNumber:@([CheckboxListStyle getType])]) { NSDictionary *checkboxStates = (NSDictionary *)stylePair.styleValue; CheckboxListStyle *cbLStyle = (CheckboxListStyle *)baseStyle; @@ -737,7 +731,7 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles offset + zeroWidthSpaceOffset + [key unsignedIntegerValue]; BOOL isChecked = [checkboxStates[key] boolValue]; if (isChecked) { - [cbLStyle toggleCheckedAt:checkboxPosition]; + [cbLStyle toggleCheckedAt:checkboxPosition withDirtyRange:YES]; } } } @@ -759,752 +753,9 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles [_input anyTextMayHaveBeenModified]; } -#pragma mark - External HTML normalization - -/** - * Normalizes external HTML (from Google Docs, Word, web pages, etc.) into our - * canonical tag subset using the Gumbo-based C++ normalizer. - * - * Converts: strong → b, em → i, span style="font-weight:bold" → b, - * strips unknown tags while preserving text - */ -- (NSString *_Nullable)normalizeExternalHtml:(NSString *_Nonnull)html { - std::string result = - GumboParser::normalizeHtml(std::string([html UTF8String])); - if (result.empty()) - return nil; - return [NSString stringWithUTF8String:result.c_str()]; -} - - (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html { - NSString *htmlWithoutSpaces = [self stripExtraWhiteSpacesAndNewlines:html]; - NSString *fixedHtml = nullptr; - - if (htmlWithoutSpaces.length >= 13) { - NSString *firstSix = - [htmlWithoutSpaces substringWithRange:NSMakeRange(0, 6)]; - NSString *lastSeven = [htmlWithoutSpaces - substringWithRange:NSMakeRange(htmlWithoutSpaces.length - 7, 7)]; - - if ([firstSix isEqualToString:@""] && - [lastSeven isEqualToString:@""]) { - // remove html tags, might be with newlines or without them - fixedHtml = [htmlWithoutSpaces copy]; - // firstly remove newlined html tags if any: - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n" - withString:@""]; - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n" - withString:@""]; - // fallback; remove html tags without their newlines - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"" - withString:@""]; - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"" - withString:@""]; - } else if (_input->useHtmlNormalizer) { - // External HTML (from Google Docs, Word, web pages, etc.) - // Run through the Gumbo-based normalizer to convert arbitrary HTML - // into our canonical tag subset. - NSString *normalized = [self normalizeExternalHtml:html]; - if (normalized != nil) { - fixedHtml = normalized; - } - } - - // Additionally, try getting the content from between body tags if there are - // some: - - // Firstly make sure there are no newlines between them. - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n" - withString:@""]; - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n" - withString:@""]; - // Then, if there actually are body tags, use the content between them. - NSRange openingBodyRange = [htmlWithoutSpaces rangeOfString:@""]; - NSRange closingBodyRange = [htmlWithoutSpaces rangeOfString:@""]; - if (openingBodyRange.length != 0 && closingBodyRange.length != 0) { - NSInteger newStart = openingBodyRange.location + 6; - NSInteger newEnd = closingBodyRange.location - 1; - fixedHtml = [htmlWithoutSpaces - substringWithRange:NSMakeRange(newStart, newEnd - newStart + 1)]; - } - } - - // second processing - try fixing htmls with wrong newlines' setup - if (fixedHtml != nullptr) { - // add
          tag wherever needed - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"

          " - withString:@"
          "]; - - // remove

          tags inside of

        10. - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"
        11. " - withString:@"

        12. "]; - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"

        13. " - withString:@""]; - - // change
          to
          - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"
          " - withString:@"
          "]; - - // remove

          tags around
          - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"


          " - withString:@"
          "]; - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"

          " - withString:@"
          "]; - - // add
          tags inside empty blockquote and codeblock tags - fixedHtml = [fixedHtml - stringByReplacingOccurrencesOfString:@"
          " - withString:@"

          "]; - fixedHtml = [fixedHtml - stringByReplacingOccurrencesOfString:@"" - withString:@"
          "]; - - // remove empty ul and ol tags - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"
            " - withString:@""]; - fixedHtml = [fixedHtml - stringByReplacingOccurrencesOfString:@"
              " - withString:@""]; - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"
                " - withString:@""]; - - // tags that have to be in separate lines - fixedHtml = [self stringByAddingNewlinesToTag:@"
                " - inString:fixedHtml - leading:YES - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"
                  " - inString:fixedHtml - leading:YES - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"
                " - inString:fixedHtml - leading:YES - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"
                  " - inString:fixedHtml - leading:YES - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"
                " - inString:fixedHtml - leading:YES - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"
                " - inString:fixedHtml - leading:YES - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"
                " - inString:fixedHtml - leading:YES - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"" - inString:fixedHtml - leading:YES - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"" - inString:fixedHtml - leading:YES - trailing:YES]; - - // line opening tags - fixedHtml = [self stringByAddingNewlinesToTag:@"

                " - inString:fixedHtml - leading:YES - trailing:NO]; - fixedHtml = [self stringByAddingNewlinesToTag:@"

              1. " - inString:fixedHtml - leading:YES - trailing:NO]; - fixedHtml = [self stringByAddingNewlinesToTag:@"
              2. " - inString:fixedHtml - leading:YES - trailing:NO]; - fixedHtml = [self stringByAddingNewlinesToTag:@"

                " - inString:fixedHtml - leading:YES - trailing:NO]; - fixedHtml = [self stringByAddingNewlinesToTag:@"

                " - inString:fixedHtml - leading:YES - trailing:NO]; - fixedHtml = [self stringByAddingNewlinesToTag:@"

                " - inString:fixedHtml - leading:YES - trailing:NO]; - fixedHtml = [self stringByAddingNewlinesToTag:@"

                " - inString:fixedHtml - leading:YES - trailing:NO]; - fixedHtml = [self stringByAddingNewlinesToTag:@"

                " - inString:fixedHtml - leading:YES - trailing:NO]; - fixedHtml = [self stringByAddingNewlinesToTag:@"
                " - inString:fixedHtml - leading:YES - trailing:NO]; - - // line closing tags - fixedHtml = [self stringByAddingNewlinesToTag:@"

                " - inString:fixedHtml - leading:NO - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"
              3. " - inString:fixedHtml - leading:NO - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"" - inString:fixedHtml - leading:NO - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"" - inString:fixedHtml - leading:NO - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"" - inString:fixedHtml - leading:NO - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"" - inString:fixedHtml - leading:NO - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"" - inString:fixedHtml - leading:NO - trailing:YES]; - fixedHtml = [self stringByAddingNewlinesToTag:@"" - inString:fixedHtml - leading:NO - trailing:YES]; - - // this is more like a hack but for some reason the last
                in - //
                and are not properly changed into zero width - // space so we do that manually here - fixedHtml = [fixedHtml - stringByReplacingOccurrencesOfString:@"
                \n
                " - withString:@"

                \u200B

                \n
                "]; - fixedHtml = [fixedHtml - stringByReplacingOccurrencesOfString:@"
                \n" - withString:@"

                \u200B

                \n"]; - - // replace "
                " at the end with "
                \n" if input is not empty to properly - // handle last
                in html - if ([fixedHtml hasSuffix:@"
                "] && fixedHtml.length != 4) { - fixedHtml = [fixedHtml stringByAppendingString:@"\n"]; - } - } - - return fixedHtml; -} - -/** - * Prepares HTML for the parser by stripping extraneous whitespace and newlines - * from structural tags, while preserving them within text content. - * - * APPROACH: - * This function treats the HTML as having two distinct states: - * 1. Structure Mode (Depth == 0): We are inside or between container tags (like - * blockquote, ul, codeblock). In this mode whitespace and newlines are - * considered layout artifacts and are REMOVED to prevent the parser from - * creating unwanted spaces. - * 2. Content Mode (Depth > 0): We are inside a text-containing tag (like p, - * b, li). In this mode, all whitespace is PRESERVED exactly as is, ensuring - * that sentences and inline formatting remain readable. - * - * The function iterates character-by-character, using a depth counter to track - * nesting levels of the specific tags defined in `textTags`. - * - * IMPORTANT: - * The `textTags` set acts as a whitelist for "Content Mode". If you add support - * for a new HTML tag that contains visible text (e.g., h4, h5, h6), - * you MUST add it to the `textTags` set below. - */ -- (NSString *)stripExtraWhiteSpacesAndNewlines:(NSString *)html { - NSSet *textTags = [NSSet setWithObjects:@"p", @"h1", @"h2", @"h3", @"h4", - @"h5", @"h6", @"li", @"b", @"a", @"s", - @"mention", @"code", @"u", @"i", nil]; - - NSMutableString *output = [NSMutableString stringWithCapacity:html.length]; - NSMutableString *currentTagBuffer = [NSMutableString string]; - NSCharacterSet *whitespaceAndNewlineSet = - [NSCharacterSet whitespaceAndNewlineCharacterSet]; - - BOOL isReadingTag = NO; - NSInteger textDepth = 0; - - for (NSUInteger i = 0; i < html.length; i++) { - unichar c = [html characterAtIndex:i]; - - if (c == '<') { - isReadingTag = YES; - [currentTagBuffer setString:@""]; - [output appendString:@"<"]; - } else if (c == '>') { - isReadingTag = NO; - [output appendString:@">"]; - - NSString *fullTag = [currentTagBuffer lowercaseString]; - - NSString *cleanName = [fullTag - stringByTrimmingCharactersInSet: - [NSCharacterSet characterSetWithCharactersInString:@"/"]]; - NSArray *parts = - [cleanName componentsSeparatedByCharactersInSet: - [NSCharacterSet whitespaceAndNewlineCharacterSet]]; - NSString *tagName = parts.firstObject; - - if (![textTags containsObject:tagName]) { - continue; - } - - if ([fullTag hasPrefix:@"/"]) { - textDepth--; - if (textDepth < 0) - textDepth = 0; - } else { - // Opening tag (e.g.

                ) -> Enter Text Mode - // (Ignore self-closing tags like if they happen to be in the - // list) - if (![fullTag hasSuffix:@"/"]) { - textDepth++; - } - } - } else { - if (isReadingTag) { - [currentTagBuffer appendFormat:@"%C", c]; - [output appendFormat:@"%C", c]; - continue; - } - - if (textDepth > 0) { - [output appendFormat:@"%C", c]; - } else { - if (![whitespaceAndNewlineSet characterIsMember:c]) { - [output appendFormat:@"%C", c]; - } - } - } - } - - return output; -} - -- (NSString *)stringByAddingNewlinesToTag:(NSString *)tag - inString:(NSString *)html - leading:(BOOL)leading - trailing:(BOOL)trailing { - NSString *str = [html copy]; - if (leading) { - NSString *formattedTag = [NSString stringWithFormat:@">%@", tag]; - NSString *formattedNewTag = [NSString stringWithFormat:@">\n%@", tag]; - str = [str stringByReplacingOccurrencesOfString:formattedTag - withString:formattedNewTag]; - } - if (trailing) { - NSString *formattedTag = [NSString stringWithFormat:@"%@<", tag]; - NSString *formattedNewTag = [NSString stringWithFormat:@"%@\n<", tag]; - str = [str stringByReplacingOccurrencesOfString:formattedTag - withString:formattedNewTag]; - } - return str; -} - -- (void)finalizeTagEntry:(NSMutableString *)tagName - ongoingTags:(NSMutableDictionary *)ongoingTags - initiallyProcessedTags:(NSMutableArray *)processedTags - plainText:(NSMutableString *)plainText { - NSMutableArray *tagEntry = [[NSMutableArray alloc] init]; - - NSArray *tagData = ongoingTags[tagName]; - NSInteger tagLocation = [((NSNumber *)tagData[0]) intValue]; - - // 'tagLocation' is an index based on 'plainText' which currently only holds - // raw text. - // - // Since 'plainText' does not yet contain the special placeholders for images, - // the indices for any text following an image are lower than they will be - // in the final NSTextStorage. - // - // We add '_precedingImageCount' to shift the start index forward, aligning - // this style's range with the actual position in the final text (where each - // image adds 1 character). - NSRange tagRange = NSMakeRange(tagLocation + _precedingImageCount, - plainText.length - tagLocation); - - [tagEntry addObject:[tagName copy]]; - [tagEntry addObject:[NSValue valueWithRange:tagRange]]; - if (tagData.count > 1) { - [tagEntry addObject:[(NSString *)tagData[1] copy]]; - } - - [processedTags addObject:tagEntry]; - [ongoingTags removeObjectForKey:tagName]; - - if ([tagName isEqualToString:@"img"]) { - _precedingImageCount++; - } -} - -- (NSArray *)getTextAndStylesFromHtml:(NSString *)fixedHtml { - NSMutableString *plainText = [[NSMutableString alloc] initWithString:@""]; - NSMutableDictionary *ongoingTags = [[NSMutableDictionary alloc] init]; - NSMutableArray *initiallyProcessedTags = [[NSMutableArray alloc] init]; - NSMutableDictionary *checkboxStates = [[NSMutableDictionary alloc] init]; - BOOL insideCheckboxList = NO; - _precedingImageCount = 0; - BOOL insideTag = NO; - BOOL gettingTagName = NO; - BOOL gettingTagParams = NO; - BOOL closingTag = NO; - NSMutableString *currentTagName = - [[NSMutableString alloc] initWithString:@""]; - NSMutableString *currentTagParams = - [[NSMutableString alloc] initWithString:@""]; - NSDictionary *htmlEntitiesDict = - [NSString getEscapedCharactersInfoFrom:fixedHtml]; - - // firstly, extract text and initially processed tags - for (int i = 0; i < fixedHtml.length; i++) { - NSString *currentCharacterStr = - [fixedHtml substringWithRange:NSMakeRange(i, 1)]; - unichar currentCharacterChar = [fixedHtml characterAtIndex:i]; - - if (currentCharacterChar == '<') { - // opening the tag, mark that we are inside and getting its name - insideTag = YES; - gettingTagName = YES; - } else if (currentCharacterChar == '>') { - // finishing some tag, no longer marked as inside or getting its - // name/params - insideTag = NO; - gettingTagName = NO; - gettingTagParams = NO; - - BOOL isSelfClosing = NO; - - // Check if params ended with '/' (e.g. ) - if ([currentTagParams hasSuffix:@"/"]) { - [currentTagParams - deleteCharactersInRange:NSMakeRange(currentTagParams.length - 1, - 1)]; - isSelfClosing = YES; - } - - if ([currentTagName isEqualToString:@"p"] || - [currentTagName isEqualToString:@"br"]) { - // do nothing, we don't include these tags in styles - } else if ([currentTagName isEqualToString:@"li"]) { - // Only track checkbox state if we're inside a checkbox list - if (insideCheckboxList && !closingTag) { - BOOL isChecked = [currentTagParams containsString:@"checked"]; - checkboxStates[@(plainText.length)] = @(isChecked); - } - } else if (!closingTag) { - // we finish opening tag - get its location and optionally params and - // put them under tag name key in ongoingTags - NSMutableArray *tagArr = [[NSMutableArray alloc] init]; - [tagArr addObject:[NSNumber numberWithInteger:plainText.length]]; - if (currentTagParams.length > 0) { - [tagArr addObject:[currentTagParams copy]]; - } - ongoingTags[currentTagName] = tagArr; - - // Check if this is a checkbox list - if ([currentTagName isEqualToString:@"ul"] && - [self isUlCheckboxList:currentTagParams]) { - insideCheckboxList = YES; - } - - // skip one newline if it was added after opening tags that are in - // separate lines - if ([self isBlockTag:currentTagName] && i + 1 < fixedHtml.length && - [[NSCharacterSet newlineCharacterSet] - characterIsMember:[fixedHtml characterAtIndex:i + 1]]) { - i += 1; - } - - if (isSelfClosing) { - [self finalizeTagEntry:currentTagName - ongoingTags:ongoingTags - initiallyProcessedTags:initiallyProcessedTags - plainText:plainText]; - } - } else { - // we finish closing tags - pack tag name, tag range and optionally tag - // params into an entry that goes inside initiallyProcessedTags - - // Check if we're closing a checkbox list by looking at the params - if ([currentTagName isEqualToString:@"ul"] && - [self isUlCheckboxList:currentTagParams]) { - insideCheckboxList = NO; - } - - BOOL isBlockTag = [self isBlockTag:currentTagName]; - - // skip one newline if it was added before some closing tags that are - // in separate lines - if (isBlockTag && plainText.length > 0 && - [[NSCharacterSet newlineCharacterSet] - characterIsMember:[plainText - characterAtIndex:plainText.length - 1]]) { - plainText = [[plainText - substringWithRange:NSMakeRange(0, plainText.length - 1)] - mutableCopy]; - } - - [self finalizeTagEntry:currentTagName - ongoingTags:ongoingTags - initiallyProcessedTags:initiallyProcessedTags - plainText:plainText]; - } - // post-tag cleanup - closingTag = NO; - currentTagName = [[NSMutableString alloc] initWithString:@""]; - currentTagParams = [[NSMutableString alloc] initWithString:@""]; - } else { - if (!insideTag) { - // no tags logic - just append the right text - - // html entity on the index; use unescaped character and forward - // iterator accordingly - NSArray *entityInfo = htmlEntitiesDict[@(i)]; - if (entityInfo != nullptr) { - NSString *escaped = entityInfo[0]; - NSString *unescaped = entityInfo[1]; - [plainText appendString:unescaped]; - // the iterator will forward by 1 itself - i += escaped.length - 1; - } else { - [plainText appendString:currentCharacterStr]; - } - } else { - if (gettingTagName) { - if (currentCharacterChar == ' ') { - // no longer getting tag name - switch to params - gettingTagName = NO; - gettingTagParams = YES; - } else if (currentCharacterChar == '/') { - // mark that the tag is closing - closingTag = YES; - } else { - // append next tag char - [currentTagName appendString:currentCharacterStr]; - } - } else if (gettingTagParams) { - // append next tag params char - [currentTagParams appendString:currentCharacterStr]; - } - } - } - } - - // process tags into proper StyleType + StylePair values - NSMutableArray *processedStyles = [[NSMutableArray alloc] init]; - - for (NSArray *arr in initiallyProcessedTags) { - NSString *tagName = (NSString *)arr[0]; - NSValue *tagRangeValue = (NSValue *)arr[1]; - NSMutableString *params = [[NSMutableString alloc] initWithString:@""]; - if (arr.count > 2) { - [params appendString:(NSString *)arr[2]]; - } - - NSMutableArray *styleArr = [[NSMutableArray alloc] init]; - StylePair *stylePair = [[StylePair alloc] init]; - if ([tagName isEqualToString:@"b"]) { - [styleArr addObject:@([BoldStyle getType])]; - } else if ([tagName isEqualToString:@"i"]) { - [styleArr addObject:@([ItalicStyle getType])]; - } else if ([tagName isEqualToString:@"img"]) { - NSRegularExpression *srcRegex = - [NSRegularExpression regularExpressionWithPattern:@"src=\"([^\"]+)\"" - options:0 - error:nullptr]; - NSTextCheckingResult *match = - [srcRegex firstMatchInString:params - options:0 - range:NSMakeRange(0, params.length)]; - - if (match == nullptr) { - continue; - } - - NSRange srcRange = match.range; - [styleArr addObject:@([ImageStyle getType])]; - // cut only the uri from the src="..." string - NSString *uri = - [params substringWithRange:NSMakeRange(srcRange.location + 5, - srcRange.length - 6)]; - ImageData *imageData = [[ImageData alloc] init]; - imageData.uri = uri; - - NSRegularExpression *widthRegex = [NSRegularExpression - regularExpressionWithPattern:@"width=\"([0-9.]+)\"" - options:0 - error:nil]; - NSTextCheckingResult *widthMatch = - [widthRegex firstMatchInString:params - options:0 - range:NSMakeRange(0, params.length)]; - - if (widthMatch) { - NSString *widthString = - [params substringWithRange:[widthMatch rangeAtIndex:1]]; - imageData.width = [widthString floatValue]; - } - - NSRegularExpression *heightRegex = [NSRegularExpression - regularExpressionWithPattern:@"height=\"([0-9.]+)\"" - options:0 - error:nil]; - NSTextCheckingResult *heightMatch = - [heightRegex firstMatchInString:params - options:0 - range:NSMakeRange(0, params.length)]; - - if (heightMatch) { - NSString *heightString = - [params substringWithRange:[heightMatch rangeAtIndex:1]]; - imageData.height = [heightString floatValue]; - } - - stylePair.styleValue = imageData; - } else if ([tagName isEqualToString:@"u"]) { - [styleArr addObject:@([UnderlineStyle getType])]; - } else if ([tagName isEqualToString:@"s"]) { - [styleArr addObject:@([StrikethroughStyle getType])]; - } else if ([tagName isEqualToString:@"code"]) { - [styleArr addObject:@([InlineCodeStyle getType])]; - } else if ([tagName isEqualToString:@"a"]) { - NSRegularExpression *hrefRegex = - [NSRegularExpression regularExpressionWithPattern:@"href=\".+\"" - options:0 - error:nullptr]; - NSTextCheckingResult *match = - [hrefRegex firstMatchInString:params - options:0 - range:NSMakeRange(0, params.length)]; - - if (match == nullptr) { - // same as on Android, no href (or empty href) equals no link style - continue; - } - - NSRange hrefRange = match.range; - [styleArr addObject:@([LinkStyle getType])]; - // cut only the url from the href="..." string - NSString *url = - [params substringWithRange:NSMakeRange(hrefRange.location + 6, - hrefRange.length - 7)]; - NSString *text = [plainText substringWithRange:tagRangeValue.rangeValue]; - - LinkData *linkData = [[LinkData alloc] init]; - linkData.url = url; - linkData.text = text; - linkData.isManual = ![text isEqualToString:url]; - - stylePair.styleValue = linkData; - } else if ([tagName isEqualToString:@"mention"]) { - [styleArr addObject:@([MentionStyle getType])]; - // extract html expression into dict using some regex - NSMutableDictionary *paramsDict = [[NSMutableDictionary alloc] init]; - NSString *pattern = @"(\\w+)=(['\"])(.*?)\\2"; - NSRegularExpression *regex = - [NSRegularExpression regularExpressionWithPattern:pattern - options:0 - error:nil]; - - [regex enumerateMatchesInString:params - options:0 - range:NSMakeRange(0, params.length) - usingBlock:^(NSTextCheckingResult *_Nullable result, - NSMatchingFlags flags, - BOOL *_Nonnull stop) { - if (result.numberOfRanges == 4) { - NSString *key = [params - substringWithRange:[result rangeAtIndex:1]]; - NSString *value = [params - substringWithRange:[result rangeAtIndex:3]]; - paramsDict[key] = value; - } - }]; - - MentionParams *mentionParams = [[MentionParams alloc] init]; - mentionParams.text = paramsDict[@"text"]; - mentionParams.indicator = paramsDict[@"indicator"]; - - [paramsDict removeObjectsForKeys:@[ @"text", @"indicator" ]]; - NSError *error; - NSData *attrsData = [NSJSONSerialization dataWithJSONObject:paramsDict - options:0 - error:&error]; - NSString *formattedAttrsString = - [[NSString alloc] initWithData:attrsData - encoding:NSUTF8StringEncoding]; - mentionParams.attributes = formattedAttrsString; - - stylePair.styleValue = mentionParams; - } else if ([tagName isEqualToString:@"h1"]) { - [styleArr addObject:@([H1Style getType])]; - } else if ([tagName isEqualToString:@"h2"]) { - [styleArr addObject:@([H2Style getType])]; - } else if ([tagName isEqualToString:@"h3"]) { - [styleArr addObject:@([H3Style getType])]; - } else if ([tagName isEqualToString:@"h4"]) { - [styleArr addObject:@([H4Style getType])]; - } else if ([tagName isEqualToString:@"h5"]) { - [styleArr addObject:@([H5Style getType])]; - } else if ([tagName isEqualToString:@"h6"]) { - [styleArr addObject:@([H6Style getType])]; - } else if ([tagName isEqualToString:@"ul"]) { - if ([self isUlCheckboxList:params]) { - [styleArr addObject:@([CheckboxListStyle getType])]; - stylePair.styleValue = - [self prepareCheckboxListStyleValue:tagRangeValue - checkboxStates:checkboxStates]; - } else { - [styleArr addObject:@([UnorderedListStyle getType])]; - } - } else if ([tagName isEqualToString:@"ol"]) { - [styleArr addObject:@([OrderedListStyle getType])]; - } else if ([tagName isEqualToString:@"blockquote"]) { - [styleArr addObject:@([BlockQuoteStyle getType])]; - } else if ([tagName isEqualToString:@"codeblock"]) { - [styleArr addObject:@([CodeBlockStyle getType])]; - } else { - // some other external tags like span just don't get put into the - // processed styles - continue; - } - - stylePair.rangeValue = tagRangeValue; - [styleArr addObject:stylePair]; - [processedStyles addObject:styleArr]; - } - - return @[ plainText, processedStyles ]; -} - -- (BOOL)isUlCheckboxList:(NSString *)params { - return ([params containsString:@"data-type=\"checkbox\""] || - [params containsString:@"data-type='checkbox'"]); -} - -- (NSDictionary *)prepareCheckboxListStyleValue:(NSValue *)rangeValue - checkboxStates:(NSDictionary *)checkboxStates { - NSRange range = [rangeValue rangeValue]; - NSMutableDictionary *statesInRange = [[NSMutableDictionary alloc] init]; - - for (NSNumber *key in checkboxStates) { - NSUInteger pos = [key unsignedIntegerValue]; - if (pos >= range.location && pos < range.location + range.length) { - [statesInRange setObject:checkboxStates[key] forKey:key]; - } - } - - return statesInRange; + return [HtmlParser initiallyProcessHtml:html + useHtmlNormalizer:_input->useHtmlNormalizer]; } @end diff --git a/ios/interfaces/BaseStyleProtocol.h b/ios/interfaces/BaseStyleProtocol.h index 5e08e558..96a3d03a 100644 --- a/ios/interfaces/BaseStyleProtocol.h +++ b/ios/interfaces/BaseStyleProtocol.h @@ -5,7 +5,7 @@ @protocol BaseStyleProtocol + (StyleType)getStyleType; + (BOOL)isParagraphStyle; -- (instancetype _Nonnull)initWithInput:(id _Nonnull)input; +- (instancetype _Nonnull)initWithHost:(id _Nonnull)host; - (void)applyStyle:(NSRange)range; - (void)addAttributes:(NSRange)range withTypingAttr:(BOOL)withTypingAttr; - (void)removeAttributes:(NSRange)range; diff --git a/ios/interfaces/EnrichedTextStyleHeaders.h b/ios/interfaces/EnrichedTextStyleHeaders.h new file mode 100644 index 00000000..9c717aae --- /dev/null +++ b/ios/interfaces/EnrichedTextStyleHeaders.h @@ -0,0 +1,59 @@ +#pragma once +#import "StyleHeaders.h" + +@interface EnrichedTextBoldStyle : BoldStyle +@end + +@interface EnrichedTextItalicStyle : ItalicStyle +@end + +@interface EnrichedTextUnderlineStyle : UnderlineStyle +@end + +@interface EnrichedTextStrikethroughStyle : StrikethroughStyle +@end + +@interface EnrichedTextInlineCodeStyle : InlineCodeStyle +@end + +@interface EnrichedTextH1Style : H1Style +@end + +@interface EnrichedTextH2Style : H2Style +@end + +@interface EnrichedTextH3Style : H3Style +@end + +@interface EnrichedTextH4Style : H4Style +@end + +@interface EnrichedTextH5Style : H5Style +@end + +@interface EnrichedTextH6Style : H6Style +@end + +@interface EnrichedTextBlockQuoteStyle : BlockQuoteStyle +@end + +@interface EnrichedTextCodeBlockStyle : CodeBlockStyle +@end + +@interface EnrichedTextUnorderedListStyle : UnorderedListStyle +@end + +@interface EnrichedTextOrderedListStyle : OrderedListStyle +@end + +@interface EnrichedTextLinkStyle : LinkStyle +@end + +@interface EnrichedTextMentionStyle : MentionStyle +@end + +@interface EnrichedTextImageStyle : ImageStyle +@end + +@interface EnrichedTextCheckboxListStyle : CheckboxListStyle +@end diff --git a/ios/interfaces/EnrichedViewHost.h b/ios/interfaces/EnrichedViewHost.h new file mode 100644 index 00000000..d56e05c1 --- /dev/null +++ b/ios/interfaces/EnrichedViewHost.h @@ -0,0 +1,25 @@ +#pragma once +#import "AttributesManager.h" +#import "EnrichedConfig.h" +#import "StyleTypeEnum.h" +#import + +@protocol EnrichedViewHost +@required +@property(nonatomic, readonly) UITextView *textView; +@property(nonatomic, readonly) EnrichedConfig *config; +@property(nonatomic, readonly) NSDictionary *stylesDict; +@property(nonatomic, readonly, nullable) AttributesManager *attributesManager; +@property(nonatomic, readonly, nullable) + NSMutableDictionary *> *conflictingStyles; +@property(nonatomic, readonly, nullable) + NSMutableDictionary *> *blockingStyles; +@property(nonatomic, readonly, nullable) + NSMutableDictionary *defaultTypingAttributes; +@property(nonatomic) BOOL blockEmitting; +@optional +- (BOOL)handleStyleBlocksAndConflicts:(StyleType)type range:(NSRange)range; +- (void)emitOnLinkDetectedEvent:(id _Nonnull)linkData range:(NSRange)range; +- (void)emitOnMentionEvent:(NSString *_Nonnull)indicator + text:(NSString *_Nullable)text; +@end diff --git a/ios/interfaces/MentionStyleProps.h b/ios/interfaces/MentionStyleProps.h index d0d59dd5..f83d1bc4 100644 --- a/ios/interfaces/MentionStyleProps.h +++ b/ios/interfaces/MentionStyleProps.h @@ -10,4 +10,8 @@ @property TextDecorationLineEnum decorationLine; + (NSDictionary *)getSinglePropsFromFollyDynamic:(folly::dynamic)folly; + (NSDictionary *)getComplexPropsFromFollyDynamic:(folly::dynamic)folly; + +// MARK: - Text only props +@property UIColor *pressColor; +@property UIColor *pressBackgroundColor; @end diff --git a/ios/interfaces/MentionStyleProps.mm b/ios/interfaces/MentionStyleProps.mm index 5ea23538..b4ca19c1 100644 --- a/ios/interfaces/MentionStyleProps.mm +++ b/ios/interfaces/MentionStyleProps.mm @@ -34,6 +34,23 @@ + (MentionStyleProps *)getSingleMentionStylePropsFromFollyDynamic: nativeProps.decorationLine = DecorationUnderline; } + // text only + if (folly["pressColor"].isNumber()) { + facebook::react::SharedColor pressColor = facebook::react::SharedColor( + facebook::react::Color(int32_t(folly["pressColor"].asInt()))); + nativeProps.pressColor = RCTUIColorFromSharedColor(pressColor); + } else { + nativeProps.pressColor = [UIColor blueColor]; + } + + if (folly["pressBackgroundColor"].isNumber()) { + facebook::react::SharedColor bgColor = facebook::react::SharedColor( + facebook::react::Color(int32_t(folly["pressBackgroundColor"].asInt()))); + nativeProps.pressBackgroundColor = RCTUIColorFromSharedColor(bgColor); + } else { + nativeProps.pressBackgroundColor = [UIColor yellowColor]; + } + return nativeProps; } diff --git a/ios/interfaces/StyleBase.h b/ios/interfaces/StyleBase.h index df65437a..26be1ba4 100644 --- a/ios/interfaces/StyleBase.h +++ b/ios/interfaces/StyleBase.h @@ -1,19 +1,18 @@ #pragma once #import "AttributeEntry.h" +#import "EnrichedViewHost.h" #import "StylePair.h" #import "StyleTypeEnum.h" #import -@class EnrichedTextInputView; - @interface StyleBase : NSObject -@property(nonatomic, weak) EnrichedTextInputView *input; +@property(nonatomic, weak) id host; + (StyleType)getType; - (NSString *)getKey; - (NSString *)getValue; - (BOOL)isParagraph; - (BOOL)needsZWS; -- (instancetype)initWithInput:(EnrichedTextInputView *)input; +- (instancetype)initWithHost:(id)host; - (NSRange)actualUsedRange:(NSRange)range; - (void)toggle:(NSRange)range; - (void)add:(NSRange)range diff --git a/ios/interfaces/StyleBase.mm b/ios/interfaces/StyleBase.mm index b91d4e68..7754008f 100644 --- a/ios/interfaces/StyleBase.mm +++ b/ios/interfaces/StyleBase.mm @@ -1,6 +1,5 @@ #import "StyleBase.h" #import "AttributeEntry.h" -#import "EnrichedTextInputView.h" #import "OccurenceUtils.h" #import "RangeUtils.h" #import "ZeroWidthSpaceUtils.h" @@ -35,9 +34,9 @@ - (BOOL)needsZWS { return NO; } -- (instancetype)initWithInput:(EnrichedTextInputView *)input { +- (instancetype)initWithHost:(id)host { self = [super init]; - _input = input; + _host = host; return self; } @@ -45,7 +44,7 @@ - (instancetype)initWithInput:(EnrichedTextInputView *)input { - (NSRange)actualUsedRange:(NSRange)range { if (![self isParagraph]) return range; - return [_input->textView.textStorage.string paragraphRangeForRange:range]; + return [self.host.textView.textStorage.string paragraphRangeForRange:range]; } - (void)toggle:(NSRange)range { @@ -76,11 +75,11 @@ - (void)add:(NSRange)range NSRange actualRange = [self actualUsedRange:range]; if (![self isParagraph]) { - [_input->textView.textStorage addAttribute:[self getKey] - value:value - range:actualRange]; + [self.host.textView.textStorage addAttribute:[self getKey] + value:value + range:actualRange]; } else { - [_input->textView.textStorage + [self.host.textView.textStorage enumerateAttribute:NSParagraphStyleAttributeName inRange:actualRange options:0 @@ -93,7 +92,7 @@ - (void)add:(NSRange)range pStyle.textLists = @[ [[NSTextList alloc] initWithMarkerFormat:value options:0] ]; - [_input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSParagraphStyleAttributeName value:pStyle range:subRange]; @@ -106,7 +105,7 @@ - (void)add:(NSRange)range // Notify attributes manager of styling to be re-done if needed. if (withDirtyRange) { - [self.input->attributesManager addDirtyRange:actualRange]; + [self.host.attributesManager addDirtyRange:actualRange]; } } @@ -114,10 +113,10 @@ - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { NSRange actualRange = [self actualUsedRange:range]; if (![self isParagraph]) { - [_input->textView.textStorage removeAttribute:[self getKey] - range:actualRange]; + [self.host.textView.textStorage removeAttribute:[self getKey] + range:actualRange]; } else { - [_input->textView.textStorage + [self.host.textView.textStorage enumerateAttribute:NSParagraphStyleAttributeName inRange:actualRange options:0 @@ -128,7 +127,7 @@ - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { if (pStyle == nullptr) return; pStyle.textLists = @[]; - [_input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSParagraphStyleAttributeName value:pStyle range:subRange]; @@ -138,13 +137,13 @@ - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { // Notify attributes manager of styling to be re-done if needed. if (withDirtyRange) { - [self.input->attributesManager addDirtyRange:actualRange]; + [self.host.attributesManager addDirtyRange:actualRange]; } } - (void)addTypingWithValue:(NSString *)value { NSMutableDictionary *newTypingAttrs = - [_input->textView.typingAttributes mutableCopy]; + [self.host.textView.typingAttributes mutableCopy]; if (![self isParagraph]) { newTypingAttrs[[self getKey]] = value; @@ -156,18 +155,18 @@ - (void)addTypingWithValue:(NSString *)value { newTypingAttrs[NSParagraphStyleAttributeName] = pStyle; } - _input->textView.typingAttributes = newTypingAttrs; + self.host.textView.typingAttributes = newTypingAttrs; } - (void)removeTyping { NSMutableDictionary *newTypingAttrs = - [_input->textView.typingAttributes mutableCopy]; + [self.host.textView.typingAttributes mutableCopy]; if (![self isParagraph]) { [newTypingAttrs removeObjectForKey:[self getKey]]; // attributes manager also needs to be notified of custom attributes that // shouldn't be extended - [_input->attributesManager didRemoveTypingAttribute:[self getKey]]; + [self.host.attributesManager didRemoveTypingAttribute:[self getKey]]; } else { NSMutableParagraphStyle *pStyle = [newTypingAttrs[NSParagraphStyleAttributeName] mutableCopy]; @@ -175,7 +174,7 @@ - (void)removeTyping { newTypingAttrs[NSParagraphStyleAttributeName] = pStyle; } - _input->textView.typingAttributes = newTypingAttrs; + self.host.textView.typingAttributes = newTypingAttrs; } // custom styles (e.g. ImageStyle, MentionStyle) will likely need to override @@ -195,14 +194,14 @@ - (BOOL)styleCondition:(id)value range:(NSRange)range { - (BOOL)detect:(NSRange)range { if (range.length >= 1) { return [OccurenceUtils detect:[self getKey] - withInput:_input + withHost:self.host inRange:range withCondition:^BOOL(id _Nullable value, NSRange range) { return [self styleCondition:value range:range]; }]; } else { return [OccurenceUtils detect:[self getKey] - withInput:_input + withHost:self.host atIndex:range.location checkPrevious:[self isParagraph] withCondition:^BOOL(id _Nullable value, NSRange range) { @@ -213,7 +212,7 @@ - (BOOL)detect:(NSRange)range { - (BOOL)any:(NSRange)range { return [OccurenceUtils any:[self getKey] - withInput:_input + withHost:self.host inRange:range withCondition:^BOOL(id _Nullable value, NSRange range) { return [self styleCondition:value range:range]; @@ -222,7 +221,7 @@ - (BOOL)any:(NSRange)range { - (NSArray *)all:(NSRange)range { return [OccurenceUtils all:[self getKey] - withInput:_input + withHost:self.host inRange:range withCondition:^BOOL(id _Nullable value, NSRange range) { return [self styleCondition:value range:range]; diff --git a/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h index 7f1d4c88..e2876936 100644 --- a/ios/interfaces/StyleHeaders.h +++ b/ios/interfaces/StyleHeaders.h @@ -27,6 +27,7 @@ - (NSRange)getFullLinkRangeAt:(NSUInteger)location; - (void)handleAutomaticLinks:(NSString *)word inRange:(NSRange)wordRange; - (void)handleManualLinks:(NSString *)word inRange:(NSRange)wordRange; +- (void)applyLinkMetaWithData:(LinkData *)linkData range:(NSRange)range; @end @interface MentionStyle : StyleBase @@ -40,6 +41,7 @@ - (MentionParams *)getMentionParamsAt:(NSUInteger)location; - (NSRange)getFullMentionRangeAt:(NSUInteger)location; - (NSValue *)getActiveMentionRange; +- (void)applyMentionMeta:(MentionParams *)params range:(NSRange)range; @end @interface HeadingStyleBase : StyleBase @@ -82,7 +84,8 @@ range:(NSRange)range withTyping:(BOOL)withTyping withDirtyRange:(BOOL)withDirtyRange; -- (void)toggleCheckedAt:(NSUInteger)location; +- (void)toggleCheckedAt:(NSUInteger)location + withDirtyRange:(BOOL)withDirtyRange; - (BOOL)getCheckboxStateAt:(NSUInteger)location; - (BOOL)handleNewlinesInRange:(NSRange)range replacementText:(NSString *)text; @end @@ -97,6 +100,7 @@ - (void)addImage:(NSString *)uri width:(CGFloat)width height:(CGFloat)height; - (void)addImageAtRange:(NSRange)range imageData:(ImageData *)imageData - withSelection:(BOOL)withSelection; + withSelection:(BOOL)withSelection + withDirtyRange:(BOOL)withDirtyRange; - (ImageData *)getImageDataAt:(NSUInteger)location; @end diff --git a/ios/internals/EnrichedTextComponentDescriptor.h b/ios/internals/EnrichedTextComponentDescriptor.h new file mode 100644 index 00000000..996aec1f --- /dev/null +++ b/ios/internals/EnrichedTextComponentDescriptor.h @@ -0,0 +1,19 @@ +#pragma once +#include +#include +#include +#include + +namespace facebook::react { +class EnrichedTextComponentDescriptor final + : public ConcreteComponentDescriptor { +public: + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + void adopt(ShadowNode &shadowNode) const override { + react_native_assert( + dynamic_cast(&shadowNode)); + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace facebook::react diff --git a/ios/internals/EnrichedTextViewShadowNode.h b/ios/internals/EnrichedTextViewShadowNode.h new file mode 100644 index 00000000..631c8a19 --- /dev/null +++ b/ios/internals/EnrichedTextViewShadowNode.h @@ -0,0 +1,34 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +JSI_EXPORT extern const char EnrichedTextViewComponentName[]; + +class EnrichedTextViewShadowNode + : public ConcreteViewShadowNode< + EnrichedTextViewComponentName, EnrichedTextViewProps, + EnrichedTextViewEventEmitter, EnrichedTextViewState> { +public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + Size + measureContent(const LayoutContext &layoutContext, + const LayoutConstraints &layoutConstraints) const override; + + static ShadowNodeTraits BaseTraits() { + auto traits = ConcreteViewShadowNode::BaseTraits(); + traits.set(ShadowNodeTraits::Trait::LeafYogaNode); + traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); + return traits; + } + +private: + id setupMockTextView_() const; +}; + +} // namespace facebook::react diff --git a/ios/internals/EnrichedTextViewShadowNode.mm b/ios/internals/EnrichedTextViewShadowNode.mm new file mode 100644 index 00000000..ac5bc500 --- /dev/null +++ b/ios/internals/EnrichedTextViewShadowNode.mm @@ -0,0 +1,72 @@ +#import "EnrichedTextViewShadowNode.h" +#import "CoreText/CoreText.h" +#import +#import +#import + +namespace facebook::react { + +extern const char EnrichedTextViewComponentName[] = "EnrichedTextView"; + +id EnrichedTextViewShadowNode::setupMockTextView_() const { + const int veryFarAway = 20000; + const int mockSize = 1000; + EnrichedTextView *mockView = [[EnrichedTextView alloc] + initWithFrame:(CGRectMake(veryFarAway, veryFarAway, mockSize, mockSize))]; + const auto props = this->getProps(); + [mockView updateProps:props oldProps:nullptr]; + return mockView; +} + +Size EnrichedTextViewShadowNode::measureContent( + const LayoutContext &layoutContext, + const LayoutConstraints &layoutConstraints) const { + const auto state = this->getStateData(); + const auto componentRef = state.getComponentViewRef(); + RCTInternalGenericWeakWrapper *weakWrapper = + (RCTInternalGenericWeakWrapper *)unwrapManagedObject(componentRef); + + if (weakWrapper != nullptr) { + id componentObject = weakWrapper.object; + EnrichedTextView *typedComponentObject = + (EnrichedTextView *)componentObject; + + if (typedComponentObject != nullptr) { + __block CGSize estimatedSize; + + if ([NSThread isMainThread]) { + estimatedSize = [typedComponentObject + measureSize:layoutConstraints.maximumSize.width]; + } else { + dispatch_sync(dispatch_get_main_queue(), ^{ + estimatedSize = [typedComponentObject + measureSize:layoutConstraints.maximumSize.width]; + }); + } + + return {estimatedSize.width, + MIN(estimatedSize.height, layoutConstraints.maximumSize.height)}; + } + } else { + __block CGSize estimatedSize; + + if ([NSThread isMainThread]) { + EnrichedTextView *mockView = setupMockTextView_(); + estimatedSize = + [mockView measureSize:layoutConstraints.maximumSize.width]; + } else { + dispatch_sync(dispatch_get_main_queue(), ^{ + EnrichedTextView *mockView = setupMockTextView_(); + estimatedSize = + [mockView measureSize:layoutConstraints.maximumSize.width]; + }); + } + + return {estimatedSize.width, + MIN(estimatedSize.height, layoutConstraints.maximumSize.height)}; + } + + return Size(); +} + +} // namespace facebook::react diff --git a/ios/internals/EnrichedTextViewState.cpp b/ios/internals/EnrichedTextViewState.cpp new file mode 100644 index 00000000..ec988d05 --- /dev/null +++ b/ios/internals/EnrichedTextViewState.cpp @@ -0,0 +1,7 @@ +#include "EnrichedTextViewState.h" + +namespace facebook::react { +std::shared_ptr EnrichedTextViewState::getComponentViewRef() const { + return componentViewRef_; +} +} // namespace facebook::react diff --git a/ios/internals/EnrichedTextViewState.h b/ios/internals/EnrichedTextViewState.h new file mode 100644 index 00000000..c32efa0f --- /dev/null +++ b/ios/internals/EnrichedTextViewState.h @@ -0,0 +1,18 @@ +#pragma once +#include + +namespace facebook::react { + +class EnrichedTextViewState { +public: + EnrichedTextViewState() : componentViewRef_(nullptr) {} + explicit EnrichedTextViewState(std::shared_ptr ref) { + componentViewRef_ = ref; + } + std::shared_ptr getComponentViewRef() const; + +private: + std::shared_ptr componentViewRef_{}; +}; + +} // namespace facebook::react diff --git a/ios/styles/BlockQuoteStyle.mm b/ios/styles/BlockQuoteStyle.mm index 98f30587..796a3cb5 100644 --- a/ios/styles/BlockQuoteStyle.mm +++ b/ios/styles/BlockQuoteStyle.mm @@ -21,9 +21,9 @@ - (BOOL)needsZWS { } - (void)applyStyling:(NSRange)range { - CGFloat indent = [self.input->config blockquoteBorderWidth] + - [self.input->config blockquoteGapWidth]; - [self.input->textView.textStorage + CGFloat indent = [self.host.config blockquoteBorderWidth] + + [self.host.config blockquoteGapWidth]; + [self.host.textView.textStorage enumerateAttribute:NSParagraphStyleAttributeName inRange:range options:0 @@ -33,23 +33,22 @@ - (void)applyStyling:(NSRange)range { [(NSParagraphStyle *)value mutableCopy]; pStyle.headIndent = indent; pStyle.firstLineHeadIndent = indent; - [self.input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSParagraphStyleAttributeName value:pStyle range:subRange]; }]; - UIColor *bqColor = [self.input->config blockquoteColor]; - [self.input->textView.textStorage addAttribute:NSForegroundColorAttributeName - value:bqColor - range:range]; - [self.input->textView.textStorage addAttribute:NSUnderlineColorAttributeName - value:bqColor - range:range]; - [self.input->textView.textStorage - addAttribute:NSStrikethroughColorAttributeName - value:bqColor - range:range]; + UIColor *bqColor = [self.host.config blockquoteColor]; + [self.host.textView.textStorage addAttribute:NSForegroundColorAttributeName + value:bqColor + range:range]; + [self.host.textView.textStorage addAttribute:NSUnderlineColorAttributeName + value:bqColor + range:range]; + [self.host.textView.textStorage addAttribute:NSStrikethroughColorAttributeName + value:bqColor + range:range]; } @end diff --git a/ios/styles/BoldStyle.mm b/ios/styles/BoldStyle.mm index c9f25123..025866c9 100644 --- a/ios/styles/BoldStyle.mm +++ b/ios/styles/BoldStyle.mm @@ -17,7 +17,7 @@ - (BOOL)isParagraph { } - (void)applyStyling:(NSRange)range { - [self.input->textView.textStorage + [self.host.textView.textStorage enumerateAttribute:NSFontAttributeName inRange:range options:0 @@ -26,7 +26,7 @@ - (void)applyStyling:(NSRange)range { UIFont *font = (UIFont *)value; if (font != nullptr) { UIFont *newFont = [font setBold]; - [self.input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSFontAttributeName value:newFont range:range]; diff --git a/ios/styles/CheckboxListStyle.mm b/ios/styles/CheckboxListStyle.mm index 6465db09..2b0104a0 100644 --- a/ios/styles/CheckboxListStyle.mm +++ b/ios/styles/CheckboxListStyle.mm @@ -22,11 +22,11 @@ - (BOOL)needsZWS { } - (void)applyStyling:(NSRange)range { - CGFloat listHeadIndent = [self.input->config checkboxListMarginLeft] + - [self.input->config checkboxListGapWidth] + - [self.input->config checkboxListBoxSize]; + CGFloat listHeadIndent = [self.host.config checkboxListMarginLeft] + + [self.host.config checkboxListGapWidth] + + [self.host.config checkboxListBoxSize]; - [self.input->textView.textStorage + [self.host.textView.textStorage enumerateAttribute:NSParagraphStyleAttributeName inRange:range options:0 @@ -36,7 +36,7 @@ - (void)applyStyling:(NSRange)range { [(NSParagraphStyle *)value mutableCopy]; pStyle.headIndent = listHeadIndent; pStyle.firstLineHeadIndent = listHeadIndent; - [self.input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSParagraphStyleAttributeName value:pStyle range:range]; @@ -87,38 +87,39 @@ - (void)reapplyFromStylePair:(StylePair *)pair { [self addWithChecked:checked range:range withTyping:NO withDirtyRange:NO]; } -- (void)toggleCheckedAt:(NSUInteger)location { - if (location >= self.input->textView.textStorage.length) { +- (void)toggleCheckedAt:(NSUInteger)location + withDirtyRange:(BOOL)withDirtyRange { + if (location >= self.host.textView.textStorage.length) { return; } NSParagraphStyle *pStyle = - [self.input->textView.textStorage attribute:NSParagraphStyleAttributeName - atIndex:location - effectiveRange:NULL]; + [self.host.textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:location + effectiveRange:NULL]; NSTextList *list = pStyle.textLists.firstObject; BOOL isCurrentlyChecked = [list.markerFormat isEqualToString:@"EnrichedCheckbox1"]; - NSRange paragraphRange = [self.input->textView.textStorage.string + NSRange paragraphRange = [self.host.textView.textStorage.string paragraphRangeForRange:NSMakeRange(location, 0)]; [self addWithChecked:!isCurrentlyChecked range:paragraphRange withTyping:NO - withDirtyRange:YES]; + withDirtyRange:withDirtyRange]; } - (BOOL)getCheckboxStateAt:(NSUInteger)location { - if (location >= self.input->textView.textStorage.length) { + if (location >= self.host.textView.textStorage.length) { return NO; } NSParagraphStyle *style = - [self.input->textView.textStorage attribute:NSParagraphStyleAttributeName - atIndex:location - effectiveRange:NULL]; + [self.host.textView.textStorage attribute:NSParagraphStyleAttributeName + atIndex:location + effectiveRange:NULL]; if (style && style.textLists.count > 0) { NSTextList *list = style.textLists.firstObject; @@ -131,18 +132,18 @@ - (BOOL)getCheckboxStateAt:(NSUInteger)location { } - (BOOL)handleNewlinesInRange:(NSRange)range replacementText:(NSString *)text { - if ([self detect:self.input->textView.selectedRange] && text.length > 0 && + if ([self detect:self.host.textView.selectedRange] && text.length > 0 && [[NSCharacterSet newlineCharacterSet] characterIsMember:[text characterAtIndex:text.length - 1]]) { // do the replacement manually [TextInsertionUtils replaceText:text at:range additionalAttributes:nullptr - input:self.input + input:self.host withSelection:YES]; // apply unchecked checkbox attributes to the new paragraph [self addWithChecked:NO - range:self.input->textView.selectedRange + range:self.host.textView.selectedRange withTyping:YES withDirtyRange:YES]; return YES; diff --git a/ios/styles/CodeBlockStyle.mm b/ios/styles/CodeBlockStyle.mm index 65d44af5..74c3dbeb 100644 --- a/ios/styles/CodeBlockStyle.mm +++ b/ios/styles/CodeBlockStyle.mm @@ -21,7 +21,7 @@ - (BOOL)needsZWS { } - (void)applyStyling:(NSRange)range { - [self.input->textView.textStorage + [self.host.textView.textStorage enumerateAttribute:NSFontAttributeName inRange:range options:0 @@ -30,19 +30,19 @@ - (void)applyStyling:(NSRange)range { UIFont *currentFont = (UIFont *)value; if (currentFont == nullptr) return; - UIFont *monoFont = [[[self.input->config monospacedFont] + UIFont *monoFont = [[[self.host.config monospacedFont] withFontTraits:currentFont] setSize:currentFont.pointSize]; if (monoFont != nullptr) { - [self.input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSFontAttributeName value:monoFont range:subRange]; } }]; - [self.input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSForegroundColorAttributeName - value:[self.input->config codeBlockFgColor] + value:[self.host.config codeBlockFgColor] range:range]; } diff --git a/ios/styles/EnrichedTextStyles.mm b/ios/styles/EnrichedTextStyles.mm new file mode 100644 index 00000000..d4b6cbfb --- /dev/null +++ b/ios/styles/EnrichedTextStyles.mm @@ -0,0 +1,58 @@ +#import "EnrichedTextStyleHeaders.h" + +@implementation EnrichedTextBoldStyle +@end + +@implementation EnrichedTextItalicStyle +@end + +@implementation EnrichedTextUnderlineStyle +@end + +@implementation EnrichedTextStrikethroughStyle +@end + +@implementation EnrichedTextInlineCodeStyle +@end + +@implementation EnrichedTextH1Style +@end + +@implementation EnrichedTextH2Style +@end + +@implementation EnrichedTextH3Style +@end + +@implementation EnrichedTextH4Style +@end + +@implementation EnrichedTextH5Style +@end + +@implementation EnrichedTextH6Style +@end + +@implementation EnrichedTextBlockQuoteStyle +@end + +@implementation EnrichedTextCodeBlockStyle +@end + +@implementation EnrichedTextUnorderedListStyle +@end + +@implementation EnrichedTextOrderedListStyle +@end + +@implementation EnrichedTextLinkStyle +@end + +@implementation EnrichedTextMentionStyle +@end + +@implementation EnrichedTextCheckboxListStyle +@end + +@implementation EnrichedTextImageStyle +@end diff --git a/ios/styles/H1Style.mm b/ios/styles/H1Style.mm index c04c26f3..c68c46a6 100644 --- a/ios/styles/H1Style.mm +++ b/ios/styles/H1Style.mm @@ -12,9 +12,9 @@ - (BOOL)isParagraph { return YES; } - (CGFloat)getHeadingFontSize { - return [self.input->config h1FontSize]; + return [self.host.config h1FontSize]; } - (BOOL)isHeadingBold { - return [self.input->config h1Bold]; + return [self.host.config h1Bold]; } @end diff --git a/ios/styles/H2Style.mm b/ios/styles/H2Style.mm index 785b2b72..98021bad 100644 --- a/ios/styles/H2Style.mm +++ b/ios/styles/H2Style.mm @@ -12,9 +12,9 @@ - (BOOL)isParagraph { return YES; } - (CGFloat)getHeadingFontSize { - return [self.input->config h2FontSize]; + return [self.host.config h2FontSize]; } - (BOOL)isHeadingBold { - return [self.input->config h2Bold]; + return [self.host.config h2Bold]; } @end diff --git a/ios/styles/H3Style.mm b/ios/styles/H3Style.mm index 9091f6e3..41190238 100644 --- a/ios/styles/H3Style.mm +++ b/ios/styles/H3Style.mm @@ -12,9 +12,9 @@ - (BOOL)isParagraph { return YES; } - (CGFloat)getHeadingFontSize { - return [self.input->config h3FontSize]; + return [self.host.config h3FontSize]; } - (BOOL)isHeadingBold { - return [self.input->config h3Bold]; + return [self.host.config h3Bold]; } @end diff --git a/ios/styles/H4Style.mm b/ios/styles/H4Style.mm index a641f5ed..135412af 100644 --- a/ios/styles/H4Style.mm +++ b/ios/styles/H4Style.mm @@ -12,9 +12,9 @@ - (BOOL)isParagraph { return YES; } - (CGFloat)getHeadingFontSize { - return [self.input->config h4FontSize]; + return [self.host.config h4FontSize]; } - (BOOL)isHeadingBold { - return [self.input->config h4Bold]; + return [self.host.config h4Bold]; } @end diff --git a/ios/styles/H5Style.mm b/ios/styles/H5Style.mm index 40fab0f2..01f77517 100644 --- a/ios/styles/H5Style.mm +++ b/ios/styles/H5Style.mm @@ -12,9 +12,9 @@ - (BOOL)isParagraph { return YES; } - (CGFloat)getHeadingFontSize { - return [self.input->config h5FontSize]; + return [self.host.config h5FontSize]; } - (BOOL)isHeadingBold { - return [self.input->config h5Bold]; + return [self.host.config h5Bold]; } @end diff --git a/ios/styles/H6Style.mm b/ios/styles/H6Style.mm index 2e576bb0..0f87b37f 100644 --- a/ios/styles/H6Style.mm +++ b/ios/styles/H6Style.mm @@ -12,9 +12,9 @@ - (BOOL)isParagraph { return YES; } - (CGFloat)getHeadingFontSize { - return [self.input->config h6FontSize]; + return [self.host.config h6FontSize]; } - (BOOL)isHeadingBold { - return [self.input->config h6Bold]; + return [self.host.config h6Bold]; } @end diff --git a/ios/styles/HeadingStyleBase.mm b/ios/styles/HeadingStyleBase.mm index 6c2e83a2..15c389d4 100644 --- a/ios/styles/HeadingStyleBase.mm +++ b/ios/styles/HeadingStyleBase.mm @@ -23,7 +23,7 @@ - (BOOL)isParagraph { } - (void)applyStyling:(NSRange)range { - [self.input->textView.textStorage + [self.host.textView.textStorage enumerateAttribute:NSFontAttributeName inRange:range options:0 @@ -36,27 +36,26 @@ - (void)applyStyling:(NSRange)range { if ([self isHeadingBold]) { newFont = [newFont setBold]; } - [self.input->textView.textStorage - addAttribute:NSFontAttributeName - value:newFont - range:subRange]; + [self.host.textView.textStorage addAttribute:NSFontAttributeName + value:newFont + range:subRange]; }]; } // used to make sure headings dont persist after a newline is placed - (BOOL)handleNewlinesInRange:(NSRange)range replacementText:(NSString *)text { // in a heading and a new text ends with a newline - if ([self detect:self.input->textView.selectedRange] && text.length > 0 && + if ([self detect:self.host.textView.selectedRange] && text.length > 0 && [[NSCharacterSet newlineCharacterSet] characterIsMember:[text characterAtIndex:text.length - 1]]) { // do the replacement manually [TextInsertionUtils replaceText:text at:range additionalAttributes:nullptr - input:self.input + input:self.host withSelection:YES]; // remove the attributes at the new selection - [self remove:self.input->textView.selectedRange withDirtyRange:YES]; + [self remove:self.host.textView.selectedRange withDirtyRange:YES]; return YES; } return NO; diff --git a/ios/styles/ImageStyle.mm b/ios/styles/ImageStyle.mm index 4149e30c..4dfa1bbd 100644 --- a/ios/styles/ImageStyle.mm +++ b/ios/styles/ImageStyle.mm @@ -33,13 +33,13 @@ - (void)reapplyFromStylePair:(StylePair *)pair { ImageAttachment *attachment = [[ImageAttachment alloc] initWithImageData:imageData]; - attachment.delegate = self.input; + attachment.delegate = (id)self.host; - [self.input->textView.textStorage addAttributes:@{ + [self.host.textView.textStorage addAttributes:@{ NSAttachmentAttributeName : attachment, ImageAttributeName : imageData } - range:range]; + range:range]; } - (AttributeEntry *)getEntryIfPresent:(NSRange)range { @@ -51,15 +51,15 @@ - (void)toggle:(NSRange)range { } - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { - [self.input->textView.textStorage beginEditing]; - [self.input->textView.textStorage removeAttribute:ImageAttributeName - range:range]; - [self.input->textView.textStorage removeAttribute:NSAttachmentAttributeName - range:range]; - [self.input->textView.textStorage endEditing]; + [self.host.textView.textStorage beginEditing]; + [self.host.textView.textStorage removeAttribute:ImageAttributeName + range:range]; + [self.host.textView.textStorage removeAttribute:NSAttachmentAttributeName + range:range]; + [self.host.textView.textStorage endEditing]; if (withDirtyRange) { - [self.input->attributesManager addDirtyRange:range]; + [self.host.attributesManager addDirtyRange:range]; } [self removeTyping]; @@ -67,11 +67,11 @@ - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { - (void)removeTyping { NSMutableDictionary *currentAttributes = - [self.input->textView.typingAttributes mutableCopy]; + [self.host.textView.typingAttributes mutableCopy]; [currentAttributes removeObjectForKey:ImageAttributeName]; [currentAttributes removeObjectForKey:NSAttachmentAttributeName]; - [self.input->attributesManager didRemoveTypingAttribute:ImageAttributeName]; - self.input->textView.typingAttributes = currentAttributes; + [self.host.attributesManager didRemoveTypingAttribute:ImageAttributeName]; + self.host.textView.typingAttributes = currentAttributes; } - (BOOL)styleCondition:(id _Nullable)value range:(NSRange)range { @@ -80,32 +80,33 @@ - (BOOL)styleCondition:(id _Nullable)value range:(NSRange)range { - (ImageData *)getImageDataAt:(NSUInteger)location { NSRange imageRange = NSMakeRange(0, 0); - NSRange inputRange = NSMakeRange(0, self.input->textView.textStorage.length); + NSRange inputRange = NSMakeRange(0, self.host.textView.textStorage.length); // don't search at the very end of input NSUInteger searchLocation = location; - if (searchLocation == self.input->textView.textStorage.length) { + if (searchLocation == self.host.textView.textStorage.length) { return nullptr; } ImageData *imageData = - [self.input->textView.textStorage attribute:ImageAttributeName - atIndex:searchLocation - longestEffectiveRange:&imageRange - inRange:inputRange]; + [self.host.textView.textStorage attribute:ImageAttributeName + atIndex:searchLocation + longestEffectiveRange:&imageRange + inRange:inputRange]; return imageData; } - (void)addImageAtRange:(NSRange)range imageData:(ImageData *)imageData - withSelection:(BOOL)withSelection { + withSelection:(BOOL)withSelection + withDirtyRange:(BOOL)withDirtyRange { if (!imageData) return; ImageAttachment *attachment = [[ImageAttachment alloc] initWithImageData:imageData]; - attachment.delegate = self.input; + attachment.delegate = (id)self.host; NSDictionary *attributes = @{NSAttachmentAttributeName : attachment, ImageAttributeName : imageData}; @@ -118,18 +119,20 @@ - (void)addImageAtRange:(NSRange)range [TextInsertionUtils insertText:placeholderChar at:range.location additionalAttributes:attributes - input:self.input + input:self.host withSelection:withSelection]; } else { [TextInsertionUtils replaceText:placeholderChar at:range additionalAttributes:attributes - input:self.input + input:self.host withSelection:withSelection]; } - NSRange insertedImageRange = NSMakeRange(range.location, 1); - [self.input->attributesManager addDirtyRange:insertedImageRange]; + if (withDirtyRange) { + NSRange insertedImageRange = NSMakeRange(range.location, 1); + [self.host.attributesManager addDirtyRange:insertedImageRange]; + } } - (void)addImage:(NSString *)uri width:(CGFloat)width height:(CGFloat)height { @@ -138,9 +141,10 @@ - (void)addImage:(NSString *)uri width:(CGFloat)width height:(CGFloat)height { data.width = width; data.height = height; - [self addImageAtRange:self.input->textView.selectedRange + [self addImageAtRange:self.host.textView.selectedRange imageData:data - withSelection:YES]; + withSelection:YES + withDirtyRange:YES]; } @end diff --git a/ios/styles/InlineCodeStyle.mm b/ios/styles/InlineCodeStyle.mm index 054332a7..4511858a 100644 --- a/ios/styles/InlineCodeStyle.mm +++ b/ios/styles/InlineCodeStyle.mm @@ -21,29 +21,29 @@ - (BOOL)isParagraph { - (void)applyStyling:(NSRange)range { // we don't want to apply inline code to newline characters, it looks bad NSArray *nonNewlineRanges = - [RangeUtils getNonNewlineRangesIn:self.input->textView range:range]; + [RangeUtils getNonNewlineRangesIn:self.host.textView range:range]; for (NSValue *value in nonNewlineRanges) { NSRange subRange = [value rangeValue]; - [self.input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSBackgroundColorAttributeName - value:[[self.input->config inlineCodeBgColor] + value:[[self.host.config inlineCodeBgColor] colorWithAlphaIfNotTransparent:0.4] range:subRange]; - [self.input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSForegroundColorAttributeName - value:[self.input->config inlineCodeFgColor] + value:[self.host.config inlineCodeFgColor] range:subRange]; - [self.input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSUnderlineColorAttributeName - value:[self.input->config inlineCodeFgColor] + value:[self.host.config inlineCodeFgColor] range:subRange]; - [self.input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSStrikethroughColorAttributeName - value:[self.input->config inlineCodeFgColor] + value:[self.host.config inlineCodeFgColor] range:subRange]; - [self.input->textView.textStorage + [self.host.textView.textStorage enumerateAttribute:NSFontAttributeName inRange:subRange options:0 @@ -51,9 +51,9 @@ - (void)applyStyling:(NSRange)range { BOOL *_Nonnull stop) { UIFont *font = (UIFont *)value; if (font != nullptr) { - UIFont *newFont = [[[self.input->config monospacedFont] + UIFont *newFont = [[[self.host.config monospacedFont] withFontTraits:font] setSize:font.pointSize]; - [self.input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSFontAttributeName value:newFont range:fontRange]; diff --git a/ios/styles/ItalicStyle.mm b/ios/styles/ItalicStyle.mm index 963a213e..f2a16114 100644 --- a/ios/styles/ItalicStyle.mm +++ b/ios/styles/ItalicStyle.mm @@ -17,7 +17,7 @@ - (BOOL)isParagraph { } - (void)applyStyling:(NSRange)range { - [self.input->textView.textStorage + [self.host.textView.textStorage enumerateAttribute:NSFontAttributeName inRange:range options:0 @@ -26,7 +26,7 @@ - (void)applyStyling:(NSRange)range { UIFont *font = (UIFont *)value; if (font != nullptr) { UIFont *newFont = [font setItalic]; - [self.input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSFontAttributeName value:newFont range:range]; diff --git a/ios/styles/LinkStyle.mm b/ios/styles/LinkStyle.mm index 626969e8..3cc00762 100644 --- a/ios/styles/LinkStyle.mm +++ b/ios/styles/LinkStyle.mm @@ -31,13 +31,13 @@ - (void)applyStyling:(NSRange)range { } NSMutableDictionary *newAttrs = [[NSMutableDictionary alloc] init]; - newAttrs[NSForegroundColorAttributeName] = [self.input->config linkColor]; - newAttrs[NSUnderlineColorAttributeName] = [self.input->config linkColor]; - newAttrs[NSStrikethroughColorAttributeName] = [self.input->config linkColor]; - if ([self.input->config linkDecorationLine] == DecorationUnderline) { + newAttrs[NSForegroundColorAttributeName] = [self.host.config linkColor]; + newAttrs[NSUnderlineColorAttributeName] = [self.host.config linkColor]; + newAttrs[NSStrikethroughColorAttributeName] = [self.host.config linkColor]; + if ([self.host.config linkDecorationLine] == DecorationUnderline) { newAttrs[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle); } - [self.input->textView.textStorage addAttributes:newAttrs range:range]; + [self.host.textView.textStorage addAttributes:newAttrs range:range]; } - (void)reapplyFromStylePair:(StylePair *)pair { @@ -63,34 +63,34 @@ - (void)toggle:(NSRange)range { // we have to make sure all links in the range get fully removed here - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { NSArray *links = [self all:range]; - [self.input->textView.textStorage beginEditing]; + [self.host.textView.textStorage beginEditing]; for (StylePair *pair in links) { NSRange linkRange = [self getFullLinkRangeAt:[pair.rangeValue rangeValue].location]; - [self.input->textView.textStorage removeAttribute:ManualLinkAttributeName - range:linkRange]; - [self.input->textView.textStorage removeAttribute:AutomaticLinkAttributeName - range:linkRange]; + [self.host.textView.textStorage removeAttribute:ManualLinkAttributeName + range:linkRange]; + [self.host.textView.textStorage removeAttribute:AutomaticLinkAttributeName + range:linkRange]; if (withDirtyRange) { - [self.input->attributesManager addDirtyRange:linkRange]; + [self.host.attributesManager addDirtyRange:linkRange]; } } - [self.input->textView.textStorage endEditing]; + [self.host.textView.textStorage endEditing]; [self removeLinkMetaFromTypingAttributes]; } // used for conflicts, we have to remove the whole link - (void)removeTyping { NSRange linkRange = - [self getFullLinkRangeAt:self.input->textView.selectedRange.location]; + [self getFullLinkRangeAt:self.host.textView.selectedRange.location]; if (linkRange.length > 0) { - [self.input->textView.textStorage beginEditing]; - [self.input->textView.textStorage removeAttribute:ManualLinkAttributeName - range:linkRange]; - [self.input->textView.textStorage removeAttribute:AutomaticLinkAttributeName - range:linkRange]; - [self.input->textView.textStorage endEditing]; - [self.input->attributesManager addDirtyRange:linkRange]; + [self.host.textView.textStorage beginEditing]; + [self.host.textView.textStorage removeAttribute:ManualLinkAttributeName + range:linkRange]; + [self.host.textView.textStorage removeAttribute:AutomaticLinkAttributeName + range:linkRange]; + [self.host.textView.textStorage endEditing]; + [self.host.attributesManager addDirtyRange:linkRange]; } [self removeLinkMetaFromTypingAttributes]; } @@ -104,7 +104,7 @@ - (BOOL)detect:(NSRange)range { if (range.length >= 1) { BOOL onlyLinks = [OccurenceUtils detectMultiple:@[ ManualLinkAttributeName, AutomaticLinkAttributeName ] - withInput:self.input + withHost:self.host inRange:range withCondition:^BOOL(id _Nullable value, NSRange subrange) { return [self styleCondition:value range:subrange]; @@ -117,7 +117,7 @@ - (BOOL)detect:(NSRange)range { - (BOOL)any:(NSRange)range { return [OccurenceUtils anyMultiple:@[ ManualLinkAttributeName, AutomaticLinkAttributeName ] - withInput:self.input + withHost:self.host inRange:range withCondition:^BOOL(id _Nullable value, NSRange subrange) { return [self styleCondition:value range:subrange]; @@ -127,7 +127,7 @@ - (BOOL)any:(NSRange)range { - (NSArray *)all:(NSRange)range { return [OccurenceUtils allMultiple:@[ ManualLinkAttributeName, AutomaticLinkAttributeName ] - withInput:self.input + withHost:self.host inRange:range withCondition:^BOOL(id _Nullable value, NSRange subrange) { return [self styleCondition:value range:subrange]; @@ -140,21 +140,21 @@ - (void)applyLinkMetaWithData:(LinkData *)linkData range:(NSRange)range { } NSString *key = linkData.isManual ? ManualLinkAttributeName : AutomaticLinkAttributeName; - [self.input->textView.textStorage addAttribute:key - value:[linkData copy] - range:range]; + [self.host.textView.textStorage addAttribute:key + value:[linkData copy] + range:range]; } - (void)removeLinkMetaFromTypingAttributes { NSMutableDictionary *newTypingAttrs = - [self.input->textView.typingAttributes mutableCopy]; + [self.host.textView.typingAttributes mutableCopy]; [newTypingAttrs removeObjectForKey:ManualLinkAttributeName]; [newTypingAttrs removeObjectForKey:AutomaticLinkAttributeName]; - self.input->textView.typingAttributes = newTypingAttrs; + self.host.textView.typingAttributes = newTypingAttrs; - [self.input->attributesManager + [self.host.attributesManager didRemoveTypingAttribute:ManualLinkAttributeName]; - [self.input->attributesManager + [self.host.attributesManager didRemoveTypingAttribute:AutomaticLinkAttributeName]; } @@ -162,7 +162,7 @@ - (void)addLink:(LinkData *)linkData range:(NSRange)range withSelection:(BOOL)withSelection { NSString *currentText = - [self.input->textView.textStorage.string substringWithRange:range]; + [self.host.textView.textStorage.string substringWithRange:range]; NSString *key = linkData.isManual ? ManualLinkAttributeName : AutomaticLinkAttributeName; @@ -175,7 +175,7 @@ - (void)addLink:(LinkData *)linkData [TextInsertionUtils insertText:linkData.text at:range.location additionalAttributes:metaAttrs - input:self.input + input:self.host withSelection:withSelection]; dirtyRange = NSMakeRange(range.location, linkData.text.length); } else if ([currentText isEqualToString:linkData.text]) { @@ -185,8 +185,8 @@ - (void)addLink:(LinkData *)linkData // manually set it behind the link ONLY with manual links, automatic ones // don't need the selection fix if (linkData.isManual && withSelection) { - [self.input->textView reactFocus]; - self.input->textView.selectedRange = + [self.host.textView reactFocus]; + self.host.textView.selectedRange = NSMakeRange(range.location + linkData.text.length, 0); } } else { @@ -194,18 +194,18 @@ - (void)addLink:(LinkData *)linkData [TextInsertionUtils replaceText:linkData.text at:range additionalAttributes:metaAttrs - input:self.input + input:self.host withSelection:withSelection]; dirtyRange = NSMakeRange(range.location, linkData.text.length); } // add new dirty range - [self.input->attributesManager addDirtyRange:dirtyRange]; + [self.host.attributesManager addDirtyRange:dirtyRange]; // mandatory connected links check NSDictionary *currentWord = - [WordsUtils getCurrentWord:self.input->textView.textStorage.string - range:self.input->textView.selectedRange]; + [WordsUtils getCurrentWord:self.host.textView.textStorage.string + range:self.host.textView.selectedRange]; if (currentWord != nullptr) { // get word properties NSString *wordText = (NSString *)[currentWord objectForKey:@"word"]; @@ -221,24 +221,24 @@ - (void)addLink:(LinkData *)linkData - (LinkData *)getLinkDataAt:(NSUInteger)location { NSRange manualLinkRange = NSMakeRange(0, 0); NSRange automaticLinkRange = NSMakeRange(0, 0); - NSRange inputRange = NSMakeRange(0, self.input->textView.textStorage.length); + NSRange inputRange = NSMakeRange(0, self.host.textView.textStorage.length); // don't search at the very end of input NSUInteger searchLocation = location; - if (searchLocation == self.input->textView.textStorage.length) { + if (searchLocation == self.host.textView.textStorage.length) { return nullptr; } LinkData *manualData = - [self.input->textView.textStorage attribute:ManualLinkAttributeName - atIndex:searchLocation - longestEffectiveRange:&manualLinkRange - inRange:inputRange]; + [self.host.textView.textStorage attribute:ManualLinkAttributeName + atIndex:searchLocation + longestEffectiveRange:&manualLinkRange + inRange:inputRange]; LinkData *automaticData = - [self.input->textView.textStorage attribute:AutomaticLinkAttributeName - atIndex:searchLocation - longestEffectiveRange:&automaticLinkRange - inRange:inputRange]; + [self.host.textView.textStorage attribute:AutomaticLinkAttributeName + atIndex:searchLocation + longestEffectiveRange:&automaticLinkRange + inRange:inputRange]; if ((manualData == nullptr && automaticData == nullptr) || (manualLinkRange.length == 0 && automaticLinkRange.length == 0)) { @@ -252,11 +252,11 @@ - (LinkData *)getLinkDataAt:(NSUInteger)location { - (NSRange)getFullLinkRangeAt:(NSUInteger)location { NSRange manualLinkRange = NSMakeRange(0, 0); NSRange automaticLinkRange = NSMakeRange(0, 0); - NSRange inputRange = NSMakeRange(0, self.input->textView.textStorage.length); + NSRange inputRange = NSMakeRange(0, self.host.textView.textStorage.length); // get the previous index if possible when at the very end of input NSUInteger searchLocation = location; - if (searchLocation == self.input->textView.textStorage.length) { + if (searchLocation == self.host.textView.textStorage.length) { if (searchLocation == 0) { return NSMakeRange(0, 0); } @@ -264,15 +264,15 @@ - (NSRange)getFullLinkRangeAt:(NSUInteger)location { } LinkData *manualData = - [self.input->textView.textStorage attribute:ManualLinkAttributeName - atIndex:searchLocation - longestEffectiveRange:&manualLinkRange - inRange:inputRange]; + [self.host.textView.textStorage attribute:ManualLinkAttributeName + atIndex:searchLocation + longestEffectiveRange:&manualLinkRange + inRange:inputRange]; LinkData *automaticData = - [self.input->textView.textStorage attribute:AutomaticLinkAttributeName - atIndex:searchLocation - longestEffectiveRange:&automaticLinkRange - inRange:inputRange]; + [self.host.textView.textStorage attribute:AutomaticLinkAttributeName + atIndex:searchLocation + longestEffectiveRange:&automaticLinkRange + inRange:inputRange]; return manualData == nullptr ? automaticData == nullptr ? NSMakeRange(0, 0) : automaticLinkRange @@ -281,7 +281,7 @@ - (NSRange)getFullLinkRangeAt:(NSUInteger)location { // handles detecting and removing automatic links - (void)handleAutomaticLinks:(NSString *)word inRange:(NSRange)wordRange { - LinkRegexConfig *linkRegexConfig = [self.input->config linkRegexConfig]; + LinkRegexConfig *linkRegexConfig = [self.host.config linkRegexConfig]; // no automatic links with isDisabled if (linkRegexConfig.isDisabled) { @@ -289,11 +289,11 @@ - (void)handleAutomaticLinks:(NSString *)word inRange:(NSRange)wordRange { } InlineCodeStyle *inlineCodeStyle = - [self.input->stylesDict objectForKey:@([InlineCodeStyle getType])]; + [self.host.stylesDict objectForKey:@([InlineCodeStyle getType])]; MentionStyle *mentionStyle = - [self.input->stylesDict objectForKey:@([MentionStyle getType])]; + [self.host.stylesDict objectForKey:@([MentionStyle getType])]; CodeBlockStyle *codeBlockStyle = - [self.input->stylesDict objectForKey:@([CodeBlockStyle getType])]; + [self.host.stylesDict objectForKey:@([CodeBlockStyle getType])]; // we don't recognize links along mentions, inline code or codeblocks if (mentionStyle != nullptr && [mentionStyle any:wordRange]) { @@ -311,7 +311,7 @@ - (void)handleAutomaticLinks:(NSString *)word inRange:(NSRange)wordRange { // we don't recognize automatic links along manual ones __block BOOL manualLinkPresent = NO; - [self.input->textView.textStorage + [self.host.textView.textStorage enumerateAttribute:ManualLinkAttributeName inRange:wordRange options:0 @@ -336,7 +336,7 @@ - (void)handleAutomaticLinks:(NSString *)word inRange:(NSRange)wordRange { matchRange:matchingRange]; } else { // use user defined regex if it exists - NSRegularExpression *userRegex = [self.input->config parsedLinkRegex]; + NSRegularExpression *userRegex = [self.host.config parsedLinkRegex]; if (userRegex == nullptr) { // fallback to default regex @@ -368,7 +368,7 @@ - (void)handleAutomaticLinks:(NSString *)word inRange:(NSRange)wordRange { [self addLink:newData range:wordRange withSelection:NO]; // emit onLinkDetected if style was added - [self.input emitOnLinkDetectedEvent:newData range:wordRange]; + [(id)self.host emitOnLinkDetectedEvent:newData range:wordRange]; } } else if ([self any:wordRange]) { // there was some automatic link (because anyOccurence is true and we are @@ -403,7 +403,7 @@ - (void)handleManualLinks:(NSString *)word inRange:(NSRange)wordRange { __block NSInteger manualLinkMinIdx = -1; __block NSInteger manualLinkMaxIdx = -1; - [self.input->textView.textStorage + [self.host.textView.textStorage enumerateAttribute:ManualLinkAttributeName inRange:wordRange options:0 @@ -437,7 +437,7 @@ - (void)handleManualLinks:(NSString *)word inRange:(NSRange)wordRange { NSRange newRange = NSMakeRange(manualLinkMinIdx, manualLinkMaxIdx - manualLinkMinIdx + 1); [self applyLinkMetaWithData:manualLinkMinValue range:newRange]; - [self.input->attributesManager addDirtyRange:newRange]; + [self.host.attributesManager addDirtyRange:newRange]; } } @@ -452,14 +452,14 @@ - (BOOL)isSingleLinkIn:(NSRange)range { - (void)removeConnectedLinksIfNeeded:(NSString *)word range:(NSRange)wordRange { BOOL anyAutomatic = [OccurenceUtils any:AutomaticLinkAttributeName - withInput:self.input + withHost:self.host inRange:wordRange withCondition:^BOOL(id _Nullable value, NSRange subrange) { return [self styleCondition:value range:subrange]; }]; BOOL anyManual = [OccurenceUtils any:ManualLinkAttributeName - withInput:self.input + withHost:self.host inRange:wordRange withCondition:^BOOL(id _Nullable value, NSRange subrange) { return [self styleCondition:value range:subrange]; @@ -474,7 +474,7 @@ - (void)removeConnectedLinksIfNeeded:(NSString *)word range:(NSRange)wordRange { // covers the whole word BOOL onlyLinks = [OccurenceUtils detectMultiple:@[ ManualLinkAttributeName, AutomaticLinkAttributeName ] - withInput:self.input + withHost:self.host inRange:wordRange withCondition:^BOOL(id _Nullable value, NSRange r) { return [self styleCondition:value range:r]; diff --git a/ios/styles/MentionStyle.mm b/ios/styles/MentionStyle.mm index bd34971e..1201b472 100644 --- a/ios/styles/MentionStyle.mm +++ b/ios/styles/MentionStyle.mm @@ -27,8 +27,8 @@ - (BOOL)isParagraph { return NO; } -- (instancetype)initWithInput:(id)input { - self = [super initWithInput:(EnrichedTextInputView *)input]; +- (instancetype)initWithHost:(id)host { + self = [super initWithHost:host]; if (self) { _activeMentionRange = nullptr; _activeMentionIndicator = nullptr; @@ -47,7 +47,7 @@ - (void)applyStyling:(NSRange)range { } MentionStyleProps *styleProps = - [self.input->config mentionStylePropsForIndicator:params.indicator]; + [self.host.config mentionStylePropsForIndicator:params.indicator]; NSMutableDictionary *newAttrs = [@{ NSForegroundColorAttributeName : styleProps.color, @@ -61,7 +61,7 @@ - (void)applyStyling:(NSRange)range { newAttrs[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle); } - [self.input->textView.textStorage addAttributes:newAttrs range:range]; + [self.host.textView.textStorage addAttributes:newAttrs range:range]; } - (void)reapplyFromStylePair:(StylePair *)pair { @@ -86,20 +86,20 @@ - (void)toggle:(NSRange)range { // other styles - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { NSArray *mentions = [self all:range]; - [self.input->textView.textStorage beginEditing]; + [self.host.textView.textStorage beginEditing]; for (StylePair *pair in mentions) { NSRange mentionRange = [self getFullMentionRangeAt:[pair.rangeValue rangeValue].location]; if (mentionRange.length == 0) { continue; } - [self.input->textView.textStorage removeAttribute:MentionAttributeName - range:mentionRange]; + [self.host.textView.textStorage removeAttribute:MentionAttributeName + range:mentionRange]; if (withDirtyRange) { - [self.input->attributesManager addDirtyRange:mentionRange]; + [self.host.attributesManager addDirtyRange:mentionRange]; } } - [self.input->textView.textStorage endEditing]; + [self.host.textView.textStorage endEditing]; [super removeTyping]; } @@ -107,13 +107,13 @@ - (void)remove:(NSRange)range withDirtyRange:(BOOL)withDirtyRange { // used for conflicts, we have to remove the whole mention - (void)removeTyping { NSRange mentionRange = - [self getFullMentionRangeAt:self.input->textView.selectedRange.location]; + [self getFullMentionRangeAt:self.host.textView.selectedRange.location]; if (mentionRange.length > 0) { - [self.input->textView.textStorage beginEditing]; - [self.input->textView.textStorage removeAttribute:MentionAttributeName - range:mentionRange]; - [self.input->textView.textStorage endEditing]; - [self.input->attributesManager addDirtyRange:mentionRange]; + [self.host.textView.textStorage beginEditing]; + [self.host.textView.textStorage removeAttribute:MentionAttributeName + range:mentionRange]; + [self.host.textView.textStorage endEditing]; + [self.host.attributesManager addDirtyRange:mentionRange]; } [super removeTyping]; } @@ -131,9 +131,9 @@ - (BOOL)detect:(NSRange)range { } - (void)applyMentionMeta:(MentionParams *)params range:(NSRange)range { - [self.input->textView.textStorage addAttribute:MentionAttributeName - value:params - range:range]; + [self.host.textView.textStorage addAttribute:MentionAttributeName + value:params + range:range]; } // MARK: - Public non-standard methods @@ -158,13 +158,13 @@ - (void)addMention:(NSString *)indicator [TextInsertionUtils replaceText:newText at:rangeToBeReplaced additionalAttributes:nullptr - input:self.input + input:self.host withSelection:YES]; // THEN, add the attributes to not apply them on the space NSRange mentionRange = NSMakeRange(rangeToBeReplaced.location, text.length); [self applyMentionMeta:params range:mentionRange]; - [self.input->attributesManager addDirtyRange:mentionRange]; + [self.host.attributesManager addDirtyRange:mentionRange]; // mention editing should finish [self removeActiveMentionRange]; @@ -175,19 +175,19 @@ - (void)addMentionAtRange:(NSRange)range params:(MentionParams *)params { _blockMentionEditing = YES; [self applyMentionMeta:params range:range]; - [self.input->attributesManager addDirtyRange:range]; + [self.host.attributesManager addDirtyRange:range]; _blockMentionEditing = NO; } - (void)startMentionWithIndicator:(NSString *)indicator { - NSRange currentRange = self.input->textView.selectedRange; + NSRange currentRange = self.host.textView.selectedRange; BOOL addSpaceBefore = NO; BOOL addSpaceAfter = NO; if (currentRange.location > 0) { - unichar charBefore = [self.input->textView.textStorage.string + unichar charBefore = [self.host.textView.textStorage.string characterAtIndex:(currentRange.location - 1)]; if (![[NSCharacterSet whitespaceAndNewlineCharacterSet] characterIsMember:charBefore]) { @@ -196,8 +196,8 @@ - (void)startMentionWithIndicator:(NSString *)indicator { } if (currentRange.location + currentRange.length < - self.input->textView.textStorage.string.length) { - unichar charAfter = [self.input->textView.textStorage.string + self.host.textView.textStorage.string.length) { + unichar charAfter = [self.host.textView.textStorage.string characterAtIndex:(currentRange.location + currentRange.length)]; if (![[NSCharacterSet whitespaceAndNewlineCharacterSet] characterIsMember:charAfter]) { @@ -216,18 +216,18 @@ - (void)startMentionWithIndicator:(NSString *)indicator { [TextInsertionUtils insertText:finalString at:currentRange.location additionalAttributes:nullptr - input:self.input + input:self.host withSelection:NO]; } else { [TextInsertionUtils replaceText:finalString at:currentRange additionalAttributes:nullptr - input:self.input + input:self.host withSelection:NO]; } - [self.input->textView reactFocus]; - self.input->textView.selectedRange = newSelect; + [self.host.textView reactFocus]; + self.host.textView.selectedRange = newSelect; } // handles removing no longer valid mentions @@ -237,7 +237,7 @@ - (void)handleExistingMentions { // any number of spaces, which makes one mention any number of words long NSRange wholeText = - NSMakeRange(0, self.input->textView.textStorage.string.length); + NSMakeRange(0, self.host.textView.textStorage.string.length); // get mentions in ascending range.location order NSArray *mentions = [[self all:wholeText] sortedArrayUsingComparator:^NSComparisonResult(id _Nonnull obj1, @@ -271,8 +271,8 @@ - (void)handleExistingMentions { } // check for text, any modifications to it makes mention invalid - NSString *existingText = [self.input->textView.textStorage.string - substringWithRange:currentRange]; + NSString *existingText = + [self.host.textView.textStorage.string substringWithRange:currentRange]; if (![existingText isEqualToString:currentText]) { [rangesToRemove addObject:[NSValue valueWithRange:currentRange]]; } @@ -291,7 +291,7 @@ - (void)manageMentionEditing { } // we don't take longer selections into consideration - if (self.input->textView.selectedRange.length > 0) { + if (self.host.textView.selectedRange.length > 0) { [self removeActiveMentionRange]; return; } @@ -307,14 +307,14 @@ - (void)manageMentionEditing { // get style classes that the mention shouldn't be recognized in, together // with other mentions - NSArray *conflicts = self.input->conflictingStyles[@([MentionStyle getType])]; - NSArray *blocks = self.input->blockingStyles[@([MentionStyle getType])]; + NSArray *conflicts = self.host.conflictingStyles[@([MentionStyle getType])]; + NSArray *blocks = self.host.blockingStyles[@([MentionStyle getType])]; NSArray *allConflicts = [[conflicts arrayByAddingObjectsFromArray:blocks] arrayByAddingObject:@([MentionStyle getType])]; BOOL conflictingStyle = NO; for (NSNumber *styleType in allConflicts) { - StyleBase *styleInst = self.input->stylesDict[styleType]; + StyleBase *styleInst = self.host.stylesDict[styleType]; if (styleInst != nullptr && [styleInst any:candidateRange]) { conflictingStyle = YES; break; @@ -334,19 +334,19 @@ - (void)manageMentionEditing { // returns mention params if it exists - (MentionParams *)getMentionParamsAt:(NSUInteger)location { NSRange mentionRange = NSMakeRange(0, 0); - NSRange inputRange = NSMakeRange(0, self.input->textView.textStorage.length); + NSRange inputRange = NSMakeRange(0, self.host.textView.textStorage.length); // don't search at the very end of input NSUInteger searchLocation = location; - if (searchLocation == self.input->textView.textStorage.length) { + if (searchLocation == self.host.textView.textStorage.length) { return nullptr; } MentionParams *value = - [self.input->textView.textStorage attribute:MentionAttributeName - atIndex:searchLocation - longestEffectiveRange:&mentionRange - inRange:inputRange]; + [self.host.textView.textStorage attribute:MentionAttributeName + atIndex:searchLocation + longestEffectiveRange:&mentionRange + inRange:inputRange]; return value; } @@ -357,26 +357,26 @@ - (NSValue *)getActiveMentionRange { // returns full range of a mention at some location - (NSRange)getFullMentionRangeAt:(NSUInteger)location { NSRange mentionRange = NSMakeRange(0, 0); - NSRange inputRange = NSMakeRange(0, self.input->textView.textStorage.length); + NSRange inputRange = NSMakeRange(0, self.host.textView.textStorage.length); // get the previous index if possible when at the very end of input NSUInteger searchLocation = location; - if (searchLocation == self.input->textView.textStorage.length) { + if (searchLocation == self.host.textView.textStorage.length) { if (searchLocation == 0) { return mentionRange; } searchLocation = searchLocation - 1; } - [self.input->textView.textStorage attribute:MentionAttributeName - atIndex:searchLocation - longestEffectiveRange:&mentionRange - inRange:inputRange]; + [self.host.textView.textStorage attribute:MentionAttributeName + atIndex:searchLocation + longestEffectiveRange:&mentionRange + inRange:inputRange]; return mentionRange; } - (MentionStyleProps *)stylePropsWithParams:(MentionParams *)params { - return [self.input->config mentionStylePropsForIndicator:params.indicator]; + return [self.host.config mentionStylePropsForIndicator:params.indicator]; } // finds if any word/words around current selection are eligible to be edited as @@ -389,9 +389,8 @@ - (NSArray *)getMentionCandidate { NSRange finalRange; // word at the current selection - currentWord = - [WordsUtils getCurrentWord:self.input->textView.textStorage.string - range:self.input->textView.selectedRange]; + currentWord = [WordsUtils getCurrentWord:self.host.textView.textStorage.string + range:self.host.textView.selectedRange]; if (currentWord != nullptr) { currentWordText = (NSString *)[currentWord objectForKey:@"word"]; currentWordRange = (NSValue *)[currentWord objectForKey:@"range"]; @@ -401,7 +400,7 @@ - (NSArray *)getMentionCandidate { // current word exists unichar currentFirstChar = [currentWordText characterAtIndex:0]; - if ([[self.input->config mentionIndicators] + if ([[self.host.config mentionIndicators] containsObject:@(currentFirstChar)]) { // current word exists and has a mention indicator; no need to check for // the previous word @@ -418,7 +417,7 @@ - (NSArray *)getMentionCandidate { return nullptr; } - unichar separatorChar = [self.input->textView.textStorage.string + unichar separatorChar = [self.host.textView.textStorage.string characterAtIndex:previousWordSearchLocation]; if (![[NSCharacterSet whitespaceCharacterSet] characterIsMember:separatorChar]) { @@ -428,7 +427,7 @@ - (NSArray *)getMentionCandidate { } previousWord = [WordsUtils - getCurrentWord:self.input->textView.textStorage.string + getCurrentWord:self.host.textView.textStorage.string range:NSMakeRange(previousWordSearchLocation, 0)]; if (previousWord != nullptr) { @@ -439,7 +438,7 @@ - (NSArray *)getMentionCandidate { // check for the mention indicators in the previous word unichar previousFirstChar = [previousWordText characterAtIndex:0]; - if ([[self.input->config mentionIndicators] + if ([[self.host.config mentionIndicators] containsObject:@(previousFirstChar)]) { // previous word has a proper mention indicator: treat both words as // an editable mention @@ -464,13 +463,13 @@ - (NSArray *)getMentionCandidate { // current word doesn't exist; try getting the previous one NSInteger previousWordSearchLocation = - self.input->textView.selectedRange.location - 1; + self.host.textView.selectedRange.location - 1; if (previousWordSearchLocation < 0) { // previous word can't exist return nullptr; } - unichar separatorChar = [self.input->textView.textStorage.string + unichar separatorChar = [self.host.textView.textStorage.string characterAtIndex:previousWordSearchLocation]; if (![[NSCharacterSet whitespaceCharacterSet] characterIsMember:separatorChar]) { @@ -480,7 +479,7 @@ - (NSArray *)getMentionCandidate { } previousWord = - [WordsUtils getCurrentWord:self.input->textView.textStorage.string + [WordsUtils getCurrentWord:self.host.textView.textStorage.string range:NSMakeRange(previousWordSearchLocation, 0)]; if (previousWord != nullptr) { @@ -491,7 +490,7 @@ - (NSArray *)getMentionCandidate { // check for the mention indicators in the previous word unichar previousFirstChar = [previousWordText characterAtIndex:0]; - if ([[self.input->config mentionIndicators] + if ([[self.host.config mentionIndicators] containsObject:@(previousFirstChar)]) { // previous word has a proper mention indicator; treat previous word + a // space as a editable mention @@ -521,7 +520,7 @@ - (void)setActiveMentionRange:(NSRange)range text:(NSString *)text { [text substringWithRange:NSMakeRange(1, text.length - 1)]; _activeMentionIndicator = indicatorString; _activeMentionRange = [NSValue valueWithRange:range]; - [self.input emitOnMentionEvent:indicatorString text:textString]; + [self.host emitOnMentionEvent:indicatorString text:textString]; } // removes stored mention range + indicator, which means that we no longer edit @@ -531,7 +530,7 @@ - (void)removeActiveMentionRange { NSString *indicatorCopy = [_activeMentionIndicator copy]; _activeMentionIndicator = nullptr; _activeMentionRange = nullptr; - [self.input emitOnMentionEvent:indicatorCopy text:nullptr]; + [self.host emitOnMentionEvent:indicatorCopy text:nullptr]; } } diff --git a/ios/styles/OrderedListStyle.mm b/ios/styles/OrderedListStyle.mm index 60a6089a..291d1a98 100644 --- a/ios/styles/OrderedListStyle.mm +++ b/ios/styles/OrderedListStyle.mm @@ -24,10 +24,10 @@ - (BOOL)needsZWS { - (void)applyStyling:(NSRange)range { // lists are drawn manually // margin before marker + gap between marker and paragraph - CGFloat listHeadIndent = [self.input->config orderedListMarginLeft] + - [self.input->config orderedListGapWidth]; + CGFloat listHeadIndent = [self.host.config orderedListMarginLeft] + + [self.host.config orderedListGapWidth]; - [self.input->textView.textStorage + [self.host.textView.textStorage enumerateAttribute:NSParagraphStyleAttributeName inRange:range options:0 @@ -37,7 +37,7 @@ - (void)applyStyling:(NSRange)range { [(NSParagraphStyle *)value mutableCopy]; pStyle.headIndent = listHeadIndent; pStyle.firstLineHeadIndent = listHeadIndent; - [self.input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSParagraphStyleAttributeName value:pStyle range:range]; @@ -47,28 +47,28 @@ - (void)applyStyling:(NSRange)range { - (BOOL)tryHandlingListShorcutInRange:(NSRange)range replacementText:(NSString *)text { NSRange paragraphRange = - [self.input->textView.textStorage.string paragraphRangeForRange:range]; + [self.host.textView.textStorage.string paragraphRangeForRange:range]; // a dot was added - check if we are both at the paragraph beginning + 1 // character (which we want to be a digit '1') if ([text isEqualToString:@"."] && range.location - 1 == paragraphRange.location) { - unichar charBefore = [self.input->textView.textStorage.string + unichar charBefore = [self.host.textView.textStorage.string characterAtIndex:range.location - 1]; if (charBefore == '1') { // we got a match - add a list if possible - if ([self.input handleStyleBlocksAndConflicts:[[self class] getType] - range:paragraphRange]) { + if ([self.host handleStyleBlocksAndConflicts:[[self class] getType] + range:paragraphRange]) { // don't emit during the replacing - self.input->blockEmitting = YES; + self.host.blockEmitting = YES; // remove the number [TextInsertionUtils replaceText:@"" at:NSMakeRange(paragraphRange.location, 1) additionalAttributes:nullptr - input:self.input + input:self.host withSelection:YES]; - self.input->blockEmitting = NO; + self.host.blockEmitting = NO; // add attributes on the paragraph [self add:NSMakeRange(paragraphRange.location, diff --git a/ios/styles/StrikethroughStyle.mm b/ios/styles/StrikethroughStyle.mm index ad829df3..5b588338 100644 --- a/ios/styles/StrikethroughStyle.mm +++ b/ios/styles/StrikethroughStyle.mm @@ -16,10 +16,10 @@ - (BOOL)isParagraph { } - (void)applyStyling:(NSRange)range { - [self.input->textView.textStorage addAttributes:@{ + [self.host.textView.textStorage addAttributes:@{ NSStrikethroughStyleAttributeName : @(NSUnderlineStyleSingle) } - range:range]; + range:range]; } @end diff --git a/ios/styles/UnderlineStyle.mm b/ios/styles/UnderlineStyle.mm index 2d30eec5..c09a0fae 100644 --- a/ios/styles/UnderlineStyle.mm +++ b/ios/styles/UnderlineStyle.mm @@ -16,9 +16,9 @@ - (BOOL)isParagraph { } - (void)applyStyling:(NSRange)range { - [self.input->textView.textStorage addAttribute:NSUnderlineStyleAttributeName - value:@(NSUnderlineStyleSingle) - range:range]; + [self.host.textView.textStorage addAttribute:NSUnderlineStyleAttributeName + value:@(NSUnderlineStyleSingle) + range:range]; } @end diff --git a/ios/styles/UnorderedListStyle.mm b/ios/styles/UnorderedListStyle.mm index 2e0a1a10..8044ab3d 100644 --- a/ios/styles/UnorderedListStyle.mm +++ b/ios/styles/UnorderedListStyle.mm @@ -24,10 +24,10 @@ - (BOOL)needsZWS { - (void)applyStyling:(NSRange)range { // lists are drawn manually // margin before bullet + gap between bullet and paragraph - CGFloat listHeadIndent = [self.input->config unorderedListMarginLeft] + - [self.input->config unorderedListGapWidth]; + CGFloat listHeadIndent = [self.host.config unorderedListMarginLeft] + + [self.host.config unorderedListGapWidth]; - [self.input->textView.textStorage + [self.host.textView.textStorage enumerateAttribute:NSParagraphStyleAttributeName inRange:range options:0 @@ -37,7 +37,7 @@ - (void)applyStyling:(NSRange)range { [(NSParagraphStyle *)value mutableCopy]; pStyle.headIndent = listHeadIndent; pStyle.firstLineHeadIndent = listHeadIndent; - [self.input->textView.textStorage + [self.host.textView.textStorage addAttribute:NSParagraphStyleAttributeName value:pStyle range:range]; @@ -47,28 +47,28 @@ - (void)applyStyling:(NSRange)range { - (BOOL)tryHandlingListShorcutInRange:(NSRange)range replacementText:(NSString *)text { NSRange paragraphRange = - [self.input->textView.textStorage.string paragraphRangeForRange:range]; + [self.host.textView.textStorage.string paragraphRangeForRange:range]; // space was added - check if we are both at the paragraph beginning + 1 // character (which we want to be a dash) if ([text isEqualToString:@" "] && range.location - 1 == paragraphRange.location) { - unichar charBefore = [self.input->textView.textStorage.string + unichar charBefore = [self.host.textView.textStorage.string characterAtIndex:range.location - 1]; if (charBefore == '-') { // we got a match - add a list if possible - if ([self.input handleStyleBlocksAndConflicts:[[self class] getType] - range:paragraphRange]) { + if ([self.host handleStyleBlocksAndConflicts:[[self class] getType] + range:paragraphRange]) { // don't emit during the replacing - self.input->blockEmitting = YES; + self.host.blockEmitting = YES; // remove the dash [TextInsertionUtils replaceText:@"" at:NSMakeRange(paragraphRange.location, 1) additionalAttributes:nullptr - input:self.input + input:self.host withSelection:YES]; - self.input->blockEmitting = NO; + self.host.blockEmitting = NO; // add attributes on the dashless paragraph [self add:NSMakeRange(paragraphRange.location, diff --git a/ios/utils/AttachmentLayoutUtils.h b/ios/utils/AttachmentLayoutUtils.h new file mode 100644 index 00000000..78a37dad --- /dev/null +++ b/ios/utils/AttachmentLayoutUtils.h @@ -0,0 +1,24 @@ +#pragma once +#import "EnrichedConfig.h" +#import "ImageAttachment.h" +#import + +@interface AttachmentLayoutUtils : NSObject + ++ (void)handleAttachmentUpdate:(MediaAttachment *)attachment + textView:(UITextView *)textView + onLayoutBlock:(dispatch_block_t)layoutBlock; + ++ (NSMutableDictionary *) + layoutAttachmentsInTextView:(UITextView *)textView + config:(EnrichedConfig *)config + existingViews: + (NSMutableDictionary *) + attachmentViews; + ++ (CGRect)frameForAttachment:(ImageAttachment *)attachment + atRange:(NSRange)range + textView:(UITextView *)textView + config:(EnrichedConfig *)config; + +@end diff --git a/ios/utils/AttachmentLayoutUtils.mm b/ios/utils/AttachmentLayoutUtils.mm new file mode 100644 index 00000000..2cd39052 --- /dev/null +++ b/ios/utils/AttachmentLayoutUtils.mm @@ -0,0 +1,143 @@ +#import "AttachmentLayoutUtils.h" + +@implementation AttachmentLayoutUtils + ++ (void)handleAttachmentUpdate:(MediaAttachment *)attachment + textView:(UITextView *)textView + onLayoutBlock:(dispatch_block_t)layoutBlock { + NSTextStorage *storage = textView.textStorage; + NSRange fullRange = NSMakeRange(0, storage.length); + + __block NSRange foundRange = NSMakeRange(NSNotFound, 0); + + [storage enumerateAttribute:NSAttachmentAttributeName + inRange:fullRange + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if (value == attachment) { + foundRange = range; + *stop = YES; + } + }]; + + if (foundRange.location == NSNotFound) { + return; + } + + [storage edited:NSTextStorageEditedAttributes + range:foundRange + changeInLength:0]; + + dispatch_async(dispatch_get_main_queue(), layoutBlock); +} + ++ (NSMutableDictionary *) + layoutAttachmentsInTextView:(UITextView *)textView + config:(EnrichedConfig *)config + existingViews: + (NSMutableDictionary *) + attachmentViews { + NSTextStorage *storage = textView.textStorage; + if (storage.length == 0) + return attachmentViews; + + NSMutableDictionary *activeAttachmentViews = + [NSMutableDictionary dictionary]; + + // Iterate over the entire text to find ImageAttachments + [storage enumerateAttribute:NSAttachmentAttributeName + inRange:NSMakeRange(0, storage.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if ([value isKindOfClass:[ImageAttachment class]]) { + ImageAttachment *attachment = (ImageAttachment *)value; + + CGRect rect = [self frameForAttachment:attachment + atRange:range + textView:textView + config:config]; + + // Get or Create the UIImageView for this specific + // attachment key + NSValue *key = + [NSValue valueWithNonretainedObject:attachment]; + UIImageView *imgView = attachmentViews[key]; + + if (!imgView) { + // It doesn't exist yet, create it + imgView = [[UIImageView alloc] initWithFrame:rect]; + imgView.contentMode = UIViewContentModeScaleAspectFit; + imgView.tintColor = [UIColor labelColor]; + + // Add it directly to the TextView + [textView addSubview:imgView]; + } + + // Update position (in case text moved/scrolled) + if (!CGRectEqualToRect(imgView.frame, rect)) { + imgView.frame = rect; + } + UIImage *targetImage = + attachment.storedAnimatedImage ?: attachment.image; + + // Only set if different to avoid resetting the animation + // loop + if (imgView.image != targetImage) { + imgView.image = targetImage; + } + + // Ensure it is visible on top + imgView.hidden = NO; + [textView bringSubviewToFront:imgView]; + + activeAttachmentViews[key] = imgView; + // Remove from the old map so we know it has been claimed + [attachmentViews removeObjectForKey:key]; + } + }]; + + // Everything remaining in attachmentViews is dead or off-screen + for (UIImageView *danglingView in attachmentViews.allValues) { + [danglingView removeFromSuperview]; + } + + return activeAttachmentViews; +} + ++ (CGRect)frameForAttachment:(ImageAttachment *)attachment + atRange:(NSRange)range + textView:(UITextView *)textView + config:(EnrichedConfig *)config { + NSLayoutManager *layoutManager = textView.layoutManager; + NSTextContainer *textContainer = textView.textContainer; + NSTextStorage *storage = textView.textStorage; + + NSRange glyphRange = [layoutManager glyphRangeForCharacterRange:range + actualCharacterRange:NULL]; + CGRect glyphRect = [layoutManager boundingRectForGlyphRange:glyphRange + inTextContainer:textContainer]; + + CGRect lineRect = + [layoutManager lineFragmentRectForGlyphAtIndex:glyphRange.location + effectiveRange:NULL]; + CGSize attachmentSize = attachment.bounds.size; + + UIFont *font = [storage attribute:NSFontAttributeName + atIndex:range.location + effectiveRange:NULL]; + if (!font) { + font = [config primaryFont]; + } + + // Calculate (Baseline Alignment) + CGFloat targetY = + CGRectGetMaxY(lineRect) + font.descender - attachmentSize.height; + CGRect rect = + CGRectMake(glyphRect.origin.x + textView.textContainerInset.left, + targetY + textView.textContainerInset.top, + attachmentSize.width, attachmentSize.height); + + return CGRectIntegral(rect); +} + +@end diff --git a/ios/utils/CheckboxHitTestUtils.mm b/ios/utils/CheckboxHitTestUtils.mm index 851d48b3..4055b8b2 100644 --- a/ios/utils/CheckboxHitTestUtils.mm +++ b/ios/utils/CheckboxHitTestUtils.mm @@ -1,6 +1,6 @@ #import "CheckboxHitTestUtils.h" +#import "EnrichedConfig.h" #import "EnrichedTextInputView.h" -#import "InputConfig.h" #import "StyleHeaders.h" static const CGFloat kCheckboxHitSlopLeft = 8.0; @@ -56,7 +56,7 @@ + (CGRect)checkboxRectForGlyphIndex:(NSUInteger)glyphIndex inInput:(EnrichedTextInputView *)input { UITextView *textView = input->textView; NSLayoutManager *layoutManager = textView.layoutManager; - InputConfig *config = input->config; + EnrichedConfig *config = input->config; if (!config) { return CGRectNull; diff --git a/ios/utils/EnrichedTextTouchHandler.h b/ios/utils/EnrichedTextTouchHandler.h new file mode 100644 index 00000000..8f59bf2f --- /dev/null +++ b/ios/utils/EnrichedTextTouchHandler.h @@ -0,0 +1,14 @@ +#import + +@class EnrichedTextView; + +@interface EnrichedTextTouchHandler : NSObject + +@property(nonatomic, weak) EnrichedTextView *view; + +- (instancetype)initWithView:(EnrichedTextView *)view; +- (void)handleTouchBeganAtPoint:(CGPoint)point; +- (void)handleTouchEndedAtPoint:(CGPoint)point; +- (void)handleTouchCancelled; + +@end diff --git a/ios/utils/EnrichedTextTouchHandler.mm b/ios/utils/EnrichedTextTouchHandler.mm new file mode 100644 index 00000000..15be60d2 --- /dev/null +++ b/ios/utils/EnrichedTextTouchHandler.mm @@ -0,0 +1,150 @@ +#import "EnrichedTextTouchHandler.h" +#import "EnrichedTextView.h" +#import "LinkData.h" +#import "MentionParams.h" +#import "MentionStyleProps.h" +#import "StyleBase.h" + +@implementation EnrichedTextTouchHandler { + NSRange _activeRange; + NSString *_activeAttrKey; + id _activeValue; +} + +- (instancetype)initWithView:(EnrichedTextView *)view { + if (self = [super init]) { + _view = view; + } + return self; +} + +- (void)handleTouchBeganAtPoint:(CGPoint)point { + NSUInteger charIndex = [self characterIndexAtPoint:point]; + NSRange range; + NSString *key; + id value = [self findClickableAt:charIndex range:&range key:&key]; + if (value) { + _activeRange = range; + _activeAttrKey = key; + _activeValue = value; + [self updateVisualsPressed:YES]; + } +} + +- (void)handleTouchEndedAtPoint:(CGPoint)point { + if (!_activeValue) { + return; + } + + NSUInteger charIndex = [self characterIndexAtPoint:point]; + if (NSLocationInRange(charIndex, _activeRange)) { + [self dispatchEvent]; + } + + [self updateVisualsPressed:NO]; + [self reset]; +} + +- (void)handleTouchCancelled { + [self updateVisualsPressed:NO]; + [self reset]; +} + +- (NSUInteger)characterIndexAtPoint:(CGPoint)point { + UITextView *tv = self.view.textView; + NSLayoutManager *lm = tv.layoutManager; + NSTextContainer *tc = tv.textContainer; + + CGPoint textOffset = + CGPointMake(tv.textContainerInset.left, tv.textContainerInset.top); + CGPoint locationInContainer = + CGPointMake(point.x - textOffset.x, point.y - textOffset.y); + + NSUInteger glyphIndex = [lm glyphIndexForPoint:locationInContainer + inTextContainer:tc]; + CGRect glyphRect = [lm boundingRectForGlyphRange:NSMakeRange(glyphIndex, 1) + inTextContainer:tc]; + + if (!CGRectContainsPoint(glyphRect, locationInContainer)) { + return NSNotFound; + } + return [lm characterIndexForGlyphAtIndex:glyphIndex]; +} + +- (id)findClickableAt:(NSUInteger)idx + range:(NSRangePointer)range + key:(NSString **)key { + if (idx == NSNotFound || idx >= self.view.textView.textStorage.length) + return nil; + + NSArray *keys = + @[ @"EnrichedManualLink", @"EnrichedAutomaticLink", @"EnrichedMention" ]; + for (NSString *k in keys) { + id val = [self.view.textView.textStorage attribute:k + atIndex:idx + effectiveRange:range]; + if (val) { + *key = k; + return val; + } + } + return nil; +} + +- (void)updateVisualsPressed:(BOOL)pressed { + if (pressed) { + UIColor *color = nil; + UIColor *bgColor = nil; + + if ([_activeAttrKey isEqualToString:@"EnrichedMention"]) { + MentionParams *m = (MentionParams *)_activeValue; + MentionStyleProps *mProps = + [self.view.config mentionStylePropsForIndicator:m.indicator]; + color = [mProps pressColor]; + bgColor = [mProps pressBackgroundColor]; + } else { + color = [self.view.config linkPressColor]; + } + + NSMutableDictionary *newAttrs = [[NSMutableDictionary alloc] init]; + if (color) { + newAttrs[NSForegroundColorAttributeName] = color; + newAttrs[NSUnderlineColorAttributeName] = color; + newAttrs[NSStrikethroughColorAttributeName] = color; + } + if (bgColor) { + newAttrs[NSBackgroundColorAttributeName] = bgColor; + } + + [self.view.textView.textStorage addAttributes:newAttrs range:_activeRange]; + } else { + // REVERT using the Style Engine in the View + for (StyleBase *style in self.view.stylesDict.allValues) { + NSString *styleKey = [style getKey]; + // If the style key matches exactly (Mentions) + // OR if both the style and the active key are Link-related + BOOL isMatch = [styleKey isEqualToString:_activeAttrKey]; + BOOL isLinkMatch = ([_activeAttrKey containsString:@"Link"] && + [styleKey containsString:@"Link"]); + + if (isMatch || isLinkMatch) { + [style applyStyling:_activeRange]; + } + } + } +} + +- (void)dispatchEvent { + if ([_activeAttrKey containsString:@"Link"]) { + [self.view emitOnLinkPressEvent:((LinkData *)_activeValue).url]; + } else if ([_activeAttrKey isEqualToString:@"EnrichedMention"]) { + [self.view emitOnMentionPressEvent:(MentionParams *)_activeValue]; + } +} + +- (void)reset { + _activeValue = nil; + _activeAttrKey = nil; + _activeRange = NSMakeRange(0, 0); +} +@end diff --git a/ios/utils/EnrichedTouchableTextView.h b/ios/utils/EnrichedTouchableTextView.h new file mode 100644 index 00000000..6b98def8 --- /dev/null +++ b/ios/utils/EnrichedTouchableTextView.h @@ -0,0 +1,14 @@ +#pragma once + +#import + +@class EnrichedTextTouchHandler; + +// Forwards single-finger touches to `EnrichedTextTouchHandler` before `super` +// so link/mention pressed styling is not delayed by `UITextView` gesture +// arbitration. +@interface EnrichedTouchableTextView : UITextView + +@property(nonatomic, weak) EnrichedTextTouchHandler *touchHandler; + +@end diff --git a/ios/utils/EnrichedTouchableTextView.mm b/ios/utils/EnrichedTouchableTextView.mm new file mode 100644 index 00000000..b1aab2cb --- /dev/null +++ b/ios/utils/EnrichedTouchableTextView.mm @@ -0,0 +1,30 @@ +#import "EnrichedTouchableTextView.h" +#import "EnrichedTextTouchHandler.h" + +@implementation EnrichedTouchableTextView + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + if (touches.count == 1) { + UITouch *touch = touches.anyObject; + CGPoint point = [touch locationInView:self]; + [self.touchHandler handleTouchBeganAtPoint:point]; + } + [super touchesBegan:touches withEvent:event]; +} + +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { + if (touches.count == 1) { + UITouch *touch = touches.anyObject; + CGPoint point = [touch locationInView:self]; + [self.touchHandler handleTouchEndedAtPoint:point]; + } + [super touchesEnded:touches withEvent:event]; +} + +- (void)touchesCancelled:(NSSet *)touches + withEvent:(UIEvent *)event { + [self.touchHandler handleTouchCancelled]; + [super touchesCancelled:touches withEvent:event]; +} + +@end diff --git a/ios/utils/OccurenceUtils.h b/ios/utils/OccurenceUtils.h index fd0143d5..a355399b 100644 --- a/ios/utils/OccurenceUtils.h +++ b/ios/utils/OccurenceUtils.h @@ -1,43 +1,43 @@ #pragma once -#import "EnrichedTextInputView.h" +#import "EnrichedViewHost.h" #import "StylePair.h" @interface OccurenceUtils : NSObject + (BOOL)detect:(NSAttributedStringKey _Nonnull)key - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host inRange:(NSRange)range withCondition:(BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, NSRange range))condition; + (BOOL)detect:(NSAttributedStringKey _Nonnull)key - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host atIndex:(NSUInteger)index checkPrevious:(BOOL)checkPrev withCondition:(BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, NSRange range))condition; + (BOOL)detectMultiple:(NSArray *_Nonnull)keys - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host inRange:(NSRange)range withCondition:(BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, NSRange range))condition; + (BOOL)any:(NSAttributedStringKey _Nonnull)key - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host inRange:(NSRange)range withCondition:(BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, NSRange range))condition; + (BOOL)anyMultiple:(NSArray *_Nonnull)keys - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host inRange:(NSRange)range withCondition:(BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, NSRange range))condition; + (NSArray *_Nullable)all:(NSAttributedStringKey _Nonnull)key - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host inRange:(NSRange)range withCondition:(BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, NSRange range))condition; + (NSArray *_Nullable) allMultiple:(NSArray *_Nonnull)keys - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host inRange:(NSRange)range withCondition:(BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, NSRange range))condition; diff --git a/ios/utils/OccurenceUtils.mm b/ios/utils/OccurenceUtils.mm index 872783c6..1cf092c2 100644 --- a/ios/utils/OccurenceUtils.mm +++ b/ios/utils/OccurenceUtils.mm @@ -4,12 +4,12 @@ @implementation OccurenceUtils + (BOOL)detect:(NSAttributedStringKey _Nonnull)key - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host inRange:(NSRange)range withCondition:(BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, NSRange range))condition { __block NSInteger totalLength = 0; - [input->textView.textStorage + [host.textView.textStorage enumerateAttribute:key inRange:range options:0 @@ -26,24 +26,31 @@ + (BOOL)detect:(NSAttributedStringKey _Nonnull)key // it means that first character of paragraph will be checked instead if the // detection is not in input's selected range and at the end of the input + (BOOL)detect:(NSAttributedStringKey _Nonnull)key - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host atIndex:(NSUInteger)index checkPrevious:(BOOL)checkPrev withCondition:(BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, NSRange range))condition { NSRange detectionRange = NSMakeRange(index, 0); id attrValue; - if (NSEqualRanges(input->textView.selectedRange, detectionRange)) { - attrValue = input->textView.typingAttributes[key]; - } else if (index == input->textView.textStorage.string.length) { + // Only trust typingAttributes when the textView is actually editable. + // Non-editable hosts (e.g. EnrichedTextView) keep selectedRange pinned at + // (0, 0), so without this gate every detection at index 0 would match the + // selection and read stale/default typingAttributes instead of the real + // attribute at that position in textStorage. + if (host.textView.isEditable && + NSEqualRanges(host.textView.selectedRange, detectionRange)) { + NSLog(@"[OccurenceUtils] isEditable srodek: %d", host.textView.isEditable); + attrValue = host.textView.typingAttributes[key]; + } else if (index == host.textView.textStorage.string.length) { if (checkPrev) { - NSRange paragraphRange = [input->textView.textStorage.string + NSRange paragraphRange = [host.textView.textStorage.string paragraphRangeForRange:detectionRange]; if (paragraphRange.location == detectionRange.location) { return NO; } else { return [self detect:key - withInput:input + withHost:host inRange:NSMakeRange(paragraphRange.location, 1) withCondition:condition]; } @@ -52,21 +59,21 @@ + (BOOL)detect:(NSAttributedStringKey _Nonnull)key } } else { NSRange attrRange = NSMakeRange(0, 0); - attrValue = [input->textView.textStorage attribute:key - atIndex:index - effectiveRange:&attrRange]; + attrValue = [host.textView.textStorage attribute:key + atIndex:index + effectiveRange:&attrRange]; } return condition(attrValue, detectionRange); } + (BOOL)detectMultiple:(NSArray *_Nonnull)keys - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host inRange:(NSRange)range withCondition:(BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, NSRange range))condition { __block NSInteger totalLength = 0; for (NSString *key in keys) { - [input->textView.textStorage + [host.textView.textStorage enumerateAttribute:key inRange:range options:0 @@ -81,12 +88,12 @@ + (BOOL)detectMultiple:(NSArray *_Nonnull)keys } + (BOOL)any:(NSAttributedStringKey _Nonnull)key - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host inRange:(NSRange)range withCondition:(BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, NSRange range))condition { __block BOOL found = NO; - [input->textView.textStorage + [host.textView.textStorage enumerateAttribute:key inRange:range options:0 @@ -101,13 +108,13 @@ + (BOOL)any:(NSAttributedStringKey _Nonnull)key } + (BOOL)anyMultiple:(NSArray *_Nonnull)keys - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host inRange:(NSRange)range withCondition:(BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, NSRange range))condition { __block BOOL found = NO; for (NSString *key in keys) { - [input->textView.textStorage + [host.textView.textStorage enumerateAttribute:key inRange:range options:0 @@ -126,7 +133,7 @@ + (BOOL)anyMultiple:(NSArray *_Nonnull)keys } + (NSArray *_Nullable)all:(NSAttributedStringKey _Nonnull)key - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host inRange:(NSRange)range withCondition: (BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, @@ -134,7 +141,7 @@ + (BOOL)anyMultiple:(NSArray *_Nonnull)keys condition { __block NSMutableArray *occurences = [[NSMutableArray alloc] init]; - [input->textView.textStorage + [host.textView.textStorage enumerateAttribute:key inRange:range options:0 @@ -152,14 +159,14 @@ + (BOOL)anyMultiple:(NSArray *_Nonnull)keys + (NSArray *_Nullable) allMultiple:(NSArray *_Nonnull)keys - withInput:(EnrichedTextInputView *_Nonnull)input + withHost:(id _Nonnull)host inRange:(NSRange)range withCondition:(BOOL(NS_NOESCAPE ^ _Nonnull)(id _Nullable value, NSRange range))condition { __block NSMutableArray *occurences = [[NSMutableArray alloc] init]; for (NSString *key in keys) { - [input->textView.textStorage + [host.textView.textStorage enumerateAttribute:key inRange:range options:0 diff --git a/ios/utils/ParagraphAttributesUtils.mm b/ios/utils/ParagraphAttributesUtils.mm index 0f4a4a19..95efd8f7 100644 --- a/ios/utils/ParagraphAttributesUtils.mm +++ b/ios/utils/ParagraphAttributesUtils.mm @@ -2,6 +2,7 @@ #import "EnrichedTextInputView.h" #import "RangeUtils.h" #import "StyleHeaders.h" +#import "StyleUtils.h" #import "TextInsertionUtils.h" @implementation ParagraphAttributesUtils @@ -159,12 +160,14 @@ + (BOOL)handleParagraphStylesMergeOnBackspace:(NSRange)range StyleType type = [[leftParagraphStyle class] getType]; - NSArray *conflictingStyles = [typedInput + NSArray *conflictingStyles = [StyleUtils getPresentStyleTypesFrom:typedInput->conflictingStyles[@(type)] - range:rightRange]; + range:rightRange + forHost:typedInput]; NSArray *blockingStyles = - [typedInput getPresentStyleTypesFrom:typedInput->blockingStyles[@(type)] - range:rightRange]; + [StyleUtils getPresentStyleTypesFrom:typedInput->blockingStyles[@(type)] + range:rightRange + forHost:typedInput]; NSArray *allToBeRemoved = [conflictingStyles arrayByAddingObjectsFromArray:blockingStyles]; diff --git a/ios/utils/StyleUtils.h b/ios/utils/StyleUtils.h new file mode 100644 index 00000000..d0850ddb --- /dev/null +++ b/ios/utils/StyleUtils.h @@ -0,0 +1,30 @@ +#import "EnrichedTextStyleHeaders.h" +#import "StyleHeaders.h" + +@interface StyleUtils : NSObject ++ (NSDictionary *> *)conflictMap; ++ (NSDictionary *> *)blockingMap; ++ (NSDictionary *)stylesDictForHost: + (id)host + isInput:(BOOL)isInput; + ++ (BOOL)handleStyleBlocksAndConflicts:(StyleType)type + range:(NSRange)range + forHost:(id)host; ++ (NSArray *)getPresentStyleTypesFrom:(NSArray *)types + range:(NSRange)range + forHost:(id)host; ++ (void)addStyleBlock:(StyleType)blocking + to:(StyleType)blocked + forHost:(id)host; ++ (void)removeStyleBlock:(StyleType)blocking + from:(StyleType)blocked + forHost:(id)host; + ++ (void)addStyleConflict:(StyleType)conflicting + to:(StyleType)conflicted + forHost:(id)host; ++ (void)removeStyleConflict:(StyleType)conflicting + from:(StyleType)conflicted + forHost:(id)host; +@end diff --git a/ios/utils/StyleUtils.mm b/ios/utils/StyleUtils.mm new file mode 100644 index 00000000..5372e8cd --- /dev/null +++ b/ios/utils/StyleUtils.mm @@ -0,0 +1,268 @@ +#import "StyleUtils.h" + +@implementation StyleUtils + ++ (NSDictionary *)conflictMap { + return @{ + @([BoldStyle getType]) : @[], + @([ItalicStyle getType]) : @[], + @([UnderlineStyle getType]) : @[], + @([StrikethroughStyle getType]) : @[], + @([InlineCodeStyle getType]) : + @[ @([LinkStyle getType]), @([MentionStyle getType]) ], + @([LinkStyle getType]) : @[ + @([InlineCodeStyle getType]), @([LinkStyle getType]), + @([MentionStyle getType]) + ], + @([MentionStyle getType]) : + @[ @([InlineCodeStyle getType]), @([LinkStyle getType]) ], + @([H1Style getType]) : @[ + @([H2Style getType]), @([H3Style getType]), @([H4Style getType]), + @([H5Style getType]), @([H6Style getType]), + @([UnorderedListStyle getType]), @([OrderedListStyle getType]), + @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]), + @([CheckboxListStyle getType]) + ], + @([H2Style getType]) : @[ + @([H1Style getType]), @([H3Style getType]), @([H4Style getType]), + @([H5Style getType]), @([H6Style getType]), + @([UnorderedListStyle getType]), @([OrderedListStyle getType]), + @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]), + @([CheckboxListStyle getType]) + ], + @([H3Style getType]) : @[ + @([H1Style getType]), @([H2Style getType]), @([H4Style getType]), + @([H5Style getType]), @([H6Style getType]), + @([UnorderedListStyle getType]), @([OrderedListStyle getType]), + @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]), + @([CheckboxListStyle getType]) + ], + @([H4Style getType]) : @[ + @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), + @([H5Style getType]), @([H6Style getType]), + @([UnorderedListStyle getType]), @([OrderedListStyle getType]), + @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]), + @([CheckboxListStyle getType]) + ], + @([H5Style getType]) : @[ + @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), + @([H4Style getType]), @([H6Style getType]), + @([UnorderedListStyle getType]), @([OrderedListStyle getType]), + @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]), + @([CheckboxListStyle getType]) + ], + @([H6Style getType]) : @[ + @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), + @([H4Style getType]), @([H5Style getType]), + @([UnorderedListStyle getType]), @([OrderedListStyle getType]), + @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]), + @([CheckboxListStyle getType]) + ], + @([UnorderedListStyle getType]) : @[ + @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), + @([H4Style getType]), @([H5Style getType]), @([H6Style getType]), + @([OrderedListStyle getType]), @([BlockQuoteStyle getType]), + @([CodeBlockStyle getType]), @([CheckboxListStyle getType]) + ], + @([OrderedListStyle getType]) : @[ + @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), + @([H4Style getType]), @([H5Style getType]), @([H6Style getType]), + @([UnorderedListStyle getType]), @([BlockQuoteStyle getType]), + @([CodeBlockStyle getType]), @([CheckboxListStyle getType]) + ], + @([CheckboxListStyle getType]) : @[ + @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), + @([H4Style getType]), @([H5Style getType]), @([H6Style getType]), + @([UnorderedListStyle getType]), @([OrderedListStyle getType]), + @([BlockQuoteStyle getType]), @([CodeBlockStyle getType]) + ], + @([BlockQuoteStyle getType]) : @[ + @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), + @([H4Style getType]), @([H5Style getType]), @([H6Style getType]), + @([UnorderedListStyle getType]), @([OrderedListStyle getType]), + @([CodeBlockStyle getType]), @([CheckboxListStyle getType]) + ], + @([CodeBlockStyle getType]) : @[ + @([H1Style getType]), @([H2Style getType]), @([H3Style getType]), + @([H4Style getType]), @([H5Style getType]), @([H6Style getType]), + @([BoldStyle getType]), @([UnderlineStyle getType]), + @([ItalicStyle getType]), @([StrikethroughStyle getType]), + @([UnorderedListStyle getType]), @([OrderedListStyle getType]), + @([BlockQuoteStyle getType]), @([InlineCodeStyle getType]), + @([MentionStyle getType]), @([LinkStyle getType]), + @([CheckboxListStyle getType]) + ], + @([ImageStyle getType]) : + @[ @([LinkStyle getType]), @([MentionStyle getType]) ] + }; +} + ++ (NSDictionary *)blockingMap { + return @{ + @([BoldStyle getType]) : @[ @([CodeBlockStyle getType]) ], + @([ItalicStyle getType]) : @[ @([CodeBlockStyle getType]) ], + @([UnderlineStyle getType]) : @[ @([CodeBlockStyle getType]) ], + @([StrikethroughStyle getType]) : @[ @([CodeBlockStyle getType]) ], + @([InlineCodeStyle getType]) : + @[ @([CodeBlockStyle getType]), @([ImageStyle getType]) ], + @([LinkStyle getType]) : + @[ @([CodeBlockStyle getType]), @([ImageStyle getType]) ], + @([MentionStyle getType]) : + @[ @([CodeBlockStyle getType]), @([ImageStyle getType]) ], + @([H1Style getType]) : @[], + @([H2Style getType]) : @[], + @([H3Style getType]) : @[], + @([H4Style getType]) : @[], + @([H5Style getType]) : @[], + @([H6Style getType]) : @[], + @([UnorderedListStyle getType]) : @[], + @([OrderedListStyle getType]) : @[], + @([CheckboxListStyle getType]) : @[], + @([BlockQuoteStyle getType]) : @[], + @([CodeBlockStyle getType]) : @[], + @([ImageStyle getType]) : @[ @([InlineCodeStyle getType]) ] + }; +} + ++ (NSDictionary *)stylesDictForHost:(id)host + isInput:(BOOL)isInput { + NSArray *baseClasses = @[ + [BoldStyle class], [ItalicStyle class], [UnderlineStyle class], + [StrikethroughStyle class], [InlineCodeStyle class], [LinkStyle class], + [MentionStyle class], [H1Style class], [H2Style class], [H3Style class], + [H4Style class], [H5Style class], [H6Style class], + [UnorderedListStyle class], [OrderedListStyle class], + [CheckboxListStyle class], [BlockQuoteStyle class], [CodeBlockStyle class], + [ImageStyle class] + ]; + + NSArray *viewerClasses = @[ + [EnrichedTextBoldStyle class], [EnrichedTextItalicStyle class], + [EnrichedTextUnderlineStyle class], [EnrichedTextStrikethroughStyle class], + [EnrichedTextInlineCodeStyle class], [EnrichedTextLinkStyle class], + [EnrichedTextMentionStyle class], [EnrichedTextH1Style class], + [EnrichedTextH2Style class], [EnrichedTextH3Style class], + [EnrichedTextH4Style class], [EnrichedTextH5Style class], + [EnrichedTextH6Style class], [EnrichedTextUnorderedListStyle class], + [EnrichedTextOrderedListStyle class], [EnrichedTextCheckboxListStyle class], + [EnrichedTextBlockQuoteStyle class], [EnrichedTextCodeBlockStyle class], + [EnrichedTextImageStyle class] + ]; + + NSMutableDictionary *dict = [NSMutableDictionary new]; + + for (NSUInteger i = 0; i < baseClasses.count; i++) { + // Choose the class based on the context + Class targetClass = isInput ? baseClasses[i] : viewerClasses[i]; + + // Instantiate and add to dictionary + // We use [baseClasses[i] getType] for the key to ensure the + // conflict maps (which use base types) always match. + StyleBase *instance = [[targetClass alloc] initWithHost:host]; + dict[@([baseClasses[i] getType])] = instance; + } + + return [dict copy]; +} + +// returns false when style shouldn't be applied and true when it can be ++ (BOOL)handleStyleBlocksAndConflicts:(StyleType)type + range:(NSRange)range + forHost:(id)host { + // handle blocking styles: if any is present we do not apply the toggled style + NSArray *blocking = + [self getPresentStyleTypesFrom:host.blockingStyles[@(type)] + range:range + forHost:host]; + if (blocking.count != 0) { + return NO; + } + + // handle conflicting styles: remove styles within the range + NSArray *conflicting = + [self getPresentStyleTypesFrom:host.conflictingStyles[@(type)] + range:range + forHost:host]; + if (conflicting.count != 0) { + for (NSNumber *type in conflicting) { + StyleBase *style = host.stylesDict[type]; + + if ([style isParagraph]) { + // for paragraph styles we can just call remove since it will pick up + // proper paragraph range + [style remove:range withDirtyRange:YES]; + } else { + // for inline styles we have to differentiate betweeen normal and typing + // attributes removal + range.length >= 1 ? [style remove:range withDirtyRange:YES] + : [style removeTyping]; + } + } + } + return YES; +} + ++ (NSArray *)getPresentStyleTypesFrom:(NSArray *)types + range:(NSRange)range + forHost:(id)host { + NSMutableArray *resultArray = + [[NSMutableArray alloc] init]; + for (NSNumber *type in types) { + StyleBase *style = host.stylesDict[type]; + + if (range.length >= 1) { + if ([style any:range]) { + [resultArray addObject:type]; + } + } else { + if ([style detect:range]) { + [resultArray addObject:type]; + } + } + } + return resultArray; +} + ++ (void)addStyleBlock:(StyleType)blocking + to:(StyleType)blocked + forHost:(id)host { + NSMutableArray *blocksArr = [host.blockingStyles[@(blocked)] mutableCopy]; + if (![blocksArr containsObject:@(blocking)]) { + [blocksArr addObject:@(blocking)]; + host.blockingStyles[@(blocked)] = blocksArr; + } +} + ++ (void)removeStyleBlock:(StyleType)blocking + from:(StyleType)blocked + forHost:(id)host { + NSMutableArray *blocksArr = [host.blockingStyles[@(blocked)] mutableCopy]; + if ([blocksArr containsObject:@(blocking)]) { + [blocksArr removeObject:@(blocking)]; + host.blockingStyles[@(blocked)] = blocksArr; + } +} + ++ (void)addStyleConflict:(StyleType)conflicting + to:(StyleType)conflicted + forHost:(id)host { + NSMutableArray *conflictsArr = + [host.conflictingStyles[@(conflicted)] mutableCopy]; + if (![conflictsArr containsObject:@(conflicting)]) { + [conflictsArr addObject:@(conflicting)]; + host.conflictingStyles[@(conflicted)] = conflictsArr; + } +} + ++ (void)removeStyleConflict:(StyleType)conflicting + from:(StyleType)conflicted + forHost:(id)host { + NSMutableArray *conflictsArr = + [host.conflictingStyles[@(conflicted)] mutableCopy]; + if ([conflictsArr containsObject:@(conflicting)]) { + [conflictsArr removeObject:@(conflicting)]; + host.conflictingStyles[@(conflicted)] = conflictsArr; + } +} + +@end diff --git a/ios/utils/TextInsertionUtils.h b/ios/utils/TextInsertionUtils.h index 1012f383..79e11bd8 100644 --- a/ios/utils/TextInsertionUtils.h +++ b/ios/utils/TextInsertionUtils.h @@ -1,3 +1,4 @@ +#import "EnrichedViewHost.h" #import @interface TextInsertionUtils : NSObject @@ -5,13 +6,12 @@ at:(NSInteger)index additionalAttributes: (NSDictionary *)additionalAttrs - input:(id)input + input:(id)host withSelection:(BOOL)withSelection; + (void)replaceText:(NSString *)text at:(NSRange)range additionalAttributes: (NSDictionary *)additionalAttrs - input:(id)input + input:(id)host withSelection:(BOOL)withSelection; -; @end diff --git a/ios/utils/TextInsertionUtils.mm b/ios/utils/TextInsertionUtils.mm index 20927ba0..70bf31ac 100644 --- a/ios/utils/TextInsertionUtils.mm +++ b/ios/utils/TextInsertionUtils.mm @@ -1,5 +1,4 @@ #import "TextInsertionUtils.h" -#import "EnrichedTextInputView.h" #import "UIView+React.h" @implementation TextInsertionUtils @@ -7,14 +6,13 @@ + (void)insertText:(NSString *)text at:(NSInteger)index additionalAttributes: (NSDictionary *)additionalAttrs - input:(id)input + input:(id)host withSelection:(BOOL)withSelection { - EnrichedTextInputView *typedInput = (EnrichedTextInputView *)input; - if (typedInput == nullptr) { + if (host == nullptr) { return; } - UITextView *textView = typedInput->textView; + UITextView *textView = host.textView; NSMutableDictionary *copiedAttrs = [textView.typingAttributes mutableCopy]; @@ -38,15 +36,13 @@ + (void)replaceText:(NSString *)text at:(NSRange)range additionalAttributes: (NSDictionary *)additionalAttrs - input:(id)input + input:(id)host withSelection:(BOOL)withSelection { - EnrichedTextInputView *typedInput = (EnrichedTextInputView *)input; - if (typedInput == nullptr) { + if (host == nullptr) { return; } - UITextView *textView = typedInput->textView; - + UITextView *textView = host.textView; [textView.textStorage replaceCharactersInRange:range withString:text]; if (additionalAttrs != nullptr) { [textView.textStorage diff --git a/ios/utils/ZeroWidthSpaceUtils.h b/ios/utils/ZeroWidthSpaceUtils.h index 7678749d..dc8bca25 100644 --- a/ios/utils/ZeroWidthSpaceUtils.h +++ b/ios/utils/ZeroWidthSpaceUtils.h @@ -1,9 +1,12 @@ +#import "EnrichedViewHost.h" #import #pragma once @interface ZeroWidthSpaceUtils : NSObject -+ (void)handleZeroWidthSpacesInInput:(id)input; ++ (void)handleZeroWidthSpacesInInput:(id)host; ++ (void)addSpacesIfNeededinInput:(id)host + inRange:(NSRange)range; + (BOOL)handleBackspaceInRange:(NSRange)range replacementText:(NSString *)text - input:(id)input; + input:(id)host; @end diff --git a/ios/utils/ZeroWidthSpaceUtils.mm b/ios/utils/ZeroWidthSpaceUtils.mm index bebb21e9..ed9025ff 100644 --- a/ios/utils/ZeroWidthSpaceUtils.mm +++ b/ios/utils/ZeroWidthSpaceUtils.mm @@ -5,26 +5,28 @@ #import "UIView+React.h" @implementation ZeroWidthSpaceUtils -+ (void)handleZeroWidthSpacesInInput:(id)input { - EnrichedTextInputView *typedInput = (EnrichedTextInputView *)input; - if (typedInput == nullptr) { ++ (void)handleZeroWidthSpacesInInput:(id)host { + if (host == nullptr) { return; } - [self removeSpacesIfNeededinInput:typedInput]; - [self addSpacesIfNeededinInput:typedInput]; + [self removeSpacesIfNeededinInput:host]; + [self + addSpacesIfNeededinInput:host + inRange:NSMakeRange( + 0, host.textView.textStorage.string.length)]; } -+ (void)removeSpacesIfNeededinInput:(EnrichedTextInputView *)input { ++ (void)removeSpacesIfNeededinInput:(id)host { NSMutableArray *indexesToBeRemoved = [[NSMutableArray alloc] init]; - NSRange preRemoveSelection = input->textView.selectedRange; + NSRange preRemoveSelection = host.textView.selectedRange; - for (int i = 0; i < input->textView.textStorage.string.length; i++) { - unichar character = [input->textView.textStorage.string characterAtIndex:i]; + for (int i = 0; i < host.textView.textStorage.string.length; i++) { + unichar character = [host.textView.textStorage.string characterAtIndex:i]; if (character == 0x200B) { NSRange characterRange = NSMakeRange(i, 1); - NSRange paragraphRange = [input->textView.textStorage.string + NSRange paragraphRange = [host.textView.textStorage.string paragraphRangeForRange:characterRange]; // having paragraph longer than 1 character means someone most likely // added something and we probably can remove the space @@ -33,7 +35,7 @@ + (void)removeSpacesIfNeededinInput:(EnrichedTextInputView *)input { // here, we still need zero width space to keep the empty list items if (paragraphRange.length == 2 && paragraphRange.location == i && [[NSCharacterSet newlineCharacterSet] - characterIsMember:[input->textView.textStorage.string + characterIsMember:[host.textView.textStorage.string characterAtIndex:i + 1]]) { removeSpace = NO; } @@ -44,7 +46,7 @@ + (void)removeSpacesIfNeededinInput:(EnrichedTextInputView *)input { } // zero width spaces with no needsZWS style on them get removed - if (![self anyZWSStylePresentInRange:characterRange input:input]) { + if (![self anyZWSStylePresentInRange:characterRange input:host]) { [indexesToBeRemoved addObject:@(characterRange.location)]; } } @@ -59,7 +61,7 @@ + (void)removeSpacesIfNeededinInput:(EnrichedTextInputView *)input { [TextInsertionUtils replaceText:@"" at:replaceRange additionalAttributes:nullptr - input:input + input:host withSelection:NO]; offset -= 1; if ([index integerValue] < preRemoveSelection.location) { @@ -72,8 +74,8 @@ + (void)removeSpacesIfNeededinInput:(EnrichedTextInputView *)input { } // fix the selection if needed - if ([input->textView isFirstResponder]) { - input->textView.selectedRange = + if ([host.textView isFirstResponder]) { + host.textView.selectedRange = NSMakeRange(preRemoveSelection.location + postRemoveLocationOffset, preRemoveSelection.length + postRemoveLengthOffset); } @@ -83,13 +85,13 @@ + (void)removeSpacesIfNeededinInput:(EnrichedTextInputView *)input { // dictionary so that ZWS characters carry the same meta-attributes that are // currently active in the typing attributes. Only within the currently selected // range! -+ (NSDictionary *)inlineMetaAttributesForInput:(EnrichedTextInputView *)input { ++ (NSDictionary *)inlineMetaAttributesForInput:(id)host { NSMutableDictionary *metaAttrs = [NSMutableDictionary new]; - for (NSNumber *type in input->stylesDict) { - StyleBase *style = input->stylesDict[type]; + for (NSNumber *type in host.stylesDict) { + StyleBase *style = host.stylesDict[type]; if (![style isParagraph]) { AttributeEntry *entry = - [style getEntryIfPresent:input->textView.selectedRange]; + [style getEntryIfPresent:host.textView.selectedRange]; if (entry) { metaAttrs[entry.key] = entry.value; } @@ -98,20 +100,27 @@ + (NSDictionary *)inlineMetaAttributesForInput:(EnrichedTextInputView *)input { return metaAttrs.count > 0 ? metaAttrs : nullptr; } -+ (void)addSpacesIfNeededinInput:(EnrichedTextInputView *)input { ++ (void)addSpacesIfNeededinInput:(id)host + inRange:(NSRange)range { NSMutableArray *indexesToBeInserted = [[NSMutableArray alloc] init]; - NSRange preAddSelection = input->textView.selectedRange; + NSRange preAddSelection = host.textView.selectedRange; - for (NSUInteger i = 0; i < input->textView.textStorage.string.length; i++) { - unichar character = [input->textView.textStorage.string characterAtIndex:i]; + // Expand to paragraph boundaries so callers can pass any style range + // without worrying about missing the terminating newline of an empty + // paragraph that starts before range.location. + NSRange scanRange = + [host.textView.textStorage.string paragraphRangeForRange:range]; + + for (NSUInteger i = scanRange.location; i < NSMaxRange(scanRange); i++) { + unichar character = [host.textView.textStorage.string characterAtIndex:i]; if ([[NSCharacterSet newlineCharacterSet] characterIsMember:character]) { NSRange characterRange = NSMakeRange(i, 1); - NSRange paragraphRange = [input->textView.textStorage.string + NSRange paragraphRange = [host.textView.textStorage.string paragraphRangeForRange:characterRange]; if (paragraphRange.length == 1) { - if ([self anyZWSStylePresentInRange:characterRange input:input]) { + if ([self anyZWSStylePresentInRange:characterRange input:host]) { // we have an empty list or quote item with no space: add it! [indexesToBeInserted addObject:@(paragraphRange.location)]; } @@ -119,7 +128,7 @@ + (void)addSpacesIfNeededinInput:(EnrichedTextInputView *)input { } } - NSDictionary *metaAttrs = [self inlineMetaAttributesForInput:input]; + NSDictionary *metaAttrs = [self inlineMetaAttributesForInput:host]; // do the replacing NSInteger offset = 0; @@ -130,7 +139,7 @@ + (void)addSpacesIfNeededinInput:(EnrichedTextInputView *)input { [TextInsertionUtils replaceText:@"\u200B\n" at:replaceRange additionalAttributes:metaAttrs - input:input + input:host withSelection:NO]; offset += 1; if ([index integerValue] < preAddSelection.location) { @@ -142,22 +151,25 @@ + (void)addSpacesIfNeededinInput:(EnrichedTextInputView *)input { } } - // additional check for last index of the input - NSRange lastRange = NSMakeRange(input->textView.textStorage.string.length, 0); - NSRange lastParagraphRange = - [input->textView.textStorage.string paragraphRangeForRange:lastRange]; - if (lastParagraphRange.length == 0 && - [self anyZWSStylePresentInRange:lastRange input:input]) { - [TextInsertionUtils insertText:@"\u200B" - at:lastRange.location - additionalAttributes:metaAttrs - input:input - withSelection:NO]; + // additional check for last index of the input - only when the caller's + // range actually reaches the end of the input + if (NSMaxRange(scanRange) == host.textView.textStorage.string.length) { + NSRange lastRange = NSMakeRange(host.textView.textStorage.string.length, 0); + NSRange lastParagraphRange = + [host.textView.textStorage.string paragraphRangeForRange:lastRange]; + if (lastParagraphRange.length == 0 && + [self anyZWSStylePresentInRange:lastRange input:host]) { + [TextInsertionUtils insertText:@"\u200B" + at:lastRange.location + additionalAttributes:metaAttrs + input:host + withSelection:NO]; + } } // fix the selection if needed - if ([input->textView isFirstResponder]) { - input->textView.selectedRange = + if ([host.textView isFirstResponder]) { + host.textView.selectedRange = NSMakeRange(preAddSelection.location + postAddLocationOffset, preAddSelection.length + postAddLengthOffset); } @@ -165,12 +177,11 @@ + (void)addSpacesIfNeededinInput:(EnrichedTextInputView *)input { + (BOOL)handleBackspaceInRange:(NSRange)range replacementText:(NSString *)text - input:(id)input { + input:(id)host { if (![text isEqualToString:@""]) { return NO; } - EnrichedTextInputView *typedInput = (EnrichedTextInputView *)input; - if (typedInput == nullptr) { + if (host == nullptr) { return NO; } @@ -178,9 +189,9 @@ + (BOOL)handleBackspaceInRange:(NSRange)range // Nothing to delete, but if the first paragraph has a needsZWS style, // remove it. if (range.length == 0 && range.location == 0) { - NSRange firstParagraphRange = [typedInput->textView.textStorage.string + NSRange firstParagraphRange = [host.textView.textStorage.string paragraphRangeForRange:NSMakeRange(0, 0)]; - if ([self removeZWSStyleInRange:firstParagraphRange input:typedInput]) { + if ([self removeZWSStyleInRange:firstParagraphRange input:host]) { return YES; } return NO; @@ -191,19 +202,19 @@ + (BOOL)handleBackspaceInRange:(NSRange)range } unichar character = - [typedInput->textView.textStorage.string characterAtIndex:range.location]; + [host.textView.textStorage.string characterAtIndex:range.location]; // zero-width space got backspaced if (character == 0x200B) { // in such case: remove the whole line without the endline if there is one NSRange paragraphRange = - [typedInput->textView.textStorage.string paragraphRangeForRange:range]; + [host.textView.textStorage.string paragraphRangeForRange:range]; NSRange removalRange = paragraphRange; // if whole paragraph gets removed then 0 length for style removal NSRange styleRemovalRange = NSMakeRange(paragraphRange.location, 0); if ([[NSCharacterSet newlineCharacterSet] - characterIsMember:[typedInput->textView.textStorage.string + characterIsMember:[host.textView.textStorage.string characterAtIndex:NSMaxRange(paragraphRange) - 1]]) { // if endline is there, don't remove it @@ -217,11 +228,11 @@ + (BOOL)handleBackspaceInRange:(NSRange)range [TextInsertionUtils replaceText:@"" at:removalRange additionalAttributes:nullptr - input:typedInput + input:host withSelection:YES]; // and then remove associated styling - [self removeZWSStyleInRange:styleRemovalRange input:typedInput]; + [self removeZWSStyleInRange:styleRemovalRange input:host]; return YES; } @@ -232,10 +243,10 @@ + (BOOL)handleBackspaceInRange:(NSRange)range // style from the current paragraph. if ([[NSCharacterSet newlineCharacterSet] characterIsMember:character]) { NSUInteger nextParaStart = NSMaxRange(range); - if (nextParaStart < typedInput->textView.textStorage.string.length) { - NSRange nextParagraphRange = [typedInput->textView.textStorage.string + if (nextParaStart < host.textView.textStorage.string.length) { + NSRange nextParagraphRange = [host.textView.textStorage.string paragraphRangeForRange:NSMakeRange(nextParaStart, 0)]; - if ([self removeZWSStyleInRange:nextParagraphRange input:typedInput]) { + if ([self removeZWSStyleInRange:nextParagraphRange input:host]) { return YES; } } @@ -245,9 +256,9 @@ + (BOOL)handleBackspaceInRange:(NSRange)range } + (BOOL)anyZWSStylePresentInRange:(NSRange)range - input:(EnrichedTextInputView *)input { - for (NSNumber *type in input->stylesDict) { - StyleBase *style = input->stylesDict[type]; + input:(id)host { + for (NSNumber *type in host.stylesDict) { + StyleBase *style = host.stylesDict[type]; if ([style needsZWS] && [style detect:range]) { return YES; } @@ -255,10 +266,9 @@ + (BOOL)anyZWSStylePresentInRange:(NSRange)range return NO; } -+ (BOOL)removeZWSStyleInRange:(NSRange)range - input:(EnrichedTextInputView *)input { - for (NSNumber *type in input->stylesDict) { - StyleBase *style = input->stylesDict[type]; ++ (BOOL)removeZWSStyleInRange:(NSRange)range input:(id)host { + for (NSNumber *type in host.stylesDict) { + StyleBase *style = host.stylesDict[type]; if ([style needsZWS] && [style detect:range]) { [style remove:range withDirtyRange:YES]; return YES; diff --git a/package.json b/package.json index c7637eb4..f8b7a8fd 100644 --- a/package.json +++ b/package.json @@ -218,6 +218,7 @@ }, "ios": { "componentProvider": { + "EnrichedTextView": "EnrichedTextView", "EnrichedTextInputView": "EnrichedTextInputView" } },