Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
27cac3c
feat: base
hejsztynx Jun 17, 2026
8a79e28
feat: style prop and theming implementation
hejsztynx Jun 17, 2026
c7ff13a
feat: impl html style
hejsztynx Jun 18, 2026
36f0424
feat: checkbox list conversion to web html
hejsztynx Jun 18, 2026
53970ec
fix: empty elements in list collapsing
hejsztynx Jun 18, 2026
9c649cb
feat: html sanitization
hejsztynx Jun 18, 2026
cfc85ba
feat: image placeholder
hejsztynx Jun 18, 2026
3207367
refactor: broken image glyph css var
hejsztynx Jun 18, 2026
1905a8c
test: enriched text tests
hejsztynx Jun 19, 2026
dfd8915
docs: update docs
hejsztynx Jun 19, 2026
bb1edff
refactor: cleanup
hejsztynx Jun 21, 2026
bdf9483
fix: onerror when setting img placeholders
hejsztynx Jun 21, 2026
0c70ea7
fix: css tweaks
hejsztynx Jun 21, 2026
6917372
fix: domparser existence check
hejsztynx Jun 21, 2026
82515f4
feat: merge with default styles
hejsztynx Jun 22, 2026
0a4a774
fix: build mention rules default flow
hejsztynx Jun 22, 2026
45d0d36
feat: disable default link press handling
hejsztynx Jun 23, 2026
9dc110a
fix: placeholder images not displaying on safari
hejsztynx Jun 23, 2026
72f566c
feat: clear input on push text
hejsztynx Jun 23, 2026
f2c8f2a
test: cleanup
hejsztynx Jun 24, 2026
b6f3e59
docs: web docs update
hejsztynx Jun 24, 2026
950ed5a
docs: web.md update
hejsztynx Jun 24, 2026
459d0f1
Update package.json
hejsztynx Jun 25, 2026
361f710
fix: unnecessary img placeholder logic on every rerender
hejsztynx Jun 25, 2026
6d068f1
Merge branch 'main' into @ksienkiewicz/feat-web-enriched-text
hejsztynx Jun 25, 2026
90b938b
Merge branch 'main' into @ksienkiewicz/feat-web-enriched-text
hejsztynx Jun 25, 2026
5f06822
chore: deps
hejsztynx Jun 25, 2026
7c542b0
chore: lint
hejsztynx Jun 25, 2026
6a4c7e1
Merge branch 'main' into @ksienkiewicz/feat-web-enriched-text
hejsztynx Jun 26, 2026
5e8693d
test: fix tests
hejsztynx Jun 26, 2026
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
229 changes: 229 additions & 0 deletions .playwright/tests/enrichedTextVisual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { test, expect, type Locator, type Page } from '@playwright/test';

test.setTimeout(90_000);

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',
} as const;

function displayLocator(page: Page): Locator {
return page.locator(sel.display);
}

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);
}

test.describe('EnrichedText display visual regression', () => {
const cases: { name: string; snapshot: string; html: string }[] = [
{
name: 'rich text: heading, bold, italic and link',
snapshot: 'enriched-text-rich-text.png',
html: [
'<html>',
'<h3>Heading</h3>',
'<p>Some <b>bold</b> and <i>italic</i> text.</p>',
'<p><b>S</i>om</i>e</b> <b>mix</b><i>ed</i> <s>t<u>ex</u>t</s>.</p>',
'<p>A <a href="https://example.com">link</a> here.</p>',
'<p>A bold <a href="https://example.com">l<b>in<b/>k</a> here.</p>',
'</html>',
].join(''),
},
{
name: 'unordered list',
snapshot: 'enriched-text-unordered-list.png',
html: '<html><ul><li>Alpha</li><li>Beta</li><li>Gamma</li></ul></html>',
},
{
name: 'unordered list with empty items',
snapshot: 'enriched-text-unordered-list-empty-items.png',
html: '<html><h4>Empty lists</h4><ul><li></li><li>Alpha</li><li></li><li>Gamma</li><li></li></ul><p>bottom</p></html>',
},
{
name: 'ordered list',
snapshot: 'enriched-text-ordered-list.png',
html: '<html><ol><li>One</li><li>Two</li><li>Three</li></ol></html>',
},
{
name: 'ordered list with empty items',
snapshot: 'enriched-text-ordered-list-empty-items.png',
html: '<html><h4>Empty lists</h4><ol><li></li><li>One</li><li></li><li>Three</li><li></li></ol><p>bottom</p></html>',
},
{
name: 'checkbox list all unchecked',
snapshot: 'enriched-text-checkbox-list-unchecked.png',
html: '<html><ul data-type="checkbox"><li>one</li><li>two</li></ul></html>',
},
{
name: 'checkbox list with checked item',
snapshot: 'enriched-text-checkbox-list-checked.png',
html: '<html><ul data-type="checkbox"><li checked>one</li><li>two</li></ul></html>',
},
{
name: 'checkbox list with empty items',
snapshot: 'enriched-text-checkbox-list-empty-items.png',
html: '<html><h4>Empty lists</h4><ul data-type="checkbox"><li></li><li checked>one</li><li></li><li>three</li><li checked></li></ul><p>bottom</p></html>',
},
];

for (const c of cases) {
test(c.name, async ({ page }) => {
await gotoTestEnrichedText(page);
await setEnrichedTextValue(page, c.html);

await expect(displayLocator(page)).toHaveScreenshot(c.snapshot);
});
}
});

