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/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/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 1261c9020..7a456cecd 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 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/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 }; }