Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { Image } from 'react-native';
import { EnrichedMarkdownTextStory } from '../EnrichedMarkdownTextStory';
import { storyMeta } from '../shared/storyMeta';
import {
Expand Down Expand Up @@ -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': {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -237,9 +248,9 @@ export const Pills: TextStory<LinkPillControls> = {
return (
<EnrichedMarkdownTextStory
title="Link Pills"
description="linkVariants[pattern].pill presents matching links as pills (iOS and Android). linkPillContent gives one exact URL its own label: the Button.tsx link uses it, README.md shows its link text, and the unmatched reactnative.dev link stays an ordinary link."
description="linkVariants[pattern].pill presents matching links as pills (iOS and Android). linkPillContent gives one exact URL its own label: the Button.tsx link uses it, README.md shows its link text, and the unmatched reactnative.dev link stays an ordinary link. pill.iconTintColor recolors the variant's icon and keeps its alpha."
{...rest}
style={toLinkPillStyle(controls)}
style={toLinkPillStyle(controls, PILL_ICON_URI)}
/>
);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,8 @@ export type LinkPillControls = {
paddingVertical: number;
maxWidth: number;
variantLabel: string;
tintIcon: boolean;
iconTintColor: string;
};

export const linkPillDefaults: LinkPillControls = {
Expand All @@ -429,6 +431,8 @@ export const linkPillDefaults: LinkPillControls = {
paddingVertical: 2,
maxWidth: 0,
variantLabel: '',
tintIcon: true,
iconTintColor: '#3730A3',
};

export type InlineCodeStyleControls = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,8 @@ export function toLinkVariantsDemoStyle(
}

export function toLinkPillStyle(
controls: LinkPillControls
controls: LinkPillControls,
iconUri?: string
): Pick<MarkdownStyle, 'linkVariants'> {
return {
linkVariants: {
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions docs-md/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { label?: string; iconUri?: string }>` | - | iOS, Android |
| Type | Default Value | Platform |
| ------------------------------------------------------------------------------ | ------------- | ------------ |
| `Record<string, { label?: string; iconUri?: string; iconTintColor?: string }>` | - | 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.

Expand Down
5 changes: 4 additions & 1 deletion docs-md/MENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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
Expand Down

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd assume we will have the same issue as I flagged in previous pr

Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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(
Expand All @@ -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()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSString *, NSString *> *)requestHeaders;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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

Expand Down Expand Up @@ -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;
Expand All @@ -104,13 +108,15 @@ - (instancetype)initWithOriginalText:(NSAttributedString *)originalText
variant:(LinkVariantConfig *)variant
label:(NSString *)label
iconUri:(NSString *)iconUri
iconTintColor:(UIColor *)iconTintColor
font:(UIFont *)font
requestHeaders:(NSDictionary<NSString *, NSString *> *)requestHeaders
{
self = [super initWithData:nil ofType:nil];
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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSAttributedStringKey, id> *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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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

/**
Expand Down
Loading
Loading