Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
29c9d2a
feat: tail ellipsize
hejsztynx Jun 29, 2026
272b51d
feat: head ellipsize
hejsztynx Jun 30, 2026
bc2133f
feat: clip ellipsize
hejsztynx Jun 30, 2026
3ea1138
feat: ellipsizeMode e2e tests
hejsztynx Jun 30, 2026
ad4ead3
feat: update docs
hejsztynx Jun 30, 2026
de29975
test: ellipsis tests
hejsztynx Jul 1, 2026
a18ca7b
fix: update tests and tail and clip logic
hejsztynx Jul 1, 2026
f243145
feat: middle ellipsize
hejsztynx Jul 3, 2026
bbd4619
refactor: functions cleanup
hejsztynx Jul 7, 2026
4022e5a
test: fix tests to respect native html format
hejsztynx Jul 8, 2026
d4d8a7e
test: typos in tests
hejsztynx Jul 8, 2026
c85e0b1
fix: sandbox creation cssText overwrite
hejsztynx Jul 8, 2026
e6aceb1
docs: ssr ui flash note
hejsztynx Jul 8, 2026
6f547df
fix: preserving original ordered list ordinals
hejsztynx Jul 9, 2026
13ae125
feat: example app action buttons
hejsztynx Jul 13, 2026
ca25bbc
fix: zws in empty p tags
hejsztynx Jul 13, 2026
2cc6093
Merge branch 'main' into @ksienkiewicz/feat-web-enriched-text-ellipsize
hejsztynx Jul 13, 2026
9990d7a
fix: acknowledge empty p tags during ellipsize algorithms
hejsztynx Jul 13, 2026
068d9e7
Merge branch 'main' into @ksienkiewicz/feat-web-enriched-text-ellipsize
hejsztynx Jul 22, 2026
f2f3c67
Merge branch 'main' into @ksienkiewicz/feat-web-enriched-text-ellipsize
hejsztynx Jul 22, 2026
785b678
refactor: middle ellipsis test suites screenshots rename
hejsztynx Jul 23, 2026
5b553fd
fix: ellipsizeMode refreshes on style and htmlStyle changes
hejsztynx Jul 23, 2026
6e44ac7
refactor: switch to \u2026 character as an ellipsis representation
hejsztynx Jul 23, 2026
6b5623f
fix: safari boundingbox calculation
hejsztynx Jul 23, 2026
73037cf
fix: backward truncation ellipsis removal
hejsztynx Jul 23, 2026
ecec3d9
refactor: render optimization when no ellipsize is desired
hejsztynx Jul 23, 2026
8461b46
feat: optimization - eatBackward and eatForward now use binary search
hejsztynx Jul 23, 2026
c9124fa
refactor: clean up comments
hejsztynx Jul 27, 2026
a0f342b
Merge branch 'main' into @ksienkiewicz/feat-web-enriched-text-ellipsize
hejsztynx Jul 27, 2026
a644930
Merge branch 'main' into @ksienkiewicz/feat-web-enriched-text-ellipsize
hejsztynx Jul 27, 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.
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.
313 changes: 313 additions & 0 deletions .playwright/tests/ellipsizeVisual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
import { test, expect, type Locator, type Page } from '@playwright/test';

test.setTimeout(90_000);

type EllipsizeMode = 'head' | 'middle' | 'tail' | 'clip';

const LOREM_IPSUM = `<html><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur porta placerat nulla vitae tincidunt. In tellus neque, volutpat id molestie ut, dapibus sit amet justo. Sed nec lobortis orci. Vestibulum magna est, placerat vitae libero ut, viverra mollis purus. Curabitur lacinia a libero non congue. Vestibulum et velit lacinia, feugiat odio eget, faucibus sapien. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus eu erat commodo, condimentum augue eu, fringilla purus. Morbi at nisi eget felis dignissim fringilla. Pellentesque blandit porttitor libero. Donec accumsan, leo eget ultricies rutrum, lorem purus aliquet quam, sit amet pellentesque lectus nulla non nulla.</p></html>`;