test.describe('visual: complex lists and layouts', () => {
test('all 3 types of the list at once', async ({ page }) => {
const html = [
'<html>',
'<ul><li>Bullet item</li></ul>',
'<ol><li>Numbered item</li></ol>',
'<ul data-type="checkbox"><li checked>Checked item</li><li>Unchecked item</li></ul>',
'</html>',
].join('');

await gotoTestEnrichedText(page);
await setEnrichedTextValue(page, html);
await expect(displayLocator(page)).toHaveScreenshot(
'enriched-text-all-list-types.png'
);
});
});

test.describe('visual: typography, blocks, and wrapping', () => {
const cases = [
{
name: 'all 6 headings',
snapshot: 'enriched-text-all-headings.png',
html: '<html><h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3><h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6></html>',
},
{
name: 'blockquote, code, codeblock',
snapshot: 'enriched-text-blockquote-code-codeblock.png',
html: [
'<html>',
'<blockquote><p>This is a blockquote. Blockquote for quoting in a block.</p></blockquote>',
'<p>Here is some <code>inline code</code> mixed in text.</p>',
'<codeblock><p>function test() {</p><p> return true;</p><p>}</p></codeblock>',
'</html>',
].join(''),
},
{
name: 'multiple newlines and multiple spaces',
snapshot: 'enriched-text-newlines-spaces.png',
html: [
'<html>',
'<p>Word spaced out a lot.</p>',
'<p><br></p>',
'<p><br></p>',
'<p>Text after empty newlines.</p>',
'</html>',
].join(''),
},
{
name: 'line wrapping',
snapshot: 'enriched-text-line-wrapping.png',
html: [
'<html>',
'<p>This is a standard paragraph with enough text that it should naturally wrap to the next line when it reaches the edge of the container.</p>',
'<p>SuperLongWordWithoutAnySpacesThatShouldForceTheWordBreakOrOverflowWrapRuleToKickInAndPreventTheLayoutFromBreakingHorizontally</p>',
'</html>',
].join(''),
},
];

for (const c of cases) {
test(c.name, async ({ page }) => {
await gotoTestEnrichedText(page);
await setEnrichedTextValue(page, c.html);
await expect(displayLocator(page)).toHaveScreenshot(c.snapshot);
});
}
});

test.describe('visual: mentions', () => {
test('display mentions', async ({ page }) => {
await gotoTestEnrichedText(page);
await setEnrichedTextValue(
page,
'<html><p>Hello <mention indicator="@" text="@John Doe">@Jo<s>hn D</s>oe</mention>!</p></html>'
);
await expect(displayLocator(page)).toHaveScreenshot(
'enriched-text-mentions.png'
);
});
});

test.describe('visual: images', () => {
test.beforeEach(async ({ page }) => {
const routePattern = '**/pw-e2e-ok.png';
const pngBody = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
await page.route(routePattern, async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/png',
body: pngBody,
});
});

// Abort broken image to force placeholder
const brokenPattern = '**/pw-e2e-broken.png';
await page.route(brokenPattern, (route) => route.abort());
});

