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/.playwright/tests/enrichedTextPress.spec.ts b/.playwright/tests/enrichedTextPress.spec.ts index 466cd42da..079930022 100644 --- a/.playwright/tests/enrichedTextPress.spec.ts +++ b/.playwright/tests/enrichedTextPress.spec.ts @@ -2,6 +2,15 @@ import { test, expect, type Page } from '@playwright/test'; test.setTimeout(90_000); +const IMAGE_ROUTE = '**/pw-e2e-ok.png'; +const IMAGE_SRC = '/pw-e2e-ok.png'; +const BROKEN_IMAGE_ROUTE = '**/pw-e2e-broken.png'; +const BROKEN_IMAGE_SRC = '/pw-e2e-broken.png'; +const PNG_BODY = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +); + const sel = { root: '[data-testid="test-enriched-text-root"]', htmlInput: '[data-testid="test-enriched-text-html-input"]', @@ -11,8 +20,22 @@ const sel = { displayInner: '[data-testid="test-enriched-text-display"] .et-view', linkPressOutput: '[data-testid="test-enriched-text-link-press-output"]', mentionPressOutput: '[data-testid="test-enriched-text-mention-press-output"]', + imagePressOutput: '[data-testid="test-enriched-text-image-press-output"]', } as const; +const VISIBILITY_TIMEOUT_MS = 1_000; + +test.beforeEach(async ({ page }) => { + await page.route(IMAGE_ROUTE, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/png', + body: PNG_BODY, + }); + }); + await page.route(BROKEN_IMAGE_ROUTE, (route) => route.abort()); +}); + async function gotoTestEnrichedText(page: Page): Promise { await page.goto('/test-enriched-text'); await page.waitForSelector(sel.displayInner); @@ -35,6 +58,10 @@ function mentionPress(page: Page) { return page.locator(sel.mentionPressOutput); } +function imagePress(page: Page) { + return page.locator(sel.imagePressOutput); +} + test.describe('EnrichedText link press', () => { test('pressing a link emits onLinkPress with the url', async ({ page }) => { await gotoTestEnrichedText(page); @@ -143,6 +170,102 @@ test.describe('EnrichedText mention press', () => { }); }); +test.describe('EnrichedText image press', () => { + test('pressing an image emits onImagePress with the image', async ({ + page, + }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + `

Look now.

` + ); + + await expect(imagePress(page)).toHaveText('null'); + + await page.locator(`${sel.displayInner} img`).click(); + + await expect + .poll(async () => + JSON.parse((await imagePress(page).textContent()) || 'null') + ) + .toEqual({ image: { uri: IMAGE_SRC, width: 32, height: 32 } }); + }); + + test('pressing an image surrounded by styled text still emits onImagePress', async ({ + page, + }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + `

A bold here.

` + ); + + await page.locator(`${sel.displayInner} img`).click(); + + await expect + .poll(async () => + JSON.parse((await imagePress(page).textContent()) || 'null') + ) + .toEqual({ image: { uri: IMAGE_SRC, width: 120, height: 60 } }); + }); + + test('pressing non-image text does not emit onImagePress', async ({ + page, + }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + `

Plain text with an .

` + ); + + await page + .locator(`${sel.displayInner} p`) + .click({ position: { x: 2, y: 2 } }); + + await expect(imagePress(page)).toHaveText('null'); + }); + + test('pressing an empty-src placeholder does not emit onImagePress', async ({ + page, + }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + '

Before after.

' + ); + + await expect(page.locator(`${sel.displayInner} img`)).toBeVisible({ + timeout: VISIBILITY_TIMEOUT_MS, + }); + + await page.locator(`${sel.displayInner} img`).click(); + + await expect(imagePress(page)).toHaveText('null'); + }); + + test('pressing a broken-URL placeholder does emit onImagePress', async ({ + page, + }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + `

Before after.

` + ); + + await expect(page.locator(`${sel.displayInner} img`)).toBeVisible({ + timeout: VISIBILITY_TIMEOUT_MS, + }); + + await page.locator(`${sel.displayInner} img`).click(); + + await expect + .poll(async () => + JSON.parse((await imagePress(page).textContent()) || 'null') + ) + .toEqual({ image: { uri: BROKEN_IMAGE_SRC, width: 40, height: 40 } }); + }); +}); + test.describe('EnrichedText press inside lists', () => { const listCases = [ { @@ -206,5 +329,25 @@ test.describe('EnrichedText press inside lists', () => { attributes: { id: '1' }, }); }); + + test(`pressing an image inside a ${c.name} emits onImagePress`, async ({ + page, + }) => { + await gotoTestEnrichedText(page); + await setEnrichedTextValue( + page, + `${c.open}
  • See tiny

  • Other item

  • ${c.close}` + ); + + await expect(imagePress(page)).toHaveText('null'); + + await page.locator(`${sel.displayInner} li img`).click(); + + await expect + .poll(async () => + JSON.parse((await imagePress(page).textContent()) || 'null') + ) + .toEqual({ image: { uri: IMAGE_SRC, width: 28, height: 28 } }); + }); } }); 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-web/src/components/TextRenderer.tsx b/apps/example-web/src/components/TextRenderer.tsx index 552f16d6b..c8ba51d45 100644 --- a/apps/example-web/src/components/TextRenderer.tsx +++ b/apps/example-web/src/components/TextRenderer.tsx @@ -5,6 +5,7 @@ import { type BlurEvent, type EnrichedTextInstance, type FocusEvent, + type OnImagePressEvent, type OnLinkPressEvent, type OnMentionPressEvent, } from 'react-native-enriched-html'; @@ -33,6 +34,10 @@ export function TextRenderer({ htmlValue }: TextRendererProps) { console.log('[EnrichedText] mention press event', e); }; + const handleImagePress = (e: OnImagePressEvent) => { + console.log('[EnrichedText] image press event', e); + }; + return (

    Enriched Text

    @@ -44,6 +49,7 @@ export function TextRenderer({ htmlValue }: TextRendererProps) { onBlur={handleTextBlur} onLinkPress={handleLinkPress} onMentionPress={handleMentionPress} + onImagePress={handleImagePress} > {htmlValue} diff --git a/apps/example-web/src/testScreens/TestEnrichedText.tsx b/apps/example-web/src/testScreens/TestEnrichedText.tsx index 136b2890c..c039e9fd9 100644 --- a/apps/example-web/src/testScreens/TestEnrichedText.tsx +++ b/apps/example-web/src/testScreens/TestEnrichedText.tsx @@ -1,6 +1,7 @@ import { useState, type ChangeEvent } from 'react'; import { EnrichedText, + type OnImagePressEvent, type OnLinkPressEvent, type OnMentionPressEvent, } from 'react-native-enriched-html'; @@ -12,6 +13,8 @@ const INITIAL_VALUE = '

    '; export function TestEnrichedText() { const [htmlInput, setHtmlInput] = useState(INITIAL_VALUE); const [value, setValue] = useState(INITIAL_VALUE); + const [lastImagePress, setLastImagePress] = + useState(null); const [lastLinkPress, setLastLinkPress] = useState( null ); @@ -30,6 +33,7 @@ export function TestEnrichedText() { htmlStyle={WEB_DEFAULT_HTML_STYLE} onLinkPress={setLastLinkPress} onMentionPress={setLastMentionPress} + onImagePress={setLastImagePress} > {value} @@ -65,6 +69,9 @@ export function TestEnrichedText() {
    {value}
    +
    +        {JSON.stringify(lastImagePress)}
    +      
             {JSON.stringify(lastLinkPress)}
           
    diff --git a/apps/example/src/components/TextRenderer.tsx b/apps/example/src/components/TextRenderer.tsx index 6fe302c46..9cf55afc7 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} > {node} diff --git a/apps/example/src/screens/EnrichedTextScreen.tsx b/apps/example/src/screens/EnrichedTextScreen.tsx index de482f1bb..93947ba3d 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/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp index 0a978260a..9fbc6f358 100644 --- a/cpp/tests/GumboParserTest.cpp +++ b/cpp/tests/GumboParserTest.cpp @@ -581,9 +581,10 @@ TEST(GumboParserTest, InterBlockWhitespace) { EXPECT_EQ(GumboParser::normalizeHtml( "

    Asdasd

    \n\n

    Asdasd

    \n\n

    Asdasda

    "), "

    Asdasd

    Asdasd

    Asdasda

    "); - EXPECT_EQ(GumboParser::normalizeHtml( - "\n

    Asdasd

    \n

    Asdasd

    \n

    Asdasda

    \n"), - "

    Asdasd

    Asdasd

    Asdasda

    "); + EXPECT_EQ( + GumboParser::normalizeHtml( + "\n

    Asdasd

    \n

    Asdasd

    \n

    Asdasda

    \n"), + "

    Asdasd

    Asdasd

    Asdasda

    "); EXPECT_EQ(GumboParser::normalizeHtml("

    Asdasd

    Asdasd

    "), "

    Asdasd

    Asdasd

    "); diff --git a/docs/TEXT_API_REFERENCE.md b/docs/TEXT_API_REFERENCE.md index af7f375df..7a456cecd 100644 --- a/docs/TEXT_API_REFERENCE.md +++ b/docs/TEXT_API_REFERENCE.md @@ -114,6 +114,27 @@ interface OnMentionPressEvent { | -------------------------------------- | ------------- | ----------------- | | `(event: OnMentionPressEvent) => void` | - | iOS, Android, Web | +### `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, Web | + +> [!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/docs/WEB.md b/docs/WEB.md index 382d26f48..1ef1a8177 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -41,7 +41,7 @@ See [Web Keyboard Shortcuts](./INPUT_API_REFERENCE.md#web-keyboard-shortcuts) fo - Customizing the styling using props: `style`, `htmlStyle`, `selectionColor`. - `selectable` prop - `useHtmlNormalizer` -- `onLinkPress` and `onMentionPress` callbacks +- `onLinkPress`, `onMentionPress` and `onImagePress` callbacks ### Unsupported 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 02d34b743..6ccbe4fa1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -23,4 +23,5 @@ export type { EnrichedTextHtmlStyle, OnMentionPressEvent, OnLinkPressEvent, + OnImagePressEvent, } from './types'; diff --git a/src/native/EnrichedText.tsx b/src/native/EnrichedText.tsx index f2a9f952c..2a1ba0198 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 acda85b41..814225df8 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; + }; +} diff --git a/src/web/EnrichedText.css b/src/web/EnrichedText.css index 706b8a02e..228177fda 100644 --- a/src/web/EnrichedText.css +++ b/src/web/EnrichedText.css @@ -320,6 +320,7 @@ width: var(--et-checkbox-box-size, 24px); height: var(--et-checkbox-box-size, 24px); + pointer-events: none; accent-color: var(--et-checkbox-box-color, #0000ff); /* disable checkbox press style as it's read-only */ diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index 63141b0d7..fb7ff9c3e 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -34,6 +34,7 @@ export const EnrichedText = memo( onBlur, onLinkPress, onMentionPress, + onImagePress, }: EnrichedTextProps) => { const containerRef = useRef(null); @@ -97,9 +98,15 @@ export const EnrichedText = memo( const onLinkPressRef = useStableRef(onLinkPress); const onMentionPressRef = useStableRef(onMentionPress); + const onImagePressRef = useStableRef(onImagePress); useImageErrorFallback(containerRef); - usePressInteractions(containerRef, onLinkPressRef, onMentionPressRef); + usePressInteractions( + containerRef, + onLinkPressRef, + onMentionPressRef, + onImagePressRef + ); return ( <> diff --git a/src/web/usePressInteractions.ts b/src/web/usePressInteractions.ts index 9768b442e..0856c5c16 100644 --- a/src/web/usePressInteractions.ts +++ b/src/web/usePressInteractions.ts @@ -1,5 +1,9 @@ import { useEffect, type RefObject } from 'react'; -import type { OnLinkPressEvent, OnMentionPressEvent } from '../types'; +import type { + OnLinkPressEvent, + OnMentionPressEvent, + OnImagePressEvent, +} from '../types'; type OnLinkPressEventRef = RefObject< ((event: OnLinkPressEvent) => void) | undefined @@ -9,10 +13,21 @@ type OnMentionPressEventRef = RefObject< ((event: OnMentionPressEvent) => void) | undefined >; +type OnImagePressEventRef = RefObject< + ((event: OnImagePressEvent) => void) | undefined +>; + +type ImageAttributes = { + uri: string; + width: number; + height: number; +}; + export function usePressInteractions( containerRef: RefObject, onLinkPressRef: OnLinkPressEventRef, - onMentionPressRef: OnMentionPressEventRef + onMentionPressRef: OnMentionPressEventRef, + onImagePressRef: OnImagePressEventRef ) { useEffect(() => { const container = containerRef.current; @@ -21,6 +36,19 @@ export function usePressInteractions( const handleInteraction = (e: MouseEvent) => { const target = e.target as HTMLElement; + const image = target.closest('img'); + if (image && container.contains(image)) { + e.preventDefault(); + + const imageAttributes = getImageAttributes(image); + + if (imageAttributes) { + onImagePressRef.current?.({ + image: imageAttributes, + }); + } + } + const checkbox = target.closest("input[type='checkbox']"); if (checkbox && container.contains(checkbox)) { e.preventDefault(); @@ -56,5 +84,44 @@ export function usePressInteractions( container.addEventListener('click', handleInteraction); return () => container.removeEventListener('click', handleInteraction); - }, [containerRef, onLinkPressRef, onMentionPressRef]); + }, [containerRef, onLinkPressRef, onMentionPressRef, onImagePressRef]); +} + +function getImageAttributes( + image: HTMLImageElement +): ImageAttributes | undefined { + const uri = image.getAttribute('src'); + + if (!uri) { + return undefined; + } + + const { width, height } = getImageDimensions(image); + + return { + uri, + width, + height, + }; +} + +function getImageDimensions(image: HTMLImageElement): { + width: number; + height: number; +} { + const rawWidth = image.getAttribute('width'); + const rawHeight = image.getAttribute('height'); + + if (rawWidth && rawHeight) { + const width = parseInt(rawWidth, 10); + const height = parseInt(rawHeight, 10); + + if (!isNaN(width) && !isNaN(height)) { + return { width, height }; + } + } + + const rect = image.getBoundingClientRect(); + + return { width: rect.width, height: rect.height }; }