const sel = {
root: '[data-testid="test-ellipsize-root"]',
display: '[data-testid="test-ellipsize-display"]',
displayInner: '[data-testid="test-ellipsize-display"] .et-view',
htmlInput: '[data-testid="test-ellipsize-html-input"]',
setValueButton: '[data-testid="test-ellipsize-set-value-button"]',
numberOfLinesInput: '[data-testid="test-ellipsize-number-of-lines-input"]',
numberOfLinesOutput: '[data-testid="test-ellipsize-number-of-lines-output"]',
modeButton: (mode: EllipsizeMode) =>
`[data-testid="test-ellipsize-mode-${mode}"]`,
modeOutput: '[data-testid="test-ellipsize-mode-output"]',
valueOutput: '[data-testid="test-ellipsize-value-output"]',
} as const;

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

async function gotoTestEllipsize(page: Page): Promise<void> {
await page.goto('/test-ellipsize');
await page.waitForSelector(sel.displayInner);
}

async function setValue(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);
}

async function setNumberOfLines(page: Page, lines: number): Promise<void> {
await page.fill(sel.numberOfLinesInput, String(lines));
await expect
.poll(
async () =>
(await page.locator(sel.numberOfLinesOutput).textContent())?.trim() ??
''
)
.toBe(String(lines));
}

async function setMode(page: Page, mode: EllipsizeMode): Promise<void> {
await page.click(sel.modeButton(mode));
await expect
.poll(
async () =>
(await page.locator(sel.modeOutput).textContent())?.trim() ?? ''
)
.toBe(mode);
}

const SHARED_MODES: EllipsizeMode[] = ['tail', 'head', 'clip'];

type EllipsizeCase = {
name: string;
slug: string;
html: string;
numberOfLines: number;
};

