diff --git a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/inline/Link.stories.tsx b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/inline/Link.stories.tsx index 7ab0a0b30..51433bfea 100644 --- a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/inline/Link.stories.tsx +++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/inline/Link.stories.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Image } from 'react-native'; import { EnrichedMarkdownTextStory } from '../EnrichedMarkdownTextStory'; import { storyMeta } from '../shared/storyMeta'; import { @@ -40,6 +41,10 @@ const PILLS_MARKDOWN = 'Open [src/components/Button.tsx](https://example.com/files/src/components/Button.tsx), ' + 'see [README.md](https://example.com/files/README.md), or visit [React Native](https://reactnative.dev).'; +const PILL_ICON_URI = Image.resolveAssetSource( + require('../../../../../src/assets/logo_icon.png') +).uri; + // Per-link content is separate from the style: one exact URL -> its label / icon. const PILLS_CONTENT = { 'https://example.com/files/src/components/Button.tsx': { @@ -71,6 +76,12 @@ const pillsArgTypes = { description: 'pill.label: shown by every matching link without its own content', }, + tintIcon: { + control: 'boolean', + description: + 'off: pill.iconTintColor is omitted and the icon keeps its colors', + }, + iconTintColor: { control: 'color', description: 'pill.iconTintColor' }, }; const linkBaseArgTypes = { @@ -237,9 +248,9 @@ export const Pills: TextStory = { return ( ); }, diff --git a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookMarkdownStyles.ts b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookMarkdownStyles.ts index 9c2441187..2c94e494e 100644 --- a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookMarkdownStyles.ts +++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookMarkdownStyles.ts @@ -416,6 +416,8 @@ export type LinkPillControls = { paddingVertical: number; maxWidth: number; variantLabel: string; + tintIcon: boolean; + iconTintColor: string; }; export const linkPillDefaults: LinkPillControls = { @@ -429,6 +431,8 @@ export const linkPillDefaults: LinkPillControls = { paddingVertical: 2, maxWidth: 0, variantLabel: '', + tintIcon: true, + iconTintColor: '#3730A3', }; export type InlineCodeStyleControls = { diff --git a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookStyleBuilders.ts b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookStyleBuilders.ts index 930ffe4ee..407499171 100644 --- a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookStyleBuilders.ts +++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/shared/storybookStyleBuilders.ts @@ -346,7 +346,8 @@ export function toLinkVariantsDemoStyle( } export function toLinkPillStyle( - controls: LinkPillControls + controls: LinkPillControls, + iconUri?: string ): Pick { return { linkVariants: { @@ -357,6 +358,10 @@ export function toLinkPillStyle( pill: controls.pill ? { label: controls.variantLabel, + iconUri, + iconTintColor: controls.tintIcon + ? controls.iconTintColor + : undefined, borderColor: controls.borderColor, borderWidth: controls.borderWidth, borderRadius: controls.borderRadius, diff --git a/docs-md/API_REFERENCE.md b/docs-md/API_REFERENCE.md index f99446c62..7836eb81a 100644 --- a/docs-md/API_REFERENCE.md +++ b/docs-md/API_REFERENCE.md @@ -498,11 +498,11 @@ Headers participate in image cache identity, so the same URL requested with diff ### `linkPillContent` -Per-link content for [link pills](./MENTIONS.md#native-link-pills): a label and icon for one exact link URL. It only affects links whose `markdownStyle.linkVariants` entry enables `pill`, and wins over that variant's `pill.label` and `pill.iconUri`. +Per-link content for [link pills](./MENTIONS.md#native-link-pills): a label, icon and icon tint for one exact link URL. It only affects links whose `markdownStyle.linkVariants` entry enables `pill`, and wins over that variant's `pill.label`, `pill.iconUri` and `pill.iconTintColor`. -| Type | Default Value | Platform | -| ------------------------------------------------------ | ------------- | ------------ | -| `Record` | - | iOS, Android | +| Type | Default Value | Platform | +| ------------------------------------------------------------------------------ | ------------- | ------------ | +| `Record` | - | iOS, Android | This is content, not style: keep `markdownStyle` stable and change this map as links appear. Keep the object reference stable between renders when its content has not changed. diff --git a/docs-md/MENTIONS.md b/docs-md/MENTIONS.md index ce9aca527..e718e1167 100644 --- a/docs-md/MENTIONS.md +++ b/docs-md/MENTIONS.md @@ -123,6 +123,7 @@ const markdownStyle = { | ------------------------ | ------------------ | --------------------------------------------------------------------- | | `pill.label` | Original link text | Label shown by every link the pattern matches. | | `pill.iconUri` | No icon | Icon shown by every link the pattern matches. See icon sources below. | +| `pill.iconTintColor` | No tint | Tints the variant's icon and keeps its alpha. Omit for no tint. | | `pill.borderRadius` | `8` | Corner radius in points/DIP. | | `pill.paddingHorizontal` | `6` | Horizontal inset in points/DIP. | | `pill.paddingVertical` | `2` | Vertical inset in points/DIP. | @@ -139,7 +140,9 @@ For text that streams in, set the block's own `lineHeight` to that value instead ### Per-link content: `linkPillContent` -`linkPillContent` maps an exact link URL to `{ label?, iconUri? }`. It only affects links whose variant enables `pill`. For both label and icon the order is: `linkPillContent` entry, then the variant's `pill.label` / `pill.iconUri`, then the link's own text (and no icon). +`linkPillContent` maps an exact link URL to `{ label?, iconUri?, iconTintColor? }`. It only affects links whose variant enables `pill`. For both label and icon the order is: `linkPillContent` entry, then the variant's `pill.label` / `pill.iconUri`, then the link's own text (and no icon). + +The variant's `pill.iconTintColor` tints the variant's own icon only. An icon set per link, such as an avatar, keeps its colors unless its entry has an `iconTintColor`. An entry with only `iconTintColor` recolors the variant's icon for that link. Lookup is by exact URL, so it stays cheap with hundreds of links, and changing it re-renders without touching `markdownStyle`. Keep the object reference stable between renders when its content has not changed. diff --git a/packages/react-native-enriched-markdown/__tests__/link-pill-style.test.ts b/packages/react-native-enriched-markdown/__tests__/link-pill-style.test.ts index 774ec8a72..6eae5a8e2 100644 --- a/packages/react-native-enriched-markdown/__tests__/link-pill-style.test.ts +++ b/packages/react-native-enriched-markdown/__tests__/link-pill-style.test.ts @@ -183,3 +183,47 @@ it('treats structurally equal per-link content as unchanged', () => { expect(isLinkPillContentEqual(undefined, undefined)).toBe(true); expect(isLinkPillContentEqual(undefined, make('a.ts'))).toBe(false); }); + +it('keeps original icon colors by default and passes an explicit tint, including transparent', () => { + const style = normalizeMarkdownStyle({ + linkVariants: { + '^plain:': { pill: true }, + '^tinted:': { pill: { iconTintColor: '#abcdef' } }, + '^clear:': { pill: { iconTintColor: 'transparent' } }, + }, + }); + const pill = (pattern: string) => + style.linkVariants.find((variant) => variant.pattern === pattern)?.pill; + expect(pill('^plain:')?.iconTintColor).toBeUndefined(); + expect(pill('^tinted:')?.iconTintColor).toBe(normalizeColor('#abcdef')); + expect(pill('^clear:')?.iconTintColor).toBe(normalizeColor('transparent')); + expect( + normalizeWebStyle({ + linkVariants: { '^app:': { pill: { iconTintColor: '#abcdef' } } }, + }).linkVariants[0]?.pill.iconTintColor + ).toBe('#abcdef'); +}); + +it('ignores an invalid icon tint instead of making the icon transparent', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const style = normalizeMarkdownStyle({ + linkVariants: { '^app:': { pill: { iconTintColor: 'invalid color' } } }, + }); + expect(style.linkVariants[0]?.pill.iconTintColor).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Ignoring invalid color') + ); + warn.mockRestore(); +}); + +it('passes a per-link icon tint and treats a tint change as a content change', () => { + const make = (iconTintColor?: string) => + normalizeLinkPillContent({ + 'user:a': { iconUri: 'avatar_a', iconTintColor }, + }); + expect(make()![0]!.iconTintColor).toBeUndefined(); + expect(make('#abcdef')![0]!.iconTintColor).toBe(normalizeColor('#abcdef')); + expect(isLinkPillContentEqual(make('#abcdef'), make('#abcdef'))).toBe(true); + expect(isLinkPillContentEqual(make('#abcdef'), make('#123456'))).toBe(false); + expect(isLinkPillContentEqual(make(), make('transparent'))).toBe(false); +}); diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkPillSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkPillSpan.kt index 84d4c6699..005a0b18e 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkPillSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkPillSpan.kt @@ -3,7 +3,10 @@ package com.swmansion.enriched.markdown.spans import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas +import android.graphics.ColorFilter import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter import android.graphics.RectF import android.graphics.Typeface import android.os.Build @@ -90,6 +93,12 @@ class LinkPillSpan( /** The minimum line height the pill asks of the block that holds it; 0 for none. */ val lineHeight: Float = pill.lineHeight + // A tint set for the link wins. The variant's tint is for the variant's own icon, not + // for an icon supplied per link (an avatar would become a silhouette). + private val iconTint: ColorFilter? = + content?.iconTintColor?.let { PorterDuffColorFilter(it, PorterDuff.Mode.SRC_IN) } + ?: pill.iconTint.takeIf { content?.iconUri.isNullOrEmpty() } + init { val iconUri = resolved.iconUri if (LinkPillIconCache.isRemote(iconUri)) { @@ -297,6 +306,8 @@ class LinkPillSpan( val scale = fontSize / max(it.width, it.height) innerRect.set(left, iconTop, left + it.width * scale, iconTop + it.height * scale) innerRect.offset((fontSize - innerRect.width()) / 2, (fontSize - innerRect.height()) / 2) + // The paint is shared by all pills, so the tint is set for every icon, tinted or not. + iconPaint.colorFilter = iconTint canvas.drawBitmap(it, null, innerRect, iconPaint) } left += fontSize * ICON_SLOT_RATIO diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkPillContent.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkPillContent.kt index 69aa200b3..caf69a96a 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkPillContent.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkPillContent.kt @@ -1,6 +1,7 @@ package com.swmansion.enriched.markdown.styles import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType /** * What a pill shows: the variant's default, or one specific link's override keyed by @@ -9,15 +10,27 @@ import com.facebook.react.bridge.ReadableMap data class LinkPillContent( val label: String = "", val iconUri: String = "", + /** Tint for this link's icon; null means none was set for the link. */ + val iconTintColor: Int? = null, ) { - /** Fields unset here take their value from [fallback]. */ + /** + * Label and icon unset here take their value from [fallback]. The tint is left alone: + * a variant's tint is for the variant's own icon, so the span decides when it applies. + */ fun orElse(fallback: LinkPillContent) = - LinkPillContent( + copy( label = label.ifEmpty { fallback.label }, iconUri = iconUri.ifEmpty { fallback.iconUri }, ) companion object { - fun fromReadableMap(map: ReadableMap) = LinkPillContent(map.getString("label") ?: "", map.getString("iconUri") ?: "") + fun fromReadableMap(map: ReadableMap): LinkPillContent { + val hasTint = map.hasKey("iconTintColor") && map.getType("iconTintColor") == ReadableType.Number + return LinkPillContent( + label = map.getString("label") ?: "", + iconUri = map.getString("iconUri") ?: "", + iconTintColor = if (hasTint) map.getDouble("iconTintColor").toInt() else null, + ) + } } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkPillStyle.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkPillStyle.kt index f9a9cd457..30dbb3daa 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkPillStyle.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkPillStyle.kt @@ -1,6 +1,9 @@ package com.swmansion.enriched.markdown.styles import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter import com.facebook.react.bridge.ReadableMap /** Geometry and default content of a variant's pill. Dimensions are in pixels. */ @@ -15,6 +18,9 @@ data class LinkPillStyle( val borderColor: Int = Color.TRANSPARENT, val maxWidth: Float = 0f, ) { + /** Replaces the icon's colors and keeps its alpha; shared by every pill of the variant. */ + val iconTint: ColorFilter? = content.iconTintColor?.let { PorterDuffColorFilter(it, PorterDuff.Mode.SRC_IN) } + companion object { /** Returns null when the variant does not enable pill presentation. */ fun fromReadableMap( @@ -23,7 +29,8 @@ data class LinkPillStyle( ): LinkPillStyle? { if (!parser.parseBoolean(map, "enabled")) return null return LinkPillStyle( - content = LinkPillContent.fromReadableMap(map), + content = + LinkPillContent.fromReadableMap(map).copy(iconTintColor = parser.parseOptionalColor(map, "iconTintColor")), borderRadius = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "borderRadius", 8.0).toFloat()), paddingHorizontal = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "paddingHorizontal", 6.0).toFloat()), paddingVertical = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "paddingVertical", 2.0).toFloat()), diff --git a/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.h b/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.h index 0b43f436e..c98d41132 100644 --- a/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.h +++ b/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.h @@ -23,11 +23,13 @@ NS_ASSUME_NONNULL_BEGIN /// string themselves (the table grid) and have no text view for the attachment to invalidate. @property (nonatomic, copy, nullable) void (^onIconLoaded)(void); -/// `variant.pill` must be non-nil. `label` and `iconUri` are already resolved (per-link content, then variant default). +/// `variant.pill` must be non-nil. `label`, `iconUri` and `iconTintColor` are already resolved (per-link content, +/// then variant default). - (instancetype)initWithOriginalText:(NSAttributedString *)originalText variant:(LinkVariantConfig *)variant label:(nullable NSString *)label iconUri:(nullable NSString *)iconUri + iconTintColor:(nullable UIColor *)iconTintColor font:(nullable UIFont *)font requestHeaders:(nullable NSDictionary *)requestHeaders; diff --git a/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.m b/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.m index 123ec6c91..835fcf70a 100644 --- a/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.m +++ b/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.m @@ -19,6 +19,7 @@ @interface ENRMLinkPillAppearance : NSObject @property (nonatomic, copy) NSString *label; @property (nonatomic, strong) UIFont *font; @property (nonatomic, strong, nullable) UIImage *icon; +@property (nonatomic, strong, nullable) UIColor *iconTint; @property (nonatomic, assign) BOOL reservesIconSlot; @property (nonatomic, assign) CGSize size; @property (nonatomic, assign) UIUserInterfaceStyle interfaceStyle; @@ -37,9 +38,11 @@ - (BOOL)isEqual:(id)object ENRMLinkPillAppearance *other = object; // The variant and icon are compared by identity: both objects live as long as the style and // the icon cache keep them, and this key retains them, so a match is never a recycled pointer. - return _variant == other->_variant && _icon == other->_icon && _reservesIconSlot == other->_reservesIconSlot && - _interfaceStyle == other->_interfaceStyle && CGSizeEqualToSize(_size, other->_size) && - [_font isEqual:other->_font] && [_label isEqualToString:other->_label]; + BOOL sameTint = _iconTint == other->_iconTint || [_iconTint isEqual:other->_iconTint]; + return _variant == other->_variant && _icon == other->_icon && sameTint && + _reservesIconSlot == other->_reservesIconSlot && _interfaceStyle == other->_interfaceStyle && + CGSizeEqualToSize(_size, other->_size) && [_font isEqual:other->_font] && + [_label isEqualToString:other->_label]; } @end @@ -94,6 +97,7 @@ @implementation ENRMLinkPillAttachment { LinkPillConfig *_pill; UIFont *_font; UIImage *_icon; + UIColor *_iconTint; // Space for the icon is held while a remote icon is still loading, so its arrival only needs a redraw. BOOL _reservesIconSlot; __weak NSTextContainer *_textContainer; @@ -104,6 +108,7 @@ - (instancetype)initWithOriginalText:(NSAttributedString *)originalText variant:(LinkVariantConfig *)variant label:(NSString *)label iconUri:(NSString *)iconUri + iconTintColor:(UIColor *)iconTintColor font:(UIFont *)font requestHeaders:(NSDictionary *)requestHeaders { @@ -111,6 +116,7 @@ - (instancetype)initWithOriginalText:(NSAttributedString *)originalText if (self) { _variant = variant; _pill = variant.pill; + _iconTint = iconTintColor; _originalText = [originalText copy]; _font = font ?: [UIFont systemFontOfSize:16]; // The pill is a single line; a label supplied by the app may still carry breaks. @@ -208,6 +214,7 @@ - (UIImage *)imageForBounds:(CGRect)bounds textContainer:(NSTextContainer *)cont appearance.label = _label; appearance.font = _font; appearance.icon = _icon; + appearance.iconTint = _iconTint; appearance.reservesIconSlot = _reservesIconSlot; appearance.size = size; appearance.interfaceStyle = interfaceStyle; @@ -241,7 +248,9 @@ - (UIImage *)imageForBounds:(CGRect)bounds textContainer:(NSTextContainer *)cont CGSize iconSize = CGSizeMake(_icon.size.width * scale, _icon.size.height * scale); CGRect iconRect = CGRectMake(left + (side - iconSize.width) / 2, (size.height - iconSize.height) / 2, iconSize.width, iconSize.height); - [_icon drawInRect:iconRect]; + UIImage *presented = + _iconTint ? [_icon imageWithTintColor:_iconTint renderingMode:UIImageRenderingModeAlwaysOriginal] : _icon; + [presented drawInRect:iconRect]; } left += side * kIconSlotRatio; } diff --git a/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.m index d2cbeae44..4a7b8a7fd 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.m @@ -29,12 +29,17 @@ static NSRange ENRMCollapseLinkIntoPill(NSMutableAttributedString *output, NSRan LinkVariantConfig *variant, StyleConfig *config, RenderContext *context) { LinkPillContent *content = [config linkPillContent][url]; + BOOL hasOwnIcon = content.iconUri.length > 0; + // A tint set for the link wins. The variant's tint is for the variant's own icon, not for an + // icon supplied per link (an avatar would become a silhouette). + RCTUIColor *iconTint = content.iconTintColor ?: (hasOwnIcon ? nil : variant.pill.iconTintColor); NSDictionary *attributes = [output attributesAtIndex:range.location effectiveRange:NULL]; ENRMLinkPillAttachment *pill = [[ENRMLinkPillAttachment alloc] initWithOriginalText:[output attributedSubstringFromRange:range] variant:variant label:content.label.length > 0 ? content.label : variant.pill.label - iconUri:content.iconUri.length > 0 ? content.iconUri : variant.pill.iconUri + iconUri:hasOwnIcon ? content.iconUri : variant.pill.iconUri + iconTintColor:iconTint font:attributes[NSFontAttributeName] ?: [context getBlockStyle].cachedFont requestHeaders:[config imageRequestHeaders]]; pill.lineHeight = [config lineHeightForLinkPill:variant.pill]; diff --git a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h index 5af78c9a0..628d069ed 100644 --- a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h +++ b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h @@ -7,6 +7,8 @@ NS_ASSUME_NONNULL_BEGIN @interface LinkPillConfig : NSObject @property (nonatomic, copy) NSString *label; @property (nonatomic, copy) NSString *iconUri; +/// Nil keeps the icon's own colors. +@property (nonatomic, strong, nullable) RCTUIColor *iconTintColor; @property (nonatomic, assign) CGFloat borderRadius; @property (nonatomic, assign) CGFloat paddingHorizontal; @property (nonatomic, assign) CGFloat paddingVertical; @@ -21,6 +23,8 @@ NS_ASSUME_NONNULL_BEGIN @interface LinkPillContent : NSObject @property (nonatomic, copy) NSString *label; @property (nonatomic, copy) NSString *iconUri; +/// Tint for this link's icon; nil means none was set for the link. +@property (nonatomic, strong, nullable) RCTUIColor *iconTintColor; @end /** diff --git a/packages/react-native-enriched-markdown/ios/utils/LinkPillContentUtils.h b/packages/react-native-enriched-markdown/ios/utils/LinkPillContentUtils.h index 9dd620ed9..e13b68c67 100644 --- a/packages/react-native-enriched-markdown/ios/utils/LinkPillContentUtils.h +++ b/packages/react-native-enriched-markdown/ios/utils/LinkPillContentUtils.h @@ -4,6 +4,7 @@ #import #ifdef __cplusplus +#import #include // The `linkPillContent` prop: per-link pill label and icon, keyed by exact URL. @@ -16,7 +17,7 @@ static bool ENRMLinkPillContentChanged(const std::vector &oldContent, const s } for (size_t i = 0; i < newContent.size(); i++) { if (newContent[i].url != oldContent[i].url || newContent[i].label != oldContent[i].label || - newContent[i].iconUri != oldContent[i].iconUri) { + newContent[i].iconUri != oldContent[i].iconUri || newContent[i].iconTintColor != oldContent[i].iconTintColor) { return true; } } @@ -36,6 +37,7 @@ static NSDictionary *_Nullable ENRMLinkPillConten LinkPillContent *value = [[LinkPillContent alloc] init]; value.label = @(entry.label.c_str()); value.iconUri = @(entry.iconUri.c_str()); + value.iconTintColor = entry.iconTintColor ? RCTUIColorFromSharedColor(entry.iconTintColor) : nil; result[@(entry.url.c_str())] = value; } return [result copy]; diff --git a/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h b/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h index 944cd5f12..dc242fce3 100644 --- a/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h +++ b/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h @@ -563,6 +563,7 @@ BOOL applyMarkdownStyleToConfig(StyleConfig *config, const MarkdownStyle &newSty newVariant.underline != oldVariant.underline || newVariant.backgroundColor != oldVariant.backgroundColor || newVariant.fontFamily != oldVariant.fontFamily || newVariant.pill.enabled != oldVariant.pill.enabled || newVariant.pill.label != oldVariant.pill.label || newVariant.pill.iconUri != oldVariant.pill.iconUri || + newVariant.pill.iconTintColor != oldVariant.pill.iconTintColor || newVariant.pill.borderRadius != oldVariant.pill.borderRadius || newVariant.pill.paddingHorizontal != oldVariant.pill.paddingHorizontal || newVariant.pill.paddingVertical != oldVariant.pill.paddingVertical || @@ -587,6 +588,7 @@ BOOL applyMarkdownStyleToConfig(StyleConfig *config, const MarkdownStyle &newSty LinkPillConfig *pill = [[LinkPillConfig alloc] init]; pill.label = [[NSString alloc] initWithUTF8String:entry.pill.label.c_str()]; pill.iconUri = [[NSString alloc] initWithUTF8String:entry.pill.iconUri.c_str()]; + pill.iconTintColor = entry.pill.iconTintColor ? RCTUIColorFromSharedColor(entry.pill.iconTintColor) : nil; pill.borderRadius = entry.pill.borderRadius; pill.paddingHorizontal = entry.pill.paddingHorizontal; pill.paddingVertical = entry.pill.paddingVertical; diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts index bd4a37900..1c0af24c1 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts @@ -95,6 +95,7 @@ interface LinkPillInternal { enabled: boolean; label: string; iconUri: string; + iconTintColor?: ColorValue; borderRadius: CodegenTypes.Float; paddingHorizontal: CodegenTypes.Float; paddingVertical: CodegenTypes.Float; @@ -321,6 +322,7 @@ export interface LinkPillContentInternal { url: string; label: string; iconUri: string; + iconTintColor?: ColorValue; } export interface SelectionMenuConfig { diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts index 82eca0f10..973e74820 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts @@ -95,6 +95,7 @@ interface LinkPillInternal { enabled: boolean; label: string; iconUri: string; + iconTintColor?: ColorValue; borderRadius: CodegenTypes.Float; paddingHorizontal: CodegenTypes.Float; paddingVertical: CodegenTypes.Float; @@ -322,6 +323,7 @@ export interface LinkPillContentInternal { url: string; label: string; iconUri: string; + iconTintColor?: ColorValue; } export interface SelectionMenuConfig { diff --git a/packages/react-native-enriched-markdown/src/linkVariantUtils.ts b/packages/react-native-enriched-markdown/src/linkVariantUtils.ts index a6301f0fc..a7589eb1b 100644 --- a/packages/react-native-enriched-markdown/src/linkVariantUtils.ts +++ b/packages/react-native-enriched-markdown/src/linkVariantUtils.ts @@ -1,5 +1,6 @@ import type { LinkVariantStyle } from './types/MarkdownStyle'; import type { LinkPillContent } from './types/MarkdownTextProps'; +import { normalizeColor } from './styleUtils'; type LinkVariantEntry = [pattern: string, style: LinkVariantStyle]; @@ -56,6 +57,7 @@ export function normalizeLinkPillStyle(style: LinkVariantStyle) { borderColor: config.borderColor ?? 'transparent', label: config.label ?? '', iconUri: config.iconUri ?? '', + iconTintColor: config.iconTintColor, borderRadius: dimension(config.borderRadius, 8), paddingHorizontal: dimension(config.paddingHorizontal, 6), paddingVertical: dimension(config.paddingVertical, 2), @@ -71,10 +73,11 @@ export function normalizeLinkPillContent( ) { if (!content) return undefined; return Object.entries(content) - .map(([url, { label, iconUri }]) => ({ + .map(([url, { label, iconUri, iconTintColor }]) => ({ url, label: label ?? '', iconUri: iconUri ?? '', + iconTintColor: normalizeColor(iconTintColor), })) .sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0)); } @@ -93,7 +96,8 @@ export function isLinkPillContentEqual( return ( entry.url === other.url && entry.label === other.label && - entry.iconUri === other.iconUri + entry.iconUri === other.iconUri && + entry.iconTintColor === other.iconTintColor ); }); } diff --git a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts index 0566f5b41..f36748212 100644 --- a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts +++ b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts @@ -345,6 +345,9 @@ export const normalizeMarkdownStyle = ( pattern, pill: { ...pill, + iconTintColor: normalizeColor(pill.iconTintColor) as + | string + | undefined, borderColor: (normalizeColor(pill.borderColor) ?? transparent) as string, }, diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts b/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts index 1aa8a1d70..0b439129d 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts @@ -132,6 +132,11 @@ export interface LinkPillStyle { label?: string; /** Local file, bundled asset or `http(s)` URL. A source that fails shows no icon. */ iconUri?: string; + /** + * Tints this variant's icon, keeping its alpha. Omit to keep the image's own colors. + * Icons set per link through `linkPillContent` are not tinted by it. + */ + iconTintColor?: string; borderRadius?: number; paddingHorizontal?: number; paddingVertical?: number; diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts b/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts index 7f3f09e59..9117812ac 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts @@ -117,6 +117,7 @@ export interface LinkPillInternal { enabled: boolean; label: string; iconUri: string; + iconTintColor?: string; borderRadius: number; paddingHorizontal: number; paddingVertical: number; diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts index 2f0b2170f..70fddd92b 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts @@ -14,6 +14,11 @@ import type { export interface LinkPillContent { label?: string; iconUri?: string; + /** + * Tint for this link's icon. The variant's `pill.iconTintColor` is not applied + * to an icon set here through `iconUri`. + */ + iconTintColor?: string; } /**