From 430a52c973cd18a34306abd3214edfdfaa9d0097 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 4 Oct 2026 13:09:22 +0200 Subject: [PATCH 1/2] feat(link-pill): add optional icon tint `pill.iconTintColor` replaces the colors of a pill's icon and keeps its alpha; omitting it keeps the image's own colors. It applies to local and remote icons and leaves the cached image untouched. The option and its Android and Jest tests come from #863, ported to the nested `pill` configuration. Android sets the tint on the shared icon paint for every icon, tinted or not, so one pill cannot tint the next. Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-Authored-By: Claude Fable 5.1 --- docs/MENTIONS.md | 1 + .../ios/ENRMLinkPillAttachmentTests.mm | 54 +++++++++++++ .../__tests__/link-pill-style.test.ts | 32 ++++++++ .../enriched/markdown/spans/LinkPillSpan.kt | 2 + .../enriched/markdown/styles/LinkPillStyle.kt | 9 +++ .../markdown/spans/LinkPillSpanTest.kt | 81 +++++++++++++++++++ .../ios/attachments/ENRMLinkPillAttachment.m | 5 +- .../ios/styles/StyleConfig.h | 2 + .../ios/utils/StylePropsUtils.h | 2 + .../src/EnrichedMarkdownNativeComponent.ts | 1 + .../EnrichedMarkdownTextNativeComponent.ts | 1 + .../src/linkVariantUtils.ts | 1 + .../src/normalizeMarkdownStyle.ts | 3 + .../src/types/MarkdownStyle.ts | 2 + .../src/types/MarkdownStyleInternal.ts | 1 + 15 files changed, 196 insertions(+), 1 deletion(-) diff --git a/docs/MENTIONS.md b/docs/MENTIONS.md index 886f2a905..2e22068bd 100644 --- a/docs/MENTIONS.md +++ b/docs/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 | Replaces the icon's colors, keeping its alpha. Omit to keep them. | | `pill.borderRadius` | `8` | Corner radius in points/DIP. | | `pill.paddingHorizontal` | `6` | Horizontal inset in points/DIP. | | `pill.paddingVertical` | `2` | Vertical inset in points/DIP. | diff --git a/packages/react-native-enriched-markdown/__tests__/ios/ENRMLinkPillAttachmentTests.mm b/packages/react-native-enriched-markdown/__tests__/ios/ENRMLinkPillAttachmentTests.mm index 2e70c06b0..745fe85a5 100644 --- a/packages/react-native-enriched-markdown/__tests__/ios/ENRMLinkPillAttachmentTests.mm +++ b/packages/react-native-enriched-markdown/__tests__/ios/ENRMLinkPillAttachmentTests.mm @@ -482,6 +482,60 @@ - (void)testLocalIconFollowsItsFileAfterTheRevalidationInterval [NSFileManager.defaultManager removeItemAtPath:path error:nil]; } +- (void)testIconTintReplacesIconColorsAndKeepsThemWithoutATint +{ + NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:@"enrm-pill-tint-icon.png"]; + UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat preferredFormat]; + format.scale = 1; + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(16, 16) format:format]; + [UIImagePNGRepresentation([renderer imageWithActions:^(UIGraphicsImageRendererContext *context) { + [UIColor.redColor setFill]; + UIRectFill(CGRectMake(0, 0, 16, 16)); + }]) writeToFile:path + atomically:YES]; + + // Counts pixels that are clearly red or clearly blue in a drawn pill. + void (^count)(UIColor *, NSUInteger *, NSUInteger *) = ^(UIColor *tint, NSUInteger *red, NSUInteger *blue) { + LinkVariantConfig *variant = [self variantWithPill:YES]; + variant.color = UIColor.blackColor; + variant.backgroundColor = UIColor.whiteColor; + variant.pill.iconUri = path; + variant.pill.iconTintColor = tint; + StyleConfig *config = [self configWithPill:YES]; + [config setLinkVariants:@[ variant ]]; + ENRMLinkPillAttachment *pill = + [self pillsIn:[self render:@"[label](https://example.com/doc)" config:config]].firstObject; + CGRect bounds = [self boundsOf:pill inWidth:1000]; + UIImage *image = [pill imageForBounds:bounds textContainer:nil characterIndex:0]; + size_t width = CGImageGetWidth(image.CGImage), height = CGImageGetHeight(image.CGImage); + NSMutableData *pixels = [NSMutableData dataWithLength:width * height * 4]; + CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB(); + CGContextRef bitmap = CGBitmapContextCreate(pixels.mutableBytes, width, height, 8, width * 4, space, + kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big); + CGColorSpaceRelease(space); + CGContextDrawImage(bitmap, CGRectMake(0, 0, width, height), image.CGImage); + CGContextRelease(bitmap); + const uint8_t *bytes = (const uint8_t *)pixels.bytes; + for (size_t offset = 0; offset < pixels.length; offset += 4) { + if (bytes[offset] > 215 && bytes[offset + 1] < 40 && bytes[offset + 2] < 40) + (*red)++; + if (bytes[offset] < 40 && bytes[offset + 1] < 40 && bytes[offset + 2] > 215) + (*blue)++; + } + }; + + NSUInteger red = 0, blue = 0; + count(nil, &red, &blue); + XCTAssertGreaterThan(red, 50u, @"without a tint the icon keeps its own colors"); + XCTAssertEqual(blue, 0u); + + red = blue = 0; + count(UIColor.blueColor, &red, &blue); + XCTAssertGreaterThan(blue, 50u, @"the tint replaces the icon's colors"); + XCTAssertEqual(red, 0u); + [NSFileManager.defaultManager removeItemAtPath:path error:nil]; +} + - (void)testFailedIconReleasesItsSlot { StyleConfig *config = [self configWithPill:YES]; 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 cee9a5018..cf33bdeae 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 @@ -182,3 +182,35 @@ 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(); +}); 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 7ddbdd9eb..d0d3aaf3d 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 @@ -263,6 +263,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 = pill.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/LinkPillStyle.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkPillStyle.kt index ad9ef2156..568f592ba 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,12 +1,17 @@ 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. */ data class LinkPillStyle( val label: String = "", val iconUri: String = "", + /** Null keeps the icon's own colors. */ + val iconTintColor: Int? = null, val borderRadius: Float = 8f, val paddingHorizontal: Float = 6f, val paddingVertical: Float = 2f, @@ -14,6 +19,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? = iconTintColor?.let { PorterDuffColorFilter(it, PorterDuff.Mode.SRC_IN) } + companion object { /** Returns null when the variant does not enable pill presentation. */ fun fromReadableMap( @@ -24,6 +32,7 @@ data class LinkPillStyle( return LinkPillStyle( label = parser.parseString(map, "label"), iconUri = parser.parseString(map, "iconUri"), + 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/android/src/test/java/com/swmansion/enriched/markdown/spans/LinkPillSpanTest.kt b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/spans/LinkPillSpanTest.kt index 3e109f478..46a8d8317 100644 --- a/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/spans/LinkPillSpanTest.kt +++ b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/spans/LinkPillSpanTest.kt @@ -858,6 +858,87 @@ class LinkPillSpanTest { assertFalse(changed.any { it === outside }) } + private fun drawIcon( + uri: String, + tint: Int?, + ): Bitmap { + val bitmap = Bitmap.createBitmap(120, 40, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val iconTop = 24 + (paint.fontMetrics.ascent + paint.fontMetrics.descent - 16) / 2 + // Align the bitmap destination, avoiding fractional resampling differences between Skia paths. + canvas.translate(0f, kotlin.math.floor(iconTop) - iconTop) + val variant = + pillStyle { copy(iconUri = uri, iconTintColor = tint, paddingHorizontal = 0f, borderWidth = 0f) } + .copy(backgroundColor = Color.TRANSPARENT) + span(variant).draw(canvas, original, 0, original.length, 0f, 0, 24, 40, paint) + return bitmap + } + + @Test + fun iconTintPreservesAlphaAndLeavesCachedOriginalColorsUnchanged() { + val file = File.createTempFile("enriched-icon", ".png") + try { + writeIcon(file) + val uri = file.toURI().toString() + val cached = LinkPillIconCache.load(RuntimeEnvironment.getApplication(), uri)!! + val before = drawIcon(uri, null) + val tinted = drawIcon(uri, Color.BLUE) + val translucent = drawIcon(uri, Color.argb(128, 0, 0, 255)) + val transparent = drawIcon(uri, Color.TRANSPARENT) + // The icon paint is shared by all pills: a tinted pill must not tint the next one. + val after = drawIcon(uri, null) + assertSame(cached, LinkPillIconCache.load(RuntimeEnvironment.getApplication(), uri)) + assertEquals(Color.RED, cached.getPixel(2, 8)) + assertEquals(Color.GREEN, cached.getPixel(12, 8)) + assertTrue(pixels(before).contentEquals(pixels(after))) + val iconTop = kotlin.math.floor(24 + (paint.fontMetrics.ascent + paint.fontMetrics.descent - 16) / 2) + val centerY = (iconTop + 8).toInt() + assertEquals(Color.RED, before.getPixel(2, centerY)) + assertEquals(Color.GREEN, before.getPixel(12, centerY)) + assertEquals(Color.BLUE, tinted.getPixel(2, centerY)) + assertEquals(Color.BLUE, tinted.getPixel(12, centerY)) + assertEquals(128, Color.alpha(translucent.getPixel(2, centerY))) + assertEquals(Color.TRANSPARENT, transparent.getPixel(2, centerY)) + assertEquals(128, Color.alpha(tinted.getPixel(2, (iconTop + 12).toInt()))) + assertEquals(Color.TRANSPARENT, tinted.getPixel(2, (iconTop + 5).toInt())) + assertEquals(64, Color.alpha(translucent.getPixel(2, (iconTop + 12).toInt()))) + // Flat regions verify image alpha independently of fractional resampling at transitions. + for (offset in listOf(2, 5, 8, 12)) { + val y = (iconTop + offset).toInt() + for (x in 1 until 15) { + assertEquals(Color.alpha(before.getPixel(x, y)), Color.alpha(tinted.getPixel(x, y))) + } + } + for (y in 0 until 40) { + for (x in 0 until 16) { + if (Color.alpha(tinted.getPixel(x, y)) > 0) { + assertEquals(0, Color.red(tinted.getPixel(x, y))) + assertEquals(0, Color.green(tinted.getPixel(x, y))) + assertEquals(255, Color.blue(tinted.getPixel(x, y))) + } + } + } + } finally { + file.delete() + } + } + + @Test + fun tintTransportDistinguishesMissingAndTransparentAndParticipatesInEquality() { + val parser = StyleParser(RuntimeEnvironment.getApplication(), false, 1f) + val map = JavaOnlyMap.of("enabled", true) + val absent = LinkPillStyle.fromReadableMap(map, parser)!! + assertNull(absent.iconTintColor) + map.putDouble("iconTintColor", 0.0) + val clear = LinkPillStyle.fromReadableMap(map, parser)!! + assertEquals(Color.TRANSPARENT, clear.iconTintColor) + map.putDouble("iconTintColor", Color.BLUE.toDouble()) + val blue = LinkPillStyle.fromReadableMap(map, parser)!! + assertEquals(Color.BLUE, blue.iconTintColor) + assertNotEquals(absent, clear) + assertNotEquals(clear.hashCode(), blue.hashCode()) + } + @Test fun pillInsidePaddedBlockquoteCoversExactlyTheLink() { val url = "https://example.com/doc" diff --git a/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.m b/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.m index 123ec6c91..3c74735b1 100644 --- a/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.m +++ b/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.m @@ -241,7 +241,10 @@ - (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 = _pill.iconTintColor ? [_icon imageWithTintColor:_pill.iconTintColor + renderingMode:UIImageRenderingModeAlwaysOriginal] + : _icon; + [presented drawInRect:iconRect]; } left += side * kIconSlotRatio; } diff --git a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h index 0fabddde9..e3b6710b6 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; diff --git a/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h b/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h index 7eb0dc66c..4dde94535 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 || @@ -586,6 +587,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 6fc80c5b1..177270d3b 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; diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts index 7eee146f3..c237292b8 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; diff --git a/packages/react-native-enriched-markdown/src/linkVariantUtils.ts b/packages/react-native-enriched-markdown/src/linkVariantUtils.ts index b44a38c2d..94be84444 100644 --- a/packages/react-native-enriched-markdown/src/linkVariantUtils.ts +++ b/packages/react-native-enriched-markdown/src/linkVariantUtils.ts @@ -56,6 +56,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), 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 69047d37e..0ca3aa1e5 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts @@ -132,6 +132,8 @@ export interface LinkPillStyle { label?: string; /** Local file, bundled asset or `http(s)` URL. A source that fails shows no icon. */ iconUri?: string; + /** Tints the icon, keeping its alpha. Omit to keep the image's own colors. */ + 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 db8d1185d..0fcd47454 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; From 48ccef6bd3ec877eb869b21f001c9a71d76ca841 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 4 Oct 2026 16:35:51 +0200 Subject: [PATCH 2/2] feat(link-pill): keep the variant tint off per-link icons The variant's `pill.iconTintColor` tinted whatever icon a pill showed, so an icon supplied through `linkPillContent` (an avatar, say) became a silhouette in the tint color. The variant tint now applies only to the variant's own icon. A `linkPillContent` entry can carry its own `iconTintColor`: it tints the entry's icon, or recolors the variant's icon for that one link when the entry has no icon. Also covers transparent and half-transparent tints in the iOS tests, documents the rule, and adds an icon with a tint control to the Storybook pill story. Co-Authored-By: Claude Fable 5.1 --- .../inline/Link.stories.tsx | 15 +- .../shared/storybookMarkdownStyles.ts | 4 + .../shared/storybookStyleBuilders.ts | 7 +- docs/API_REFERENCE.md | 8 +- docs/MENTIONS.md | 6 +- .../ios/ENRMLinkPillAttachmentTests.mm | 131 ++++++++++++------ .../__tests__/link-pill-style.test.ts | 12 ++ .../enriched/markdown/spans/LinkPillSpan.kt | 11 +- .../markdown/styles/LinkPillContent.kt | 2 + .../utils/common/MarkdownViewManagerUtils.kt | 5 +- .../markdown/spans/LinkPillSpanTest.kt | 40 ++++++ .../ios/attachments/ENRMLinkPillAttachment.h | 4 +- .../ios/attachments/ENRMLinkPillAttachment.m | 18 ++- .../ios/renderer/LinkRenderer.m | 7 +- .../ios/styles/StyleConfig.h | 2 + .../ios/utils/LinkPillContentUtils.h | 4 +- .../src/EnrichedMarkdownNativeComponent.ts | 1 + .../EnrichedMarkdownTextNativeComponent.ts | 1 + .../src/linkVariantUtils.ts | 7 +- .../src/types/MarkdownStyle.ts | 5 +- .../src/types/MarkdownTextProps.ts | 5 + 21 files changed, 232 insertions(+), 63 deletions(-) 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/API_REFERENCE.md b/docs/API_REFERENCE.md index f99446c62..7836eb81a 100644 --- a/docs/API_REFERENCE.md +++ b/docs/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/MENTIONS.md b/docs/MENTIONS.md index 2e22068bd..2e0809d10 100644 --- a/docs/MENTIONS.md +++ b/docs/MENTIONS.md @@ -123,7 +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 | Replaces the icon's colors, keeping its alpha. Omit to keep them. | +| `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. | @@ -135,7 +135,9 @@ Nonfinite dimensions use defaults and negative dimensions clamp to zero. ### 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__/ios/ENRMLinkPillAttachmentTests.mm b/packages/react-native-enriched-markdown/__tests__/ios/ENRMLinkPillAttachmentTests.mm index 745fe85a5..263f4f851 100644 --- a/packages/react-native-enriched-markdown/__tests__/ios/ENRMLinkPillAttachmentTests.mm +++ b/packages/react-native-enriched-markdown/__tests__/ios/ENRMLinkPillAttachmentTests.mm @@ -482,9 +482,11 @@ - (void)testLocalIconFollowsItsFileAfterTheRevalidationInterval [NSFileManager.defaultManager removeItemAtPath:path error:nil]; } -- (void)testIconTintReplacesIconColorsAndKeepsThemWithoutATint +#pragma mark - Icon tint + +- (NSString *)writeRedIconNamed:(NSString *)name { - NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:@"enrm-pill-tint-icon.png"]; + NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:name]; UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat preferredFormat]; format.scale = 1; UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(16, 16) format:format]; @@ -493,47 +495,96 @@ - (void)testIconTintReplacesIconColorsAndKeepsThemWithoutATint UIRectFill(CGRectMake(0, 0, 16, 16)); }]) writeToFile:path atomically:YES]; + return path; +} - // Counts pixels that are clearly red or clearly blue in a drawn pill. - void (^count)(UIColor *, NSUInteger *, NSUInteger *) = ^(UIColor *tint, NSUInteger *red, NSUInteger *blue) { - LinkVariantConfig *variant = [self variantWithPill:YES]; - variant.color = UIColor.blackColor; - variant.backgroundColor = UIColor.whiteColor; - variant.pill.iconUri = path; - variant.pill.iconTintColor = tint; - StyleConfig *config = [self configWithPill:YES]; - [config setLinkVariants:@[ variant ]]; - ENRMLinkPillAttachment *pill = - [self pillsIn:[self render:@"[label](https://example.com/doc)" config:config]].firstObject; - CGRect bounds = [self boundsOf:pill inWidth:1000]; - UIImage *image = [pill imageForBounds:bounds textContainer:nil characterIndex:0]; - size_t width = CGImageGetWidth(image.CGImage), height = CGImageGetHeight(image.CGImage); - NSMutableData *pixels = [NSMutableData dataWithLength:width * height * 4]; - CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB(); - CGContextRef bitmap = CGBitmapContextCreate(pixels.mutableBytes, width, height, 8, width * 4, space, - kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big); - CGColorSpaceRelease(space); - CGContextDrawImage(bitmap, CGRectMake(0, 0, width, height), image.CGImage); - CGContextRelease(bitmap); - const uint8_t *bytes = (const uint8_t *)pixels.bytes; - for (size_t offset = 0; offset < pixels.length; offset += 4) { - if (bytes[offset] > 215 && bytes[offset + 1] < 40 && bytes[offset + 2] < 40) - (*red)++; - if (bytes[offset] < 40 && bytes[offset + 1] < 40 && bytes[offset + 2] > 215) - (*blue)++; - } - }; +/// Draws a black-on-white pill and counts the pixels of its icon by color: the icon's own +/// red, a full blue tint, and blue at half strength over the white background. +- (NSDictionary *)iconColorsWithVariantIcon:(NSString *)variantIcon + variantTint:(UIColor *)variantTint + content:(LinkPillContent *)content +{ + LinkVariantConfig *variant = [self variantWithPill:YES]; + variant.color = UIColor.blackColor; + variant.backgroundColor = UIColor.whiteColor; + variant.pill.iconUri = variantIcon; + variant.pill.iconTintColor = variantTint; + StyleConfig *config = [self configWithPill:YES]; + [config setLinkVariants:@[ variant ]]; + if (content) + [config setLinkPillContent:@{kDocURL : content}]; + ENRMLinkPillAttachment *pill = + [self pillsIn:[self render:@"[label](https://example.com/doc)" config:config]].firstObject; + UIImage *image = [pill imageForBounds:[self boundsOf:pill inWidth:1000] textContainer:nil characterIndex:0]; + size_t width = CGImageGetWidth(image.CGImage), height = CGImageGetHeight(image.CGImage); + NSMutableData *pixels = [NSMutableData dataWithLength:width * height * 4]; + CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB(); + CGContextRef bitmap = CGBitmapContextCreate(pixels.mutableBytes, width, height, 8, width * 4, space, + kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big); + CGColorSpaceRelease(space); + CGContextDrawImage(bitmap, CGRectMake(0, 0, width, height), image.CGImage); + CGContextRelease(bitmap); + const uint8_t *bytes = (const uint8_t *)pixels.bytes; + NSUInteger red = 0, blue = 0, halfBlue = 0; + for (size_t offset = 0; offset < pixels.length; offset += 4) { + uint8_t r = bytes[offset], g = bytes[offset + 1], b = bytes[offset + 2]; + if (r > 215 && g < 40 && b < 40) + red++; + if (r < 40 && g < 40 && b > 215) + blue++; + if (r > 90 && r < 165 && g > 90 && g < 165 && b > 215) + halfBlue++; + } + return @{@"red" : @(red), @"blue" : @(blue), @"halfBlue" : @(halfBlue)}; +} - NSUInteger red = 0, blue = 0; - count(nil, &red, &blue); - XCTAssertGreaterThan(red, 50u, @"without a tint the icon keeps its own colors"); - XCTAssertEqual(blue, 0u); +- (void)testIconTintReplacesIconColorsAndKeepsItsAlpha +{ + NSString *icon = [self writeRedIconNamed:@"enrm-pill-tint-icon.png"]; + NSDictionary *plain = [self iconColorsWithVariantIcon:icon variantTint:nil content:nil]; + XCTAssertGreaterThan([plain[@"red"] unsignedIntegerValue], 50u, @"without a tint the icon keeps its own colors"); + XCTAssertEqualObjects(plain[@"blue"], @0); + + NSDictionary *tinted = [self iconColorsWithVariantIcon:icon variantTint:UIColor.blueColor content:nil]; + XCTAssertEqualObjects(tinted[@"blue"], plain[@"red"], @"the tint replaces every pixel of the icon"); + XCTAssertEqualObjects(tinted[@"red"], @0); + + UIColor *half = [UIColor colorWithRed:0 green:0 blue:1 alpha:0.5]; + NSDictionary *translucent = [self iconColorsWithVariantIcon:icon variantTint:half content:nil]; + XCTAssertEqualObjects(translucent[@"halfBlue"], plain[@"red"], @"the tint's alpha is applied"); + + // A transparent tint is a tint: it hides the icon, unlike a missing one. + NSDictionary *clear = [self iconColorsWithVariantIcon:icon variantTint:UIColor.clearColor content:nil]; + XCTAssertEqualObjects(clear, (@{@"red" : @0, @"blue" : @0, @"halfBlue" : @0})); + [NSFileManager.defaultManager removeItemAtPath:icon error:nil]; +} - red = blue = 0; - count(UIColor.blueColor, &red, &blue); - XCTAssertGreaterThan(blue, 50u, @"the tint replaces the icon's colors"); - XCTAssertEqual(red, 0u); - [NSFileManager.defaultManager removeItemAtPath:path error:nil]; +- (void)testVariantTintLeavesPerLinkIconsAloneAndAPerLinkTintWins +{ + NSString *glyph = [self writeRedIconNamed:@"enrm-pill-tint-glyph.png"]; + NSString *avatar = [self writeRedIconNamed:@"enrm-pill-tint-avatar.png"]; + UIColor *half = [UIColor colorWithRed:0 green:0 blue:1 alpha:0.5]; + + LinkPillContent *ownIcon = [self contentWithLabel:@"" iconUri:avatar]; + NSDictionary *untouched = [self iconColorsWithVariantIcon:glyph variantTint:UIColor.blueColor content:ownIcon]; + XCTAssertGreaterThan([untouched[@"red"] unsignedIntegerValue], 50u, @"a per-link icon keeps its colors"); + XCTAssertEqualObjects(untouched[@"blue"], @0); + + LinkPillContent *ownIconAndTint = [self contentWithLabel:@"" iconUri:avatar]; + ownIconAndTint.iconTintColor = half; + NSDictionary *ownTint = [self iconColorsWithVariantIcon:glyph variantTint:UIColor.blueColor content:ownIconAndTint]; + XCTAssertGreaterThan([ownTint[@"halfBlue"] unsignedIntegerValue], 50u); + XCTAssertEqualObjects(ownTint[@"red"], @0); + XCTAssertEqualObjects(ownTint[@"blue"], @0); + + // A per-link tint without an icon recolors the variant's icon for that link. + LinkPillContent *tintOnly = [self contentWithLabel:@"" iconUri:@""]; + tintOnly.iconTintColor = half; + NSDictionary *recolored = [self iconColorsWithVariantIcon:glyph variantTint:UIColor.blueColor content:tintOnly]; + XCTAssertGreaterThan([recolored[@"halfBlue"] unsignedIntegerValue], 50u); + XCTAssertEqualObjects(recolored[@"blue"], @0); + [NSFileManager.defaultManager removeItemAtPath:glyph error:nil]; + [NSFileManager.defaultManager removeItemAtPath:avatar error:nil]; } - (void)testFailedIconReleasesItsSlot 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 cf33bdeae..124e40afe 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 @@ -214,3 +214,15 @@ it('ignores an invalid icon tint instead of making the icon transparent', () => ); 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 d0d3aaf3d..c88b01054 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 @@ -79,6 +82,12 @@ class LinkPillSpan( val accessibilityText = if (label == originalLinkText) originalLinkText else "$label, $originalLinkText" + // 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 = content?.iconUri?.ifEmpty { null } ?: pill.iconUri if (LinkPillIconCache.isRemote(iconUri)) { @@ -264,7 +273,7 @@ class LinkPillSpan( 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 = pill.iconTint + 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 9c3a232a6..bd0602bc8 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 @@ -8,4 +8,6 @@ package com.swmansion.enriched.markdown.styles 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, ) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt index 71e1b07cc..b252dfaca 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt @@ -3,6 +3,7 @@ package com.swmansion.enriched.markdown.utils.common import android.view.View import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType import com.facebook.react.uimanager.UIManagerHelper import com.swmansion.enriched.markdown.accessibility.AccessibilityLabels import com.swmansion.enriched.markdown.events.CodeBlockPressEvent @@ -159,7 +160,9 @@ fun parseLinkPillContent(value: ReadableArray?): Map = .mapNotNull { index -> val entry = value?.getMap(index) ?: return@mapNotNull null val url = entry.getString("url") ?: return@mapNotNull null - url to LinkPillContent(entry.getString("label") ?: "", entry.getString("iconUri") ?: "") + val hasTint = entry.hasKey("iconTintColor") && entry.getType("iconTintColor") == ReadableType.Number + val tint = if (hasTint) entry.getDouble("iconTintColor").toInt() else null + url to LinkPillContent(entry.getString("label") ?: "", entry.getString("iconUri") ?: "", tint) }.toMap() fun parseSelectionMenuConfig(value: ReadableMap?): SelectionMenuConfig { diff --git a/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/spans/LinkPillSpanTest.kt b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/spans/LinkPillSpanTest.kt index 46a8d8317..cf04098f3 100644 --- a/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/spans/LinkPillSpanTest.kt +++ b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/spans/LinkPillSpanTest.kt @@ -697,12 +697,17 @@ class LinkPillSpanTest { JavaOnlyArray.of( JavaOnlyMap.of("url", "https://example.com/a", "label", "A", "iconUri", ""), JavaOnlyMap.of("url", "https://example.com/b", "label", "", "iconUri", "pill_file"), + JavaOnlyMap.of("url", "user:c", "label", "", "iconUri", "", "iconTintColor", Color.BLUE.toDouble()), + JavaOnlyMap.of("url", "user:d", "label", "", "iconUri", "", "iconTintColor", 0.0), ), ) assertEquals( mapOf( "https://example.com/a" to LinkPillContent("A", ""), "https://example.com/b" to LinkPillContent("", "pill_file"), + "user:c" to LinkPillContent(iconTintColor = Color.BLUE), + // A transparent tint is a tint; only a missing one means "not set". + "user:d" to LinkPillContent(iconTintColor = Color.TRANSPARENT), ), parsed, ) @@ -923,6 +928,41 @@ class LinkPillSpanTest { } } + @Test + fun variantTintLeavesPerLinkIconsAloneAndAPerLinkTintWins() { + val context = RuntimeEnvironment.getApplication() + val variantIcon = File.createTempFile("enriched-glyph", ".png") + val linkIcon = File.createTempFile("enriched-avatar", ".png") + try { + writeIcon(variantIcon) + writeIcon(linkIcon) + val variant = + pillStyle { copy(iconUri = variantIcon.toURI().toString(), iconTintColor = Color.BLUE, paddingHorizontal = 0f, borderWidth = 0f) } + // A black label, so only the icon can produce the colors checked below. + .copy(color = Color.BLACK, backgroundColor = Color.TRANSPARENT) + + fun colorsWith(content: LinkPillContent?): Set { + val bitmap = Bitmap.createBitmap(120, 40, Bitmap.Config.ARGB_8888) + LinkPillSpan(variant, Typeface.DEFAULT, 16f, original, context, content) + .draw(Canvas(bitmap), original, 0, original.length, 0f, 0, 24, 40, paint) + return pixels(bitmap).filter { it == Color.RED || it == Color.GREEN || it == Color.BLUE || it == Color.MAGENTA }.toSet() + } + + assertEquals("The variant's own icon takes the variant tint", setOf(Color.BLUE), colorsWith(null)) + val avatar = linkIcon.toURI().toString() + assertEquals("A per-link icon keeps its colors", setOf(Color.RED, Color.GREEN), colorsWith(LinkPillContent(iconUri = avatar))) + assertEquals(setOf(Color.MAGENTA), colorsWith(LinkPillContent(iconUri = avatar, iconTintColor = Color.MAGENTA))) + assertEquals( + "A per-link tint without an icon recolors the variant's icon for that link", + setOf(Color.MAGENTA), + colorsWith(LinkPillContent(iconTintColor = Color.MAGENTA)), + ) + } finally { + variantIcon.delete() + linkIcon.delete() + } + } + @Test fun tintTransportDistinguishesMissingAndTransparentAndParticipatesInEquality() { val parser = StyleParser(RuntimeEnvironment.getApplication(), false, 1f) diff --git a/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.h b/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.h index 15415489c..759fe03bd 100644 --- a/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.h +++ b/packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.h @@ -21,11 +21,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 3c74735b1..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,9 +248,8 @@ - (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); - UIImage *presented = _pill.iconTintColor ? [_icon imageWithTintColor:_pill.iconTintColor - renderingMode:UIImageRenderingModeAlwaysOriginal] - : _icon; + 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 4533f174e..6550a7407 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]]; diff --git a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h index e3b6710b6..c5101d31f 100644 --- a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h +++ b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h @@ -21,6 +21,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/src/EnrichedMarkdownNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts index 177270d3b..359e912cd 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts @@ -321,6 +321,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 c237292b8..bf96814c6 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts @@ -322,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/linkVariantUtils.ts b/packages/react-native-enriched-markdown/src/linkVariantUtils.ts index 94be84444..af2901c9e 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]; @@ -71,10 +72,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 +95,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/types/MarkdownStyle.ts b/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts index 0ca3aa1e5..d39b03a4e 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts @@ -132,7 +132,10 @@ export interface LinkPillStyle { label?: string; /** Local file, bundled asset or `http(s)` URL. A source that fails shows no icon. */ iconUri?: string; - /** Tints the icon, keeping its alpha. Omit to keep the image's own colors. */ + /** + * 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; diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts index 312a4009b..1707286b9 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; } /**