const SHARED_CASES: EllipsizeCase[] = [
{
name: 'paragraph that fits',
slug: 'paragraph-fit',
html: `<html><p>hello that is a paragraph that should fit and require no ellipsis</p></html>`,
numberOfLines: 2,
},
{
name: 'single word wrapping to multiple lines',
slug: 'single-word',
html:
`<html><p>Verylongpieceoftextfitintoonelinesowecancheckthiscasewherethereis` +
`averylongpieceoftextanditwrapsaroundsomenumberoflinesandthatwillbetruncated` +
`accordinglytothesetellipsizemodeintheenrichedtextcomponent</p></html>`,
numberOfLines: 2,
},
{
name: 'a long word that causes line wrapping',
slug: 'one-word-wrapping',
html:
`<html><p>Some paragraph with a long word that will not fit and wrap.` +
` The long word: Konstantynopolitanczykowianeczka</p></html>`,
numberOfLines: 2,
},
{
name: 'paragraph with a number of lines much greater than the given limit',
slug: 'very-long-paragraph',
html: LOREM_IPSUM,
numberOfLines: 2,
},
{
name: 'a number of paragraphs much greater than the given limit',
slug: 'multiple-short-paragraphs',
html: `<html><p>first</p><p>second</p><p>third</p><p>fourth</p><p>fifth</p><p>sixth</p><p>seventh</p></html>`,
numberOfLines: 4,
},
Comment thread
hejsztynx marked this conversation as resolved.
{
name: 'a number of paragraphs much greater than the given limit and the last one overflows',
slug: 'multiple-short-paragraphs-with-last-overflow',
html: `<html><p>first</p><p>second</p><p>third</p><p>fourth</p><p>fifth</p><p>sixth</p><p>seventh line that will be long enough that it overflows to the next 8th line</p></html>`,
numberOfLines: 4,
},
Comment thread
hejsztynx marked this conversation as resolved.
{
name: 'some text between empty paragraphs',
slug: 'between-empty-paragraphs',
html: `<html><br><p>between empty paragraphs</p><br></html>`,
numberOfLines: 2,
},
{
name: 'some text between multiple empty lines',
slug: 'between-multiple-empty-lines',
html: `<html><br><br><p>between empty paragraphs</p><br></html>`,
numberOfLines: 2,
},
{
name: 'some empty paragraphs in the middle',
slug: 'empty-in-the-middle',
html: `<html><p>first line</p><br><br><br><p>last line</p></html>`,
numberOfLines: 2,
},
{
name: 'unordered list',
slug: 'unordered-list',
html: `<html><ul><li>first</li><li>second</li><li>third</li></ul></html>`,
numberOfLines: 2,
},
{
name: 'unordered list with empty elements',
slug: 'unordered-list-with-empty',
html: `<html><ul><li></li><li>second</li><li></li><li>fourth</li></ul></html>`,
numberOfLines: 3,
},
{
name: 'unordered list with only empty elements',
slug: 'unordered-list-only-empty',
html: `<html><ul><li></li><li></li><li></li></ul></html>`,
numberOfLines: 2,
},
{
name: 'ordered list',
slug: 'ordered-list',
html: `<html><ol><li>first</li><li>second</li><li>third</li></ol></html>`,
numberOfLines: 2,
},
{
name: 'ordered list with empty elements',
slug: 'ordered-list-with-empty',
html: `<html><ol><li></li><li>second</li><li></li><li>fourth</li></ol></html>`,
numberOfLines: 3,
},
{
name: 'ordered list with only empty elements',
slug: 'ordered-list-only-empty',
html: `<html><ol><li></li><li></li><li></li></ol></html>`,
numberOfLines: 2,
},
{
name: 'checkbox list',
slug: 'checkbox-list',
html: `<html><ul data-type="checkbox"><li checked>first</li><li>second</li><li>third</li></ul></html>`,
numberOfLines: 2,
},
{
name: 'checkbox list with empty elements',
slug: 'checkbox-list-with-empty',
html: `<html><ul data-type="checkbox"><li checked></li><li>second</li><li></li><li>fourth</li></ul></html>`,
numberOfLines: 3,
},
{
name: 'checkbox list with only empty elements',
slug: 'checkbox-list-only-empty',
html: `<html><ul data-type="checkbox"><li checked></li><li></li><li></li></ul></html>`,
numberOfLines: 2,
},
{
name: 'different paragraph style',
slug: 'different-paragraph-style',
html: `<html><blockquote><p>this is a pretty long blockquote - a different block style paragraph, long enough to overflow in the given number of lines.</p></blockquote></html>`,
numberOfLines: 2,
},
{
name: 'different paragraph style with an empty line',
slug: 'different-paragraph-style-with-empty-line',
html: `<html><blockquote><p>this is a pretty short blockquote.</p><br><p>This is a line after an empty line.</p></blockquote></html>`,
numberOfLines: 2,
},
{
name: 'mixing different paragraph styles',
slug: 'paragraph-styles-mix',
html: `<html><p>This is a normal paragraph.</p><codeblock><p>This is a codeblock that will be long enough to overflow.</p></codeblock></html>`,
numberOfLines: 2,
},
{
name: 'inline images',
slug: 'inline-images',
html:
`<html><p>This is a line with an inline <img src="asd" width="80" height="80"/>image.` +
`</p><p>This is a second line and the inline image <img src="asd" width="80" height="80"/> ` +
`will make it overflow to the next line.</p><p>This is the last line with an <img src="asd" width="80" height="80"/>inline image.</p></html>`,
numberOfLines: 2,
},
];

for (const mode of SHARED_MODES) {
test.describe(`EnrichedText ellipsize - ${mode}`, () => {
for (const c of SHARED_CASES) {
test(c.name, async ({ page }) => {
await gotoTestEllipsize(page);
await setValue(page, c.html);
await setNumberOfLines(page, c.numberOfLines);
await setMode(page, mode);

await expect(displayLocator(page)).toHaveScreenshot(
`ellipsize-${c.slug}-${mode}.png`
);
});
}
});
}

