diff --git a/.maestro/enrichedInput/flows/links_visual.yaml b/.maestro/enrichedInput/flows/links_visual.yaml new file mode 100644 index 000000000..bf3ad4c47 --- /dev/null +++ b/.maestro/enrichedInput/flows/links_visual.yaml @@ -0,0 +1,73 @@ +appId: swmansion.enriched.example +--- +# Verifies typing a URL directly into the editor +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: "editor-input" + +# autolinks break on text change or paste +- inputText: 'swmansion.com' + +- doubleTapOn: + id: 'editor-input' + point: '20%, 50%' +- tapOn: + text: 'Copy' + +- tapOn: + id: 'editor-input' + point: '70%, 50%' +- pressKey: Enter + +- longPressOn: + id: 'editor-input' + point: '50%, 70%' +- tapOn: + text: 'Paste' +- inputText: 'm' + +- pressKey: Enter +- inputText: 'swm' +- longPressOn: + id: 'editor-input' + point: '50%, 75%' +- tapOn: + text: 'Paste' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'links_visual_auto' + +# manual link where href == textValue but does not match the linkRegex is considered as a manual link +- runFlow: + file: '../subflows/set_editor_value.yaml' + env: + VALUE: '

example.com

' + +- doubleTapOn: + id: 'editor-input' + point: '20%, 50%' +- tapOn: + text: 'Copy' + +- tapOn: + id: 'editor-input' + point: '70%, 50%' +- pressKey: Enter + +- longPressOn: + id: 'editor-input' + point: '50%, 70%' +- tapOn: + text: 'Paste' +- pressKey: Backspace + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'links_visual_manual' diff --git a/.maestro/enrichedInput/screenshots/android/links_visual_auto.png b/.maestro/enrichedInput/screenshots/android/links_visual_auto.png new file mode 100644 index 000000000..369f3a2ea Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/links_visual_auto.png differ diff --git a/.maestro/enrichedInput/screenshots/android/links_visual_manual.png b/.maestro/enrichedInput/screenshots/android/links_visual_manual.png new file mode 100644 index 000000000..cc265a9db Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/links_visual_manual.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/links_visual_auto.png b/.maestro/enrichedInput/screenshots/ios/links_visual_auto.png new file mode 100644 index 000000000..2efbdbf07 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/links_visual_auto.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/links_visual_manual.png b/.maestro/enrichedInput/screenshots/ios/links_visual_manual.png new file mode 100644 index 000000000..bb0bd58f7 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/links_visual_manual.png differ diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java index 02d52f706..cb1060726 100644 --- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java @@ -66,6 +66,11 @@ private static class HtmlParser { } public static Spanned fromHtml(String source, T style, EnrichedSpanFactory spanFactory) { + return fromHtml(source, style, spanFactory, null); + } + + public static Spanned fromHtml( + String source, T style, EnrichedSpanFactory spanFactory, Pattern linkRegex) { Parser parser = new Parser(); try { parser.setProperty(Parser.schemaProperty, HtmlParser.schema); @@ -74,7 +79,7 @@ public static Spanned fromHtml(String source, T style, EnrichedSpanFactory implements ContentHandler { private final String mSource; private final XMLReader mReader; private final SpannableStringBuilder mSpannableStringBuilder; + private final Pattern mLinkRegex; private static Integer currentOrderedListItemIndex = 0; private static Boolean isInOrderedList = false; private static Boolean isInCheckboxList = false; @@ -432,12 +438,17 @@ private static void pushAlignmentMark(Editable text, Attributes attributes) { } public HtmlToSpannedConverter( - String source, T style, Parser parser, EnrichedSpanFactory spanFactory) { + String source, + T style, + Parser parser, + EnrichedSpanFactory spanFactory, + Pattern linkRegex) { mStyle = style; mSource = source; mSpannableStringBuilder = new SpannableStringBuilder(); mReader = parser; mSpanFactory = spanFactory; + mLinkRegex = linkRegex; } public Spanned convert() { @@ -602,7 +613,7 @@ private void handleEndTag(String tag) { } else if (tag.equalsIgnoreCase("codeblock")) { endCodeBlock(mSpannableStringBuilder, mStyle, mSpanFactory); } else if (tag.equalsIgnoreCase("a")) { - endA(mSpannableStringBuilder, mStyle, mSpanFactory); + endA(mSpannableStringBuilder, mStyle, mSpanFactory, mLinkRegex); } else if (tag.equalsIgnoreCase("u")) { end(mSpannableStringBuilder, Underline.class, mSpanFactory.createUnderlineSpan(mStyle)); } else if (tag.equalsIgnoreCase("s")) { @@ -862,12 +873,18 @@ private static void startA(Editable text, Attributes attributes) { start(text, new Href(href)); } - private static void endA(Editable text, T style, EnrichedSpanFactory spanFactory) { + private static boolean urlMatchesLinkRegex(String url, Pattern linkRegex) { + if (linkRegex == null) return false; + return linkRegex.matcher(url).matches(); + } + + private static void endA( + Editable text, T style, EnrichedSpanFactory spanFactory, Pattern linkRegex) { Href h = getLast(text, Href.class); - if (h != null) { - if (h.mHref != null) { - setSpanFromMark(text, h, spanFactory.createLinkSpan(h.mHref, style)); - } + if (h != null && h.mHref != null) { + String linkText = text.subSequence(text.getSpanStart(h), text.length()).toString(); + boolean isManual = !linkText.equals(h.mHref) || !urlMatchesLinkRegex(h.mHref, linkRegex); + setSpanFromMark(text, h, spanFactory.createLinkSpan(h.mHref, style, isManual)); } } diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt index f61ef8d60..38fb167b1 100644 --- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt @@ -37,6 +37,7 @@ interface EnrichedSpanFactory { fun createLinkSpan( url: String, style: T, + isManual: Boolean, ): EnrichedLinkSpan fun createMentionSpan( diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt index c2e1db3da..f61ac812b 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt @@ -38,6 +38,7 @@ class EnrichedTextSpanFactory : EnrichedSpanFactory { override fun createLinkSpan( url: String, style: EnrichedTextStyle, + isManual: Boolean, ) = EnrichedTextLinkSpan(url, style) override fun createMentionSpan( diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt index d18e16c00..fadeb6e1b 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt @@ -40,7 +40,8 @@ class EnrichedTextInputSpannableFactory : EnrichedSpanFactory { override fun createLinkSpan( url: String, style: HtmlStyle, - ) = EnrichedInputLinkSpan(url, style, true) + isManual: Boolean, + ) = EnrichedInputLinkSpan(url, style, isManual) override fun createMentionSpan( text: String, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index cdcad4e2f..5be5ee69b 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -9,6 +9,7 @@ import android.graphics.Color import android.graphics.Rect import android.graphics.text.LineBreaker import android.os.Build +import android.text.Editable import android.text.InputType import android.text.Spannable import android.text.SpannableString @@ -398,8 +399,9 @@ class EnrichedTextInputView : val pasteEnd = (start + insertedLength).coerceIn(0, finalText.length) setSelection(pasteEnd) - // Detect links in the newly pasted range - parametrizedStyles?.detectLinksInRange(finalText, start.coerceAtMost(pasteEnd), pasteEnd) + // Update links and mentions in the newly pasted range + val editable = text as? Editable ?: return + parametrizedStyles?.afterTextChanged(editable, start.coerceAtMost(pasteEnd), pasteEnd) } fun requestFocusProgrammatically() { @@ -413,7 +415,7 @@ class EnrichedTextInputView : val normalized = GumboNormalizer.normalizeHtml(text.toString()) ?: return text return try { - val parsed = EnrichedParser.fromHtml(normalized, htmlStyle, spannableFactory) + val parsed = EnrichedParser.fromHtml(normalized, htmlStyle, spannableFactory, linkRegex) parsed.trimEnd('\n') } catch (e: Exception) { Log.e(TAG, "Error parsing normalized HTML: ${e.message}") @@ -426,7 +428,7 @@ class EnrichedTextInputView : if (isInternalHtml) { try { - val parsed = EnrichedParser.fromHtml(text.toString(), htmlStyle, spannableFactory) + val parsed = EnrichedParser.fromHtml(text.toString(), htmlStyle, spannableFactory, linkRegex) return parsed.trimEnd('\n') } catch (e: Exception) { Log.e(TAG, "Error parsing HTML: ${e.message}") diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 21ff1b721..7fa3f322f 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -719,6 +719,15 @@ - (void)updateProps:(Props::Shared const &)props textShortcuts = shortcuts; } + // linkRegex + LinkRegexConfig *oldRegexConfig = + [[LinkRegexConfig alloc] initWithLinkRegexProp:oldViewProps.linkRegex]; + LinkRegexConfig *newRegexConfig = + [[LinkRegexConfig alloc] initWithLinkRegexProp:newViewProps.linkRegex]; + if (![newRegexConfig isEqualToConfig:oldRegexConfig]) { + [config setLinkRegexConfig:newRegexConfig]; + } + // default value - must be set before placeholder to make sure it correctly // shows on first mount if (newViewProps.defaultValue != oldViewProps.defaultValue) { @@ -775,15 +784,6 @@ - (void)updateProps:(Props::Shared const &)props [config setMentionIndicators:newIndicators]; } - // linkRegex - LinkRegexConfig *oldRegexConfig = - [[LinkRegexConfig alloc] initWithLinkRegexProp:oldViewProps.linkRegex]; - LinkRegexConfig *newRegexConfig = - [[LinkRegexConfig alloc] initWithLinkRegexProp:newViewProps.linkRegex]; - if (![newRegexConfig isEqualToConfig:oldRegexConfig]) { - [config setLinkRegexConfig:newRegexConfig]; - } - // selection color sets both selection and cursor on iOS (just as in RN) if (newViewProps.selectionColor != oldViewProps.selectionColor) { if (isColorMeaningful(newViewProps.selectionColor)) { diff --git a/ios/htmlParser/HtmlParser.h b/ios/htmlParser/HtmlParser.h index c079ccec3..ee23cb863 100644 --- a/ios/htmlParser/HtmlParser.h +++ b/ios/htmlParser/HtmlParser.h @@ -2,10 +2,13 @@ #import "EnrichedViewHost.h" #import +@class EnrichedConfig; + @interface HtmlParser : NSObject + (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html useHtmlNormalizer:(BOOL)useHtmlNormalizer; -+ (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml; ++ (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml + config:(EnrichedConfig *_Nullable)config; + (NSString *_Nonnull)parseToHtmlFromRange:(NSRange)range host:(id)host; @end diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm index a6e84dd6e..543c835cb 100644 --- a/ios/htmlParser/HtmlParser.mm +++ b/ios/htmlParser/HtmlParser.mm @@ -1,6 +1,7 @@ #import "HtmlParser.h" #import "AlignmentEntry.h" #import "AlignmentUtils.h" +#import "EnrichedConfig.h" #import "ImageData.h" #import "LinkData.h" #import "MentionParams.h" @@ -414,7 +415,9 @@ + (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html return fixedHtml; } -+ (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml { ++ (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml + config: + (EnrichedConfig *_Nullable)config { NSMutableString *plainText = [[NSMutableString alloc] initWithString:@""]; NSMutableDictionary *ongoingTags = [[NSMutableDictionary alloc] init]; NSMutableArray *initiallyProcessedTags = [[NSMutableArray alloc] init]; @@ -747,7 +750,9 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml { LinkData *linkData = [[LinkData alloc] init]; linkData.url = url; linkData.text = text; - linkData.isManual = ![text isEqualToString:url]; + linkData.isManual = !([text isEqualToString:url] && + [LinkStyle matchesLinkRegexWithConfig:url + config:config]); stylePair.styleValue = linkData; } else if ([tagName isEqualToString:@"mention"]) { diff --git a/ios/inputHtmlParser/InputHtmlParser.mm b/ios/inputHtmlParser/InputHtmlParser.mm index bc9e5875b..05c331b75 100644 --- a/ios/inputHtmlParser/InputHtmlParser.mm +++ b/ios/inputHtmlParser/InputHtmlParser.mm @@ -24,7 +24,8 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { _input->textView.typingAttributes = _input->defaultTypingAttributes; @try { - NSArray *processingResult = [HtmlParser getTextAndStylesFromHtml:html]; + NSArray *processingResult = + [HtmlParser getTextAndStylesFromHtml:html config:_input.config]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; NSArray *alignments = (NSArray *)processingResult[2]; @@ -50,7 +51,8 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { @try { - NSArray *processingResult = [HtmlParser getTextAndStylesFromHtml:html]; + NSArray *processingResult = + [HtmlParser getTextAndStylesFromHtml:html config:_input.config]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; NSArray *alignments = (NSArray *)processingResult[2]; @@ -81,7 +83,8 @@ - (void)replaceFromHtml:(NSString *_Nonnull)html range:(NSRange)range { - (void)insertFromHtml:(NSString *_Nonnull)html location:(NSInteger)location { @try { - NSArray *processingResult = [HtmlParser getTextAndStylesFromHtml:html]; + NSArray *processingResult = + [HtmlParser getTextAndStylesFromHtml:html config:_input.config]; NSString *plainText = (NSString *)processingResult[0]; NSArray *stylesInfo = (NSArray *)processingResult[1]; NSArray *alignments = (NSArray *)processingResult[2]; diff --git a/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h index 1a16d3819..aca5d4a3c 100644 --- a/ios/interfaces/StyleHeaders.h +++ b/ios/interfaces/StyleHeaders.h @@ -4,6 +4,8 @@ #import "MentionParams.h" #import "StyleBase.h" +@class EnrichedConfig; + @interface BoldStyle : StyleBase @end @@ -28,6 +30,8 @@ - (void)handleAutomaticLinks:(NSString *)word inRange:(NSRange)wordRange; - (void)handleManualLinks:(NSString *)word inRange:(NSRange)wordRange; - (void)applyLinkMetaWithData:(LinkData *)linkData range:(NSRange)range; ++ (BOOL)matchesLinkRegexWithConfig:(NSString *)url + config:(EnrichedConfig *)config; @end @interface MentionStyle : StyleBase diff --git a/ios/styles/LinkStyle.mm b/ios/styles/LinkStyle.mm index ef600c6c4..1eae1a1d1 100644 --- a/ios/styles/LinkStyle.mm +++ b/ios/styles/LinkStyle.mm @@ -326,28 +326,10 @@ - (void)handleAutomaticLinks:(NSString *)word inRange:(NSRange)wordRange { } // all conditions are met; try matching the word to a proper regex - - NSString *regexPassedUrl = nullptr; - NSRange matchingRange = NSMakeRange(0, word.length); - - if (linkRegexConfig.isDefault) { - // use default regex - regexPassedUrl = [self tryMatchingDefaultLinkRegex:word - matchRange:matchingRange]; - } else { - // use user defined regex if it exists - NSRegularExpression *userRegex = [self.host.config parsedLinkRegex]; - - if (userRegex == nullptr) { - // fallback to default regex - regexPassedUrl = [self tryMatchingDefaultLinkRegex:word - matchRange:matchingRange]; - } else if ([userRegex numberOfMatchesInString:word - options:0 - range:matchingRange]) { - regexPassedUrl = word; - } - } + NSString *regexPassedUrl = + [LinkStyle matchesLinkRegexWithConfig:word config:self.host.config] + ? word + : nullptr; if (regexPassedUrl != nullptr) { // add style only if needed @@ -378,21 +360,25 @@ - (void)handleAutomaticLinks:(NSString *)word inRange:(NSRange)wordRange { } } -- (NSString *)tryMatchingDefaultLinkRegex:(NSString *)word - matchRange:(NSRange)range { - if ([[LinkStyle fullRegex] numberOfMatchesInString:word ++ (BOOL)matchesLinkRegexWithConfig:(NSString *)url + config:(EnrichedConfig *)config { + LinkRegexConfig *linkRegexConfig = [config linkRegexConfig]; + if (linkRegexConfig == nullptr || linkRegexConfig.isDisabled) { + return NO; + } + NSRange range = NSMakeRange(0, url.length); + NSRegularExpression *userRegex = [config parsedLinkRegex]; + if (linkRegexConfig.isDefault || userRegex == nullptr) { + return [[self fullRegex] numberOfMatchesInString:url options:0 - range:range] || - [[LinkStyle wwwRegex] numberOfMatchesInString:word - options:0 - range:range] || - [[LinkStyle bareRegex] numberOfMatchesInString:word + range:range] > 0 || + [[self wwwRegex] numberOfMatchesInString:url options:0 + range:range] > 0 || + [[self bareRegex] numberOfMatchesInString:url options:0 - range:range]) { - return word; + range:range] > 0; } - - return nullptr; + return [userRegex numberOfMatchesInString:url options:0 range:range] > 0; } // handles refreshing manual links diff --git a/ios/textHtmlParser/TextHtmlParser.mm b/ios/textHtmlParser/TextHtmlParser.mm index 1f930a015..3899fd2d8 100644 --- a/ios/textHtmlParser/TextHtmlParser.mm +++ b/ios/textHtmlParser/TextHtmlParser.mm @@ -31,7 +31,8 @@ - (void)replaceWholeFromHtml:(NSString *_Nonnull)html { return; } - NSArray *result = [HtmlParser getTextAndStylesFromHtml:normalized]; + NSArray *result = [HtmlParser getTextAndStylesFromHtml:normalized + config:nil]; NSString *plainText = result[0]; NSArray *processedStyles = result[1]; NSArray *alignments = result[2];