Skip to content

Commit 120206f

Browse files
feat(web): text alignment (#654)
# Summary Text alignment for Web ## Test Plan - Run example web app and play with `textAlignment` when different styles are toggled. - Check if paragraphs/lists with different `textAlignment` than `auto` are properly parsed and contain `style="text-align:center|right|justify"` inside HTML - Run yarn test:e2e:web to see if all the tests pass ## Screenshots / Videos https://github.com/user-attachments/assets/92a4b539-6ff0-423b-af68-3bc2f3f787a5 ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ❌ | | Web | ✅ | ## Checklist - [x] E2E tests are passing - [x] Required E2E tests have been added (if applicable)
1 parent b9310d7 commit 120206f

21 files changed

Lines changed: 571 additions & 29 deletions
9.26 KB
Loading
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
3+
import { toolbarButton } from '../helpers/toolbar';
4+
import {
5+
editorLocator,
6+
getSerializedHtml,
7+
gotoVisualRegression,
8+
setEditorHtml,
9+
} from '../helpers/visual-regression';
10+
11+
// Toolbar shorthand for alignment buttons (testId: toolbar-button-alignment-{value})
12+
function alignBtn(
13+
page: Page,
14+
alignment: 'left' | 'center' | 'right' | 'justify'
15+
) {
16+
return toolbarButton(page, `alignment-${alignment}`);
17+
}
18+
19+
test.describe('alignment html round-trip', () => {
20+
test.beforeEach(async ({ page }) => {
21+
await gotoVisualRegression(page);
22+
});
23+
24+
test('center-aligned paragraph is preserved', async ({ page }) => {
25+
await setEditorHtml(
26+
page,
27+
'<html><p style="text-align: center">Center</p></html>'
28+
);
29+
const html = await getSerializedHtml(page);
30+
expect(html).toContain('<p style="text-align: center;">Center</p>');
31+
});
32+
33+
test('right-aligned heading is preserved', async ({ page }) => {
34+
await setEditorHtml(
35+
page,
36+
'<html><h2 style="text-align: right">Right Heading</h2></html>'
37+
);
38+
const html = await getSerializedHtml(page);
39+
expect(html).toContain('<h2 style="text-align: right;">Right Heading</h2>');
40+
});
41+
42+
test('ordered list alignment is on the <ol> wrapper, not on <li>', async ({
43+
page,
44+
}) => {
45+
await setEditorHtml(
46+
page,
47+
'<html><ol style="text-align: center"><li>Item 1</li><li>Item 2</li></ol></html>'
48+
);
49+
const html = await getSerializedHtml(page);
50+
expect(html).toContain('<ol style="text-align: center;">');
51+
expect(html).not.toMatch(/<li[^>]*style[^>]*>/);
52+
});
53+
54+
test('unordered list alignment is on the <ul> wrapper', async ({ page }) => {
55+
await setEditorHtml(
56+
page,
57+
'<html><ul style="text-align: right"><li>Bullet</li></ul></html>'
58+
);
59+
const html = await getSerializedHtml(page);
60+
expect(html).toContain('<ul style="text-align: right;">');
61+
expect(html).not.toMatch(/<li[^>]*style[^>]*>/);
62+
});
63+
64+
test('aligned paragraph inside blockquote is preserved', async ({ page }) => {
65+
await setEditorHtml(
66+
page,
67+
'<html><blockquote><p style="text-align: center">Quote</p></blockquote></html>'
68+
);
69+
const html = await getSerializedHtml(page);
70+
expect(html).toContain('<p style="text-align: center;">Quote</p>');
71+
expect(html).toContain('<blockquote>');
72+
});
73+
74+
test('multiple paragraphs with different alignments are each preserved', async ({
75+
page,
76+
}) => {
77+
await setEditorHtml(
78+
page,
79+
'<html>' +
80+
'<p>Default</p>' +
81+
'<p style="text-align: center">Center</p>' +
82+
'<p style="text-align: right">Right</p>' +
83+
'</html>'
84+
);
85+
const html = await getSerializedHtml(page);
86+
expect(html).toContain('<p>Default</p>');
87+
expect(html).toContain('<p style="text-align: center;">Center</p>');
88+
expect(html).toContain('<p style="text-align: right;">Right</p>');
89+
});
90+
91+
test('blockquote paragraphs can have independent alignments', async ({
92+
page,
93+
}) => {
94+
await setEditorHtml(
95+
page,
96+
'<html>' +
97+
'<blockquote>' +
98+
'<p style="text-align: center">Quote center</p>' +
99+
'<p style="text-align: left">Quote left</p>' +
100+
'</blockquote>' +
101+
'</html>'
102+
);
103+
const html = await getSerializedHtml(page);
104+
expect(html).toContain('<p style="text-align: center;">Quote center</p>');
105+
expect(html).toContain('<p style="text-align: left;">Quote left</p>');
106+
});
107+
});
108+
109+
test.describe('alignment toolbar interaction', () => {
110+
test.beforeEach(async ({ page }) => {
111+
await gotoVisualRegression(page);
112+
});
113+
114+
test('clicking center sets center alignment on paragraph', async ({
115+
page,
116+
}) => {
117+
await setEditorHtml(page, '<html><p>Hello</p></html>');
118+
await page.locator('.eti-editor p').click();
119+
await alignBtn(page, 'center').click();
120+
const html = await getSerializedHtml(page);
121+
expect(html).toContain('<p style="text-align: center;">Hello</p>');
122+
});
123+
124+
test('clicking right sets right alignment on paragraph', async ({ page }) => {
125+
await setEditorHtml(page, '<html><p>Hello</p></html>');
126+
await page.locator('.eti-editor p').click();
127+
await alignBtn(page, 'right').click();
128+
const html = await getSerializedHtml(page);
129+
expect(html).toContain('<p style="text-align: right;">Hello</p>');
130+
});
131+
132+
test('clicking justify sets justify alignment on paragraph', async ({
133+
page,
134+
}) => {
135+
await setEditorHtml(page, '<html><p>Hello</p></html>');
136+
await page.locator('.eti-editor p').click();
137+
await alignBtn(page, 'justify').click();
138+
const html = await getSerializedHtml(page);
139+
expect(html).toContain('<p style="text-align: justify;">Hello</p>');
140+
});
141+
142+
test('clicking center on ordered list sets alignment on <ol> wrapper', async ({
143+
page,
144+
}) => {
145+
await setEditorHtml(page, '<html><ol><li>Item</li></ol></html>');
146+
await page.locator('.eti-editor ol li p').click();
147+
await alignBtn(page, 'center').click();
148+
const html = await getSerializedHtml(page);
149+
expect(html).toContain('<ol style="text-align: center;">');
150+
expect(html).not.toMatch(/<li[^>]*style[^>]*>/);
151+
});
152+
153+
test('clicking center on unordered list sets alignment on <ul> wrapper', async ({
154+
page,
155+
}) => {
156+
await setEditorHtml(page, '<html><ul><li>Bullet</li></ul></html>');
157+
await page.locator('.eti-editor ul li p').click();
158+
await alignBtn(page, 'center').click();
159+
const html = await getSerializedHtml(page);
160+
expect(html).toContain('<ul style="text-align: center;">');
161+
expect(html).not.toMatch(/<li[^>]*style[^>]*>/);
162+
});
163+
164+
test('clicking a different alignment replaces the previous one', async ({
165+
page,
166+
}) => {
167+
await setEditorHtml(
168+
page,
169+
'<html><p style="text-align: center">Text</p></html>'
170+
);
171+
await page.locator('.eti-editor p').click();
172+
await alignBtn(page, 'right').click();
173+
const html = await getSerializedHtml(page);
174+
expect(html).not.toContain('text-align: center');
175+
expect(html).toContain('<p style="text-align: right;">Text</p>');
176+
});
177+
178+
test('alignment toolbar button is active when cursor is on aligned paragraph', async ({
179+
page,
180+
}) => {
181+
await setEditorHtml(
182+
page,
183+
'<html><p style="text-align: center">Center</p></html>'
184+
);
185+
await page.locator('.eti-editor p').click();
186+
await expect(alignBtn(page, 'center')).toHaveClass(/toolbar-btn--active/);
187+
await expect(alignBtn(page, 'left')).not.toHaveClass(/toolbar-btn--active/);
188+
await expect(alignBtn(page, 'right')).not.toHaveClass(
189+
/toolbar-btn--active/
190+
);
191+
});
192+
193+
test('alignment toolbar button is active when cursor is inside aligned list', async ({
194+
page,
195+
}) => {
196+
await setEditorHtml(
197+
page,
198+
'<html><ol style="text-align: right"><li>Item</li></ol></html>'
199+
);
200+
await page.locator('.eti-editor ol li p').click();
201+
await expect(alignBtn(page, 'right')).toHaveClass(/toolbar-btn--active/);
202+
await expect(alignBtn(page, 'center')).not.toHaveClass(
203+
/toolbar-btn--active/
204+
);
205+
});
206+
});
207+
208+
test.describe('alignment preserved through block style toggle', () => {
209+
test.beforeEach(async ({ page }) => {
210+
await gotoVisualRegression(page);
211+
});
212+
213+
test('center alignment preserved when toggling paragraph to H1', async ({
214+
page,
215+
}) => {
216+
await setEditorHtml(
217+
page,
218+
'<html><p style="text-align: center">Hello</p></html>'
219+
);
220+
await page.locator('.eti-editor p').click();
221+
await toolbarButton(page, 'h1').click();
222+
const html = await getSerializedHtml(page);
223+
expect(html).toContain('<h1 style="text-align: center;">Hello</h1>');
224+
});
225+
226+
test('right alignment preserved when toggling paragraph to H3', async ({
227+
page,
228+
}) => {
229+
await setEditorHtml(
230+
page,
231+
'<html><p style="text-align: right">Hello</p></html>'
232+
);
233+
await page.locator('.eti-editor p').click();
234+
await toolbarButton(page, 'h3').click();
235+
const html = await getSerializedHtml(page);
236+
expect(html).toContain('<h3 style="text-align: right;">Hello</h3>');
237+
});
238+
239+
test('center alignment preserved when toggling paragraph to ordered list', async ({
240+
page,
241+
}) => {
242+
await setEditorHtml(
243+
page,
244+
'<html><p style="text-align: center">Hello</p></html>'
245+
);
246+
await page.locator('.eti-editor p').click();
247+
await toolbarButton(page, 'orderedList').click();
248+
const html = await getSerializedHtml(page);
249+
expect(html).toContain('<ol style="text-align: center;">');
250+
expect(html).toContain('Hello');
251+
});
252+
253+
test('center alignment preserved when toggling paragraph to unordered list', async ({
254+
page,
255+
}) => {
256+
await setEditorHtml(
257+
page,
258+
'<html><p style="text-align: center">Hello</p></html>'
259+
);
260+
await page.locator('.eti-editor p').click();
261+
await toolbarButton(page, 'unorderedList').click();
262+
const html = await getSerializedHtml(page);
263+
expect(html).toContain('<ul style="text-align: center;">');
264+
expect(html).toContain('Hello');
265+
});
266+
267+
test('center alignment preserved when toggling ordered list to paragraph', async ({
268+
page,
269+
}) => {
270+
await setEditorHtml(
271+
page,
272+
'<html><ol style="text-align: center"><li>Hello</li></ol></html>'
273+
);
274+
await page.locator('.eti-editor ol li p').click();
275+
await toolbarButton(page, 'orderedList').click();
276+
const html = await getSerializedHtml(page);
277+
expect(html).toContain('<p style="text-align: center;">Hello</p>');
278+
});
279+
280+
test('right alignment preserved when toggling unordered list to paragraph', async ({
281+
page,
282+
}) => {
283+
await setEditorHtml(
284+
page,
285+
'<html><ul style="text-align: right"><li>Hello</li></ul></html>'
286+
);
287+
await page.locator('.eti-editor ul li p').click();
288+
await toolbarButton(page, 'unorderedList').click();
289+
const html = await getSerializedHtml(page);
290+
expect(html).toContain('<p style="text-align: right;">Hello</p>');
291+
});
292+
293+
test('right alignment preserved when toggling paragraph to blockquote', async ({
294+
page,
295+
}) => {
296+
await setEditorHtml(
297+
page,
298+
'<html><p style="text-align: right">Hello</p></html>'
299+
);
300+
await page.locator('.eti-editor p').click();
301+
await toolbarButton(page, 'blockQuote').click();
302+
const html = await getSerializedHtml(page);
303+
expect(html).toContain('<blockquote>');
304+
expect(html).toContain('<p style="text-align: right;">Hello</p>');
305+
});
306+
307+
test('center alignment preserved when toggling H1 to ordered list', async ({
308+
page,
309+
}) => {
310+
await setEditorHtml(
311+
page,
312+
'<html><h1 style="text-align: center">Hello</h1></html>'
313+
);
314+
await page.locator('.eti-editor h1').click();
315+
await toolbarButton(page, 'orderedList').click();
316+
const html = await getSerializedHtml(page);
317+
expect(html).toContain('<ol style="text-align: center;">');
318+
expect(html).toContain('Hello');
319+
});
320+
});
321+
322+
test.describe('alignment visual', () => {
323+
test.beforeEach(async ({ page }) => {
324+
await gotoVisualRegression(page);
325+
});
326+
327+
test('mixed paragraph, heading, and list alignments render correctly', async ({
328+
page,
329+
}) => {
330+
await setEditorHtml(
331+
page,
332+
'<html>' +
333+
'<p>Left aligned</p>' +
334+
'<p style="text-align: center">Centre aligned</p>' +
335+
'<h6 style="text-align: center">Heading 6 Centre</h6>' +
336+
'<p style="text-align: right">Right aligned</p>' +
337+
'<ol style="text-align: right"><li>Element 1</li><li>Element 2</li></ol>' +
338+
'</html>'
339+
);
340+
await expect(editorLocator(page)).toHaveScreenshot('alignment-mixed.png');
341+
});
342+
});

apps/example-web/src/components/Toolbar.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ interface ToolbarButtonProps {
1717
label: string;
1818
testId: string;
1919
isActive: boolean;
20-
isDisabled: boolean;
20+
isDisabled?: boolean;
2121
variant?: string;
2222
onPress: () => void;
2323
}
@@ -202,6 +202,16 @@ export function Toolbar({
202202
onPress: (editor: EnrichedTextInputInstance | null) => void;
203203
}[];
204204

205+
const alignmentItems: {
206+
label: string;
207+
value: 'left' | 'center' | 'right' | 'justify';
208+
}[] = [
209+
{ label: '←', value: 'left' },
210+
{ label: '↔', value: 'center' },
211+
{ label: '→', value: 'right' },
212+
{ label: '≡', value: 'justify' },
213+
];
214+
205215
return (
206216
<div className="toolbar">
207217
<div className="toolbar-controls" {...dragScroll}>
@@ -218,6 +228,17 @@ export function Toolbar({
218228
}}
219229
/>
220230
))}
231+
{alignmentItems.map((item) => (
232+
<ToolbarButton
233+
key={`alignment-${item.value}`}
234+
label={item.label}
235+
testId={`toolbar-button-alignment-${item.value}`}
236+
isActive={s?.alignment === item.value}
237+
onPress={() => {
238+
editorRef.current?.setTextAlignment(item.value);
239+
}}
240+
/>
241+
))}
221242
</div>
222243
<div className="toolbar-fill" aria-hidden="true" />
223244
</div>

0 commit comments

Comments
 (0)