diff --git a/.maestro/enrichedText/flows/image_press.yaml b/.maestro/enrichedText/flows/image_press.yaml new file mode 100644 index 000000000..6cbf1a3d9 --- /dev/null +++ b/.maestro/enrichedText/flows/image_press.yaml @@ -0,0 +1,24 @@ +appId: swmansion.enriched.example +--- +# Validates that image press events are triggered correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: '' + +- tapOn: + id: 'enriched-text' + point: '5%,50%' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'image_press' diff --git a/.maestro/enrichedText/screenshots/android/image_press.png b/.maestro/enrichedText/screenshots/android/image_press.png new file mode 100644 index 000000000..a574ba4ac Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/image_press.png differ diff --git a/.maestro/enrichedText/screenshots/ios/image_press.png b/.maestro/enrichedText/screenshots/ios/image_press.png new file mode 100644 index 000000000..32a6b570c Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/image_press.png differ diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextViewManager.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextViewManager.kt index 62f91256c..042b278f5 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextViewManager.kt @@ -9,6 +9,7 @@ import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.viewmanagers.EnrichedTextViewManagerDelegate import com.facebook.react.viewmanagers.EnrichedTextViewManagerInterface import com.facebook.yoga.YogaMeasureMode +import com.swmansion.enriched.text.events.OnImagePressEvent import com.swmansion.enriched.text.events.OnLinkPressEvent import com.swmansion.enriched.text.events.OnMentionPressEvent @@ -29,6 +30,7 @@ class EnrichedTextViewManager : val map = mutableMapOf() map.put(OnLinkPressEvent.EVENT_NAME, mapOf("registrationName" to OnLinkPressEvent.EVENT_NAME)) map.put(OnMentionPressEvent.EVENT_NAME, mapOf("registrationName" to OnMentionPressEvent.EVENT_NAME)) + map.put(OnImagePressEvent.EVENT_NAME, mapOf("registrationName" to OnImagePressEvent.EVENT_NAME)) return map } diff --git a/android/src/main/java/com/swmansion/enriched/text/events/OnImagePressEvent.kt b/android/src/main/java/com/swmansion/enriched/text/events/OnImagePressEvent.kt new file mode 100644 index 000000000..c694b4acc --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/events/OnImagePressEvent.kt @@ -0,0 +1,29 @@ +package com.swmansion.enriched.text.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +class OnImagePressEvent( + surfaceId: Int, + viewId: Int, + private val uri: String, + private val width: Double, + private val height: Double, +) : Event(surfaceId, viewId) { + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap? { + val eventData: WritableMap = Arguments.createMap() + val imageMap = Arguments.createMap() + imageMap.putString("uri", uri) + imageMap.putDouble("width", width) + imageMap.putDouble("height", height) + eventData.putMap("image", imageMap) + return eventData + } + + companion object { + const val EVENT_NAME: String = "onImagePress" + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextImageSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextImageSpan.kt index 21f2c9fe3..5f26af56f 100644 --- a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextImageSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextImageSpan.kt @@ -3,11 +3,16 @@ package com.swmansion.enriched.text.spans import android.graphics.drawable.Drawable import android.os.Handler import android.os.Looper +import android.view.View +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerHelper import com.swmansion.enriched.R import com.swmansion.enriched.common.AsyncDrawable import com.swmansion.enriched.common.ResourceManager import com.swmansion.enriched.common.spans.EnrichedImageSpan import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.events.OnImagePressEvent +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextClickableSpan import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan class EnrichedTextImageSpan( @@ -16,11 +21,28 @@ class EnrichedTextImageSpan( width: Int, height: Int, ) : EnrichedImageSpan(drawable, source, width, height), - EnrichedTextSpan { + EnrichedTextSpan, + EnrichedTextClickableSpan { override val dependsOnHtmlStyle = false + override var isPressed = false override fun rebuildWithStyle(style: EnrichedTextStyle) = this + override fun onClick(view: View) { + val context = view.context as? ReactContext ?: return + val surfaceId = UIManagerHelper.getSurfaceId(context) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) + dispatcher?.dispatchEvent( + OnImagePressEvent( + surfaceId, + view.id, + source ?: "", + getWidth().toDouble(), + getHeight().toDouble(), + ), + ) + } + // We use a callback to trigger a layout rebuild because ForceRedrawSpan/SpanWatcher // (used in EnrichedImageSpan) doesn’t work in EnrichedTextView with SpannedString. fun observeAsyncDrawableLoaded(onLoaded: () -> Unit) { diff --git a/apps/example/src/components/TextRenderer.tsx b/apps/example/src/components/TextRenderer.tsx index a8a3e4b9c..38a67b347 100644 --- a/apps/example/src/components/TextRenderer.tsx +++ b/apps/example/src/components/TextRenderer.tsx @@ -3,6 +3,7 @@ import { EnrichedText, type OnLinkPressEvent, type OnMentionPressEvent, + type OnImagePressEvent, } from 'react-native-enriched-html'; import { enrichedTextHtmlStyle } from '../constants/editorConfig'; @@ -22,6 +23,13 @@ export const TextRenderer = ({ nodes }: TextRendererProps) => { ); }; + const handleImagePress = (e: OnImagePressEvent) => { + Alert.alert( + 'Image Pressed', + `You pressed the image: ${JSON.stringify(e.image)}` + ); + }; + if (nodes.length === 0) { return null; } @@ -35,6 +43,7 @@ export const TextRenderer = ({ nodes }: TextRendererProps) => { htmlStyle={enrichedTextHtmlStyle} onLinkPress={handleLinkPress} onMentionPress={handleMentionPress} + onImagePress={handleImagePress} useHtmlNormalizer > {node} diff --git a/apps/example/src/screens/EnrichedTextScreen.tsx b/apps/example/src/screens/EnrichedTextScreen.tsx index fa8cdf62e..c61b8321a 100644 --- a/apps/example/src/screens/EnrichedTextScreen.tsx +++ b/apps/example/src/screens/EnrichedTextScreen.tsx @@ -3,6 +3,7 @@ import { View, StyleSheet, ScrollView, Text } from 'react-native'; import { EnrichedText, type EnrichedTextProps, + type OnImagePressEvent, type OnLinkPressEvent, type OnMentionPressEvent, } from 'react-native-enriched-html'; @@ -37,6 +38,10 @@ export function EnrichedTextScreen({ onSwitch }: EnrichedTextScreenProps) { ); }; + const handleImagePress = (e: OnImagePressEvent) => { + setHtml(`You pressed the image: ${JSON.stringify(e.image)}`); + }; + return ( <> {html} diff --git a/docs/TEXT_API_REFERENCE.md b/docs/TEXT_API_REFERENCE.md index 145ddd237..b9d6320e8 100644 --- a/docs/TEXT_API_REFERENCE.md +++ b/docs/TEXT_API_REFERENCE.md @@ -114,6 +114,27 @@ interface OnMentionPressEvent { | -------------------------------------- | ------------- | ------------ | | `(event: OnMentionPressEvent) => void` | - | iOS, Android | +### `onImagePress` + +Called when the user presses an inline image. Receives an `OnImagePressEvent` with the image uri and dimensions. + +```ts +interface OnImagePressEvent { + image: { + uri: string; + width: number; + height: number; + }; +} +``` + +| Type | Default Value | Platform | +| ------------------------------------ | ------------- | ------------ | +| `(event: OnImagePressEvent) => void` | - | iOS, Android | + +> [!NOTE] +> No visual feedback is applied on press. + ## EnrichedTextHtmlStyle type Extends [`HtmlStyle`](API_REFERENCE.md#htmlstyle-type) with additional press-state styling for interactive elements. All properties from `HtmlStyle` are supported except `a` and `mention`, which are replaced by the extended versions below. diff --git a/ios/EnrichedTextView.h b/ios/EnrichedTextView.h index e0af096f7..cd5d47243 100644 --- a/ios/EnrichedTextView.h +++ b/ios/EnrichedTextView.h @@ -28,6 +28,7 @@ NS_ASSUME_NONNULL_BEGIN - (CGSize)measureSize:(CGFloat)maxWidth; - (void)emitOnLinkPressEvent:(NSString *)url; - (void)emitOnMentionPressEvent:(MentionParams *)mention; +- (void)emitOnImagePressEvent:(MediaAttachment *)attachment; @end NS_ASSUME_NONNULL_END diff --git a/ios/EnrichedTextView.mm b/ios/EnrichedTextView.mm index 16ef47083..75e7ded9a 100644 --- a/ios/EnrichedTextView.mm +++ b/ios/EnrichedTextView.mm @@ -785,6 +785,21 @@ - (void)emitOnMentionPressEvent:(MentionParams *)mention { } } +- (void)emitOnImagePressEvent:(MediaAttachment *)attachment { + if (!attachment) + return; + auto emitter = [self getEventEmitter]; + if (emitter != nullptr) { + emitter->onImagePress( + {.image = { + .uri = + attachment.uri ? [attachment.uri toCppString] : std::string{}, + .width = attachment.width, + .height = attachment.height, + }}); + } +} + // MARK: - Media attachments delegate - (void)mediaAttachmentDidUpdate:(MediaAttachment *)attachment { diff --git a/ios/utils/EnrichedTextTouchHandler.mm b/ios/utils/EnrichedTextTouchHandler.mm index 255daef6d..48665320c 100644 --- a/ios/utils/EnrichedTextTouchHandler.mm +++ b/ios/utils/EnrichedTextTouchHandler.mm @@ -1,6 +1,7 @@ #import "EnrichedTextTouchHandler.h" #import "ColorExtension.h" #import "EnrichedTextView.h" +#import "ImageAttachment.h" #import "LinkData.h" #import "MentionParams.h" #import "MentionStyleProps.h" @@ -78,8 +79,10 @@ - (id)findClickableAt:(NSUInteger)idx if (idx == NSNotFound || idx >= self.view.textView.textStorage.length) return nil; - NSArray *keys = - @[ @"EnrichedManualLink", @"EnrichedAutomaticLink", @"EnrichedMention" ]; + NSArray *keys = @[ + @"EnrichedManualLink", @"EnrichedAutomaticLink", @"EnrichedMention", + @"EnrichedImage" + ]; for (NSString *k in keys) { id val = [self.view.textView.textStorage attribute:k atIndex:idx @@ -93,6 +96,10 @@ - (id)findClickableAt:(NSUInteger)idx } - (void)updateVisualsPressed:(BOOL)pressed { + if ([_activeAttrKey isEqualToString:@"EnrichedImage"]) { + return; + } + if (pressed) { UIColor *color = nil; UIColor *bgColor = nil; @@ -141,6 +148,15 @@ - (void)dispatchEvent { [self.view emitOnLinkPressEvent:((LinkData *)_activeValue).url]; } else if ([_activeAttrKey isEqualToString:@"EnrichedMention"]) { [self.view emitOnMentionPressEvent:(MentionParams *)_activeValue]; + } else if ([_activeAttrKey isEqualToString:@"EnrichedImage"]) { + NSRange attachmentRange = _activeRange; + ImageAttachment *attachment = + [self.view.textView.textStorage attribute:NSAttachmentAttributeName + atIndex:attachmentRange.location + effectiveRange:NULL]; + if ([attachment isKindOfClass:[ImageAttachment class]]) { + [self.view emitOnImagePressEvent:attachment]; + } } } diff --git a/src/index.native.tsx b/src/index.native.tsx index 82d450b16..8f7473c7a 100644 --- a/src/index.native.tsx +++ b/src/index.native.tsx @@ -31,4 +31,5 @@ export type { EnrichedTextHtmlStyle, OnMentionPressEvent, OnLinkPressEvent, + OnImagePressEvent, } from './types'; diff --git a/src/index.tsx b/src/index.tsx index 59d9054fa..24108e2a4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -22,4 +22,5 @@ export type { EnrichedTextHtmlStyle, OnMentionPressEvent, OnLinkPressEvent, + OnImagePressEvent, } from './types'; diff --git a/src/native/EnrichedText.tsx b/src/native/EnrichedText.tsx index ec24debfd..a236b8a9a 100644 --- a/src/native/EnrichedText.tsx +++ b/src/native/EnrichedText.tsx @@ -16,6 +16,7 @@ import EnrichedTextNativeComponent, { type NativeProps, type OnLinkPressEvent, type OnMentionPressEventInternal, + type OnImagePressEvent, } from '../spec/EnrichedTextNativeComponent'; import { nullthrows } from '../utils/nullthrows'; import { normalizeEnrichedTextHtmlStyle } from '../utils/normalizeHtmlStyle'; @@ -37,6 +38,7 @@ export const EnrichedText = ({ allowFontScaling = true, onLinkPress: _onLinkPress, onMentionPress: _onMentionPress, + onImagePress: _onImagePress, ...rest }: EnrichedTextProps) => { const nativeRef = useRef(null); @@ -66,6 +68,13 @@ export const EnrichedText = ({ [_onMentionPress] ); + const onImagePress: DirectEventHandler = useCallback( + (e) => { + _onImagePress?.(e.nativeEvent); + }, + [_onImagePress] + ); + useImperativeHandle(ref, () => ({ measureInWindow: (callback: MeasureInWindowOnSuccessCallback) => { nullthrows(nativeRef.current).measureInWindow(callback); @@ -109,6 +118,7 @@ export const EnrichedText = ({ allowFontScaling={allowFontScaling} onLinkPress={onLinkPress} onMentionPress={onMentionPress} + onImagePress={onImagePress} {...rest} /> ); diff --git a/src/spec/EnrichedTextNativeComponent.ts b/src/spec/EnrichedTextNativeComponent.ts index fec62bd3d..790165b97 100644 --- a/src/spec/EnrichedTextNativeComponent.ts +++ b/src/spec/EnrichedTextNativeComponent.ts @@ -79,6 +79,14 @@ export interface OnMentionPressEvent { attributes: Record; } +export interface OnImagePressEvent { + image: { + uri: string; + width: Float; + height: Float; + }; +} + export interface NativeProps extends ViewProps { // Custom props text: string; @@ -95,6 +103,7 @@ export interface NativeProps extends ViewProps { // Events onLinkPress?: DirectEventHandler; onMentionPress?: DirectEventHandler; + onImagePress?: DirectEventHandler; // Style related props - used for generating proper setters in component's manager // These should not be passed as regular props diff --git a/src/types.ts b/src/types.ts index 90282deff..6c0155f94 100644 --- a/src/types.ts +++ b/src/types.ts @@ -836,6 +836,9 @@ export interface EnrichedTextProps extends ViewProps { /** Called when the user taps a mention node inside the rendered content. */ onMentionPress?: (event: OnMentionPressEvent) => void; + + /** Called when the user taps an inline image inside the rendered content. */ + onImagePress?: (event: OnImagePressEvent) => void; } export interface EnrichedTextMentionStyleProperties extends MentionStyleProperties { @@ -864,3 +867,11 @@ export interface OnMentionPressEvent { indicator: string; attributes: Record; } + +export interface OnImagePressEvent { + image: { + uri: string; + width: number; + height: number; + }; +}