const cases = [
{
name: 'inline images next to some text',
snapshot: 'enriched-text-images-inline.png',
html: '<html><p>Start text <img src="/pw-e2e-ok.png" width="40" height="40" /> end text.</p></html>',
},
{
name: 'inline images inside list',
snapshot: 'enriched-text-images-inside-list.png',
html: '<html><ul><li>Bullet item <img src="/pw-e2e-ok.png" width="20" height="20" /> with image.</li></ul></html>',
},
{
name: 'image placeholder display next to some text',
snapshot: 'enriched-text-images-placeholder-inline.png',
html: '<html><p>Look at this broken <img src="/pw-e2e-broken.png" width="60" height="60" /> picture.</p></html>',
},
{
name: 'image placeholder inside lists',
snapshot: 'enriched-text-images-placeholder-list.png',
html: '<html><ol><li>List with a broken image <img src="" width="20" height="20" /> inside.</li></ol></html>',
},
];

for (const c of cases) {
test(c.name, async ({ page }) => {
await gotoTestEnrichedText(page);
await setEnrichedTextValue(page, c.html);

await page.waitForTimeout(100);

await expect(displayLocator(page)).toHaveScreenshot(c.snapshot);
});
}
});
4 changes: 4 additions & 0 deletions apps/example-web/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ body {
align-items: center;
}

.enriched-text-container {
width: 100%;
}

.app-title {
font-size: 24px;
font-weight: bold;
Expand Down
46 changes: 45 additions & 1 deletion apps/example-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import {
type OnSubmitEditing,
type OnChangeMentionEvent,
type OnMentionDetected,
EnrichedText,
} from 'react-native-enriched-html';
import { WEB_DEFAULT_HTML_STYLE } from './defaultHtmlStyle';
import type { NativeSyntheticEvent } from 'react-native';
import type { NativeSyntheticEvent, TextStyle } from 'react-native';
import { EditorActions } from './components/EditorActions';
import { SetValueModal } from './components/SetValueModal';
import { ImageModal } from './components/ImageModal';
Expand Down Expand Up @@ -55,6 +56,8 @@ function App() {
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false);
const [isImageModalOpen, setIsImageModalOpen] = useState(false);

const [enrichedTextValue, setEnrichedTextValue] = useState('');

const isLinkActive = !!editorState?.link.isActive;
const hasLinkUrl = currentLink.url.length > 0;
const hasLinkSpan = currentLink.start !== 0 || currentLink.end !== 0;
Expand Down Expand Up @@ -228,6 +231,19 @@ function App() {
}
};

const handleSetEnrichedTextValue = () => {
ref.current
?.getHTML()
.then((html) => {
setEnrichedTextValue(html);
ref.current?.setValue('');
})
.catch((error: unknown) => {
setEnrichedTextValue('');
console.error('Failed to get HTML:', error);
});
};

return (
<div className="container">
<h1 className="app-title">Enriched Text Input</h1>
Expand Down Expand Up @@ -306,8 +322,26 @@ function App() {
}}
/>

<button
className="btn btn-full"
data-testid="set-enriched-text-value"
onClick={handleSetEnrichedTextValue}
>
Push Text
</button>

{showHtmlOutput && <HtmlOutputPanel html={currentHtml} />}

<div className="container enriched-text-container">
<h1 className="app-title">Enriched Text</h1>
<EnrichedText
style={enrichedTextStyle}
htmlStyle={WEB_DEFAULT_HTML_STYLE}
>
{enrichedTextValue}
</EnrichedText>
</div>

{isSetValueModalOpen && (
<SetValueModal
onSetValue={(value) => {
Expand Down Expand Up @@ -348,4 +382,14 @@ const enrichedInputStyle: EnrichedInputStyle = {
fontSize: 18,
};

const enrichedTextStyle: TextStyle = {
backgroundColor: 'gainsboro',
width: '100%',
marginVertical: 12,
paddingVertical: 12,
paddingHorizontal: 14,
borderRadius: 8,
fontSize: 18,
};

export default App;
5 changes: 5 additions & 0 deletions apps/example-web/src/RouteSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TestLinks } from './testScreens/TestLinks';
import { TestSetSelection } from './testScreens/TestSetSelection';
import { VisualRegression } from './testScreens/VisualRegression';
import { TestSubmitProps } from './testScreens/TestSubmitProps';
import { TestEnrichedText } from './testScreens/TestEnrichedText';
import { useEffect, useState } from 'react';

export default function RouteSelector() {
Expand Down Expand Up @@ -40,5 +41,9 @@ export default function RouteSelector() {
return <TestMentions />;
}

if (path === '/test-enriched-text') {
return <TestEnrichedText />;
}

return <App />;
}
Loading
Loading