From 946c613223f489ece345576adfe906d92031d1a8 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 10 Jul 2026 11:21:40 +0200 Subject: [PATCH 1/4] feat: onImagePress --- apps/example-web/src/App.tsx | 6 ++++ src/web/EnrichedText.tsx | 13 +++++-- src/web/usePressInteractions.ts | 64 +++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 src/web/usePressInteractions.ts diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 8fa6e035..dade5ddd 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -15,6 +15,7 @@ import { type OnChangeMentionEvent, type OnMentionDetected, EnrichedText, + type OnImagePressEvent, } from 'react-native-enriched-html'; import { WEB_DEFAULT_HTML_STYLE } from './defaultHtmlStyle'; import type { NativeSyntheticEvent, TextStyle } from 'react-native'; @@ -173,6 +174,10 @@ function App() { setSelection(e.nativeEvent); }; + const handleImagePress = (e: OnImagePressEvent) => { + console.log('[EnrichedText] image press event', e); + }; + const openLinkModal = () => { setIsLinkModalOpen(true); }; @@ -337,6 +342,7 @@ function App() { {enrichedTextValue} diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index c26efb58..67c16e6d 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -14,9 +14,16 @@ import { prepareHtmlForWeb } from './normalization/prepareHtmlForWeb'; import { INLINE_IMAGE_CSS_VARIABLES } from './styleConversion/inlineImageCSSVariables'; import { useImageErrorFallback } from './useImageErrorFallback'; import { usePressInteractions } from './usePressInteractions'; +import { useStableRef } from './useStableRef'; export const EnrichedText = memo( - ({ children, htmlStyle, style, selectionColor }: EnrichedTextProps) => { + ({ + children, + htmlStyle, + style, + selectionColor, + onImagePress, + }: EnrichedTextProps) => { const containerRef = useRef(null); const sanitizedHtml = useMemo(() => sanitizeHtml(children), [children]); @@ -64,7 +71,9 @@ export const EnrichedText = memo( [textStyle, themingStyle, cssVars] ); - usePressInteractions(containerRef); + const onImagePressRef = useStableRef(onImagePress); + + usePressInteractions(containerRef, onImagePressRef); useImageErrorFallback(containerRef); return ( diff --git a/src/web/usePressInteractions.ts b/src/web/usePressInteractions.ts new file mode 100644 index 00000000..94fb550d --- /dev/null +++ b/src/web/usePressInteractions.ts @@ -0,0 +1,64 @@ +import { useEffect, type RefObject } from 'react'; +import type { OnImagePressEvent } from '../types'; + +type OnImagePressEventRef = RefObject< + ((event: OnImagePressEvent) => void) | undefined +>; + +type ImageAttributes = { + uri: string; + width: number; + height: number; +}; + +export function usePressInteractions( + containerRef: RefObject, + onImagePressRef: OnImagePressEventRef +) { + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleInteraction = (e: MouseEvent) => { + const target = e.target as HTMLElement; + + const image = target.closest('img'); + + if (image && container.contains(image)) { + e.preventDefault(); + + const imageAttributes = parseImageAttributs(image); + + if (imageAttributes) { + onImagePressRef.current?.({ + image: imageAttributes, + }); + } + } + }; + + container.addEventListener('click', handleInteraction); + return () => container.removeEventListener('click', handleInteraction); + }, [containerRef, onImagePressRef]); +} + +function parseImageAttributs(image: HTMLElement): ImageAttributes | undefined { + const uri = image.getAttribute('src'); + const rawWidth = image.getAttribute('width'); + const rawHeight = image.getAttribute('height'); + + if (uri && rawWidth && rawHeight) { + const width = parseInt(rawWidth, 10); + const height = parseInt(rawHeight, 10); + + if (!isNaN(width) && !isNaN(height)) { + return { + uri, + width, + height, + }; + } + } + + return undefined; +} From 7b614da186869fd540288e39954156aa6ef6520c Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 10 Jul 2026 11:26:47 +0200 Subject: [PATCH 2/4] test: onImagePress tests --- .playwright/tests/enrichedTextPress.spec.ts | 191 ++++++++++++++++++ .../src/testScreens/TestEnrichedText.tsx | 12 +- 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 .playwright/tests/enrichedTextPress.spec.ts diff --git a/.playwright/tests/enrichedTextPress.spec.ts b/.playwright/tests/enrichedTextPress.spec.ts new file mode 100644 index 00000000..528631e3 --- /dev/null +++ b/.playwright/tests/enrichedTextPress.spec.ts @@ -0,0 +1,191 @@ +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"]', + setValueButton: '[data-testid="test-enriched-text-set-value-button"]', + valueOutput: '[data-testid="test-enriched-text-value-output"]', + display: '[data-testid="test-enriched-text-display"]', + displayInner: '[data-testid="test-enriched-text-display"] .et-view', + 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); +} + +async function setEnrichedTextValue(page: Page, html: string): Promise { + await page.fill(sel.htmlInput, html); + await page.click(sel.setValueButton); + + await expect + .poll(async () => (await page.locator(sel.valueOutput).textContent()) ?? '') + .toBe(html); +} + +function imagePress(page: Page) { + return page.locator(sel.imagePressOutput); +} + +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 = [ + { + name: 'unordered list', + open: '
    ', + close: '
', + }, + { + name: 'ordered list', + open: '
    ', + close: '
', + }, + // { + // name: 'checkbox list', + // open: '
    ', + // close: '
', + // }, + ]; + + for (const c of listCases) { + 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/apps/example-web/src/testScreens/TestEnrichedText.tsx b/apps/example-web/src/testScreens/TestEnrichedText.tsx index 912031dc..39f4a1d2 100644 --- a/apps/example-web/src/testScreens/TestEnrichedText.tsx +++ b/apps/example-web/src/testScreens/TestEnrichedText.tsx @@ -1,5 +1,8 @@ import { useState, type ChangeEvent } from 'react'; -import { EnrichedText } from 'react-native-enriched-html'; +import { + EnrichedText, + type OnImagePressEvent, +} from 'react-native-enriched-html'; import type { TextStyle } from 'react-native'; import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle'; @@ -8,6 +11,8 @@ const INITIAL_VALUE = '

    '; export function TestEnrichedText() { const [htmlInput, setHtmlInput] = useState(INITIAL_VALUE); const [value, setValue] = useState(INITIAL_VALUE); + const [lastImagePress, setLastImagePress] = + useState(null); return (
    @@ -18,6 +23,7 @@ export function TestEnrichedText() { {value} @@ -42,6 +48,10 @@ export function TestEnrichedText() {
    {value}
    + +
    +        {JSON.stringify(lastImagePress)}
    +      
    ); } From daacfb8eeb7c4c60e54b0d030ad216d291475689 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 10 Jul 2026 12:00:52 +0200 Subject: [PATCH 3/4] docs: update --- docs/TEXT_API_REFERENCE.md | 6 +++--- docs/WEB.md | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/TEXT_API_REFERENCE.md b/docs/TEXT_API_REFERENCE.md index b9d6320e..9c016525 100644 --- a/docs/TEXT_API_REFERENCE.md +++ b/docs/TEXT_API_REFERENCE.md @@ -128,9 +128,9 @@ interface OnImagePressEvent { } ``` -| Type | Default Value | Platform | -| ------------------------------------ | ------------- | ------------ | -| `(event: OnImagePressEvent) => void` | - | iOS, Android | +| Type | Default Value | Platform | +| ------------------------------------ | ------------- | ----------------- | +| `(event: OnImagePressEvent) => void` | - | iOS, Android, Web | > [!NOTE] > No visual feedback is applied on press. diff --git a/docs/WEB.md b/docs/WEB.md index 34e5342a..0af770f3 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -39,6 +39,7 @@ See [Web Keyboard Shortcuts](./INPUT_API_REFERENCE.md#web-keyboard-shortcuts) fo ### What works - Customizing the styling using props: `style`, `htmlStyle`, `selectionColor`. +- `onImagePress` callback ### Unsupported From 6767b26ba0f29f9d925194f5fbd5446d30321e09 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 10 Jul 2026 12:03:11 +0200 Subject: [PATCH 4/4] fix: checkbox pointer event --- .playwright/tests/enrichedTextPress.spec.ts | 10 +++++----- src/web/EnrichedText.css | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.playwright/tests/enrichedTextPress.spec.ts b/.playwright/tests/enrichedTextPress.spec.ts index 528631e3..af23349c 100644 --- a/.playwright/tests/enrichedTextPress.spec.ts +++ b/.playwright/tests/enrichedTextPress.spec.ts @@ -160,11 +160,11 @@ test.describe('EnrichedText press inside lists', () => { open: '
      ', close: '
    ', }, - // { - // name: 'checkbox list', - // open: '
      ', - // close: '
    ', - // }, + { + name: 'checkbox list', + open: '
      ', + close: '
    ', + }, ]; for (const c of listCases) { diff --git a/src/web/EnrichedText.css b/src/web/EnrichedText.css index 0e8878b4..01f3a188 100644 --- a/src/web/EnrichedText.css +++ b/src/web/EnrichedText.css @@ -271,7 +271,6 @@ .et-view ul[data-type="checkbox"] > li { position: relative; list-style: none; - pointer-events: none; min-height: max(1lh, var(--et-checkbox-box-size, 24px)); } @@ -284,6 +283,7 @@ height: var(--et-checkbox-box-size, 24px); margin: 0 var(--et-checkbox-gap-width, 16px) 0 0; flex-shrink: 0; + pointer-events: none; accent-color: var(--et-checkbox-box-color, #0000ff); } @@ -293,7 +293,6 @@ gap: 0; margin: 0; padding: 0; - user-select: none; } .et-view img {