test.describe('EnrichedText ellipsize - middle', () => {
const middleEllipsizeCases: EllipsizeCase[] = [
{
name: 'all text within one paragraph, with one line exceeding the limit',
slug: 'one-paragraph-one-line-overflowing',
html:
`<html><p>This is a fairly long paragraph that should wrap` +
` across three lines so the truncation has something to chew on.</p></html>`,
numberOfLines: 2,
},
{
name: 'all text within one paragraph, but with at least two lines exceeding the limit',
slug: 'one-paragraph-many-lines-overflowing',
html:
`<html><p>This is a fairly long paragraph that should wrap across four lines so the` +
` truncation has something to chew on. Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p></html>`,
numberOfLines: 2,
},
{
name: 'all text within one paragraph but with multiple inline images',
slug: 'one-paragraph-many-inline-images',
html:
`<html><p>Some paragraph text overflowing but with so<img src="w" width="20" ` +
`height="20"/>me inline images <img src="w" width="20" height="20"/><img src="w" ` +
`width="20" height="20"/><img src="w" width="20" height="20"/>insi<img src="w" width="20" ` +
`height="20"/>de the eaten text and before, after dummytext the rendered <img src="w" width="20" height="20"/>ellipsis</p></html>`,
numberOfLines: 2,
},
{
name: 'different paragraphs will be truncated with the middle ellipsis if the needed last lines belong to a single paragraph',
slug: 'different-paragraph-style',
html: `<html><p>first paragraph.</p><h6>Different paragraph style that will overflow to the next line</h6></html>`,
numberOfLines: 2,
},
{
name: 'different paragraph style with many lines',
slug: 'different-paragraph-style-multiple-lines',
html:
`<html><p>first paragraph.</p><codeblock><p>Different paragraph style and the text ` +
`that will overflow to the next lines of that block verylongwordthatwillnotfitinthelineandwilloverflow</p></codeblock></html>`,
numberOfLines: 2,
},
{
name: 'ellipsis on the last paragraph with leading other paragraphs and break lines',
slug: 'other-paragraphs-and-break-lines-inside-block-style',
html:
`<html><p>Normal style.</p><blockquote><p>First line before empty ones.</p><br><br><p>Last ` +
`paragraph inside a block style that will overflow to three different lines, so we can check this case.</p></blockquote></html>`,
numberOfLines: 5,
},
{
name: 'empty lines are preserved',
slug: 'empty-lines',
html: `<html><br><p>first line</p><br><p>last line that will overflow and will have an ellipsis</p></html>`,
numberOfLines: 4,
},
// tests checking a proper fallback to the head ellipsis
{
name: 'when the overflowing lines are different blocks, it fallbacks to head ellipsis',
slug: 'different-blocks-head-fallback',
html: `<html><p>first line</p><p>second line</p><p>third line</p></html>`,
numberOfLines: 2,
},
{
name: 'breaklines inside a block style are considered as different paragraphs',
slug: 'breaklines-head-fallback',
html: `<html><blockquote><p>first line</p><br><br></blockquote></html>`,
numberOfLines: 2,
},
];

for (const c of middleEllipsizeCases) {
test(c.name, async ({ page }) => {
await gotoTestEllipsize(page);
await setValue(page, c.html);
await setNumberOfLines(page, c.numberOfLines);
await setMode(page, 'middle');

await expect(displayLocator(page)).toHaveScreenshot(
`ellipsize-middle-${c.slug}.png`
Comment thread
hejsztynx marked this conversation as resolved.
Outdated
);
});
}
});
3 changes: 2 additions & 1 deletion apps/example-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,8 @@ function App() {
?.getHTML()
.then((html) => {
setEnrichedTextValue(html);
ref.current?.setValue('');
// temporary for making the testing easier
// ref.current?.setValue('');
Comment thread
hejsztynx marked this conversation as resolved.
Comment on lines +241 to +242

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder: uncomment it before merging

})
.catch((error: unknown) => {
setEnrichedTextValue('');
Expand Down
5 changes: 5 additions & 0 deletions apps/example-web/src/RouteSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TestSetSelection } from './testScreens/TestSetSelection';
import { VisualRegression } from './testScreens/VisualRegression';
import { TestSubmitProps } from './testScreens/TestSubmitProps';
import { TestEnrichedText } from './testScreens/TestEnrichedText';
import { TestEllipsize } from './testScreens/TestEllipsize';
import { useEffect, useState } from 'react';

export default function RouteSelector() {
Expand Down Expand Up @@ -45,5 +46,9 @@ export default function RouteSelector() {
return <TestEnrichedText />;
}

if (path === '/test-ellipsize') {
return <TestEllipsize />;
}

return <App />;
}
19 changes: 19 additions & 0 deletions apps/example-web/src/components/EnrichedTextActions.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.actions-container {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
margin-top: 16px;
margin-bottom: 12px;
}

.btn-row {
display: flex;
gap: 8px;
width: 100%;
}

.btn-active {
background-color: rgb(0, 26, 114);
color: white;
}
Loading
Loading