Skip to content
Open
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
191 changes: 191 additions & 0 deletions .playwright/tests/enrichedTextPress.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await page.goto('/test-enriched-text');
await page.waitForSelector(sel.displayInner);
}

async function setEnrichedTextValue(page: Page, html: string): Promise<void> {
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,
`<html><p>Look <img src="${IMAGE_SRC}" width="32" height="32" /> now.</p></html>`
);

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,
`<html><p>A <b>bold</b> <img src="${IMAGE_SRC}" width="120" height="60" /> here.</p></html>`
);

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,
`<html><p>Plain text with an <img src="${IMAGE_SRC}" width="32" height="32" />.</p></html>`
);

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,
'<html><p>Before <img src="" width="40" height="40" /> after.</p></html>'
);

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,
`<html><p>Before <img src="${BROKEN_IMAGE_SRC}" width="40" height="40" /> after.</p></html>`
);

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: '<ul>',
close: '</ul>',
},
{
name: 'ordered list',
open: '<ol>',
close: '</ol>',
},
{
name: 'checkbox list',
open: '<ul data-type="checkbox">',
close: '</ul>',
},
];

for (const c of listCases) {
test(`pressing an image inside a ${c.name} emits onImagePress`, async ({
page,
}) => {
await gotoTestEnrichedText(page);
await setEnrichedTextValue(
page,
`<html>${c.open}<li><p>See <img src="${IMAGE_SRC}" width="28" height="28" /> tiny</p></li><li><p>Other item</p></li>${c.close}</html>`
);

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 } });
});
}
});
6 changes: 6 additions & 0 deletions apps/example-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -173,6 +174,10 @@ function App() {
setSelection(e.nativeEvent);
};

const handleImagePress = (e: OnImagePressEvent) => {
console.log('[EnrichedText] image press event', e);
};

const openLinkModal = () => {
setIsLinkModalOpen(true);
};
Expand Down Expand Up @@ -337,6 +342,7 @@ function App() {
<EnrichedText
style={enrichedTextStyle}
htmlStyle={WEB_DEFAULT_HTML_STYLE}
onImagePress={handleImagePress}
>
{enrichedTextValue}
</EnrichedText>
Expand Down
12 changes: 11 additions & 1 deletion apps/example-web/src/testScreens/TestEnrichedText.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -8,6 +11,8 @@ const INITIAL_VALUE = '<html><p></p></html>';
export function TestEnrichedText() {
const [htmlInput, setHtmlInput] = useState(INITIAL_VALUE);
const [value, setValue] = useState(INITIAL_VALUE);
const [lastImagePress, setLastImagePress] =
useState<OnImagePressEvent | null>(null);

return (
<div data-testid="test-enriched-text-root">
Expand All @@ -18,6 +23,7 @@ export function TestEnrichedText() {
<EnrichedText
style={enrichedTextStyle}
htmlStyle={WEB_DEFAULT_HTML_STYLE}
onImagePress={setLastImagePress}
>
{value}
</EnrichedText>
Expand All @@ -42,6 +48,10 @@ export function TestEnrichedText() {
</button>

<pre data-testid="test-enriched-text-value-output">{value}</pre>

<pre data-testid="test-enriched-text-image-press-output">
{JSON.stringify(lastImagePress)}
</pre>
</div>
);
}
Expand Down
6 changes: 3 additions & 3 deletions docs/TEXT_API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/WEB.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 1 addition & 2 deletions src/web/EnrichedText.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand All @@ -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);
}

Expand All @@ -293,7 +293,6 @@
gap: 0;
margin: 0;
padding: 0;
user-select: none;
}

.et-view img {
Expand Down
13 changes: 11 additions & 2 deletions src/web/EnrichedText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment on lines 16 to +17

export const EnrichedText = memo(
({ children, htmlStyle, style, selectionColor }: EnrichedTextProps) => {
({
children,
htmlStyle,
style,
selectionColor,
onImagePress,
}: EnrichedTextProps) => {
const containerRef = useRef<HTMLDivElement>(null);

const sanitizedHtml = useMemo(() => sanitizeHtml(children), [children]);
Expand Down Expand Up @@ -64,7 +71,9 @@ export const EnrichedText = memo(
[textStyle, themingStyle, cssVars]
);

usePressInteractions(containerRef);
const onImagePressRef = useStableRef(onImagePress);

usePressInteractions(containerRef, onImagePressRef);
useImageErrorFallback(containerRef);

return (
Expand Down
64 changes: 64 additions & 0 deletions src/web/usePressInteractions.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>,
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)) {
Comment on lines +22 to +27
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;
}
Comment on lines +45 to +64