Skip to content

Commit 4f63738

Browse files
authored
feat(web): images (#576)
<!-- Thanks for submitting a pull request! We appreciate you spending the time to work on these changes. Please follow the template so that the reviewers can easily understand what the code changes affect --> # Summary + adds images to the web version + added e2e tests for them + **what's missing**: pasting images and thus handling for `onPasteImages` callback. **Differences compare to native**: + On Android, applying a link to a selection that includes an image behaves differently than it does on iOS. The web uses the iOS behavior in this case. https://github.com/user-attachments/assets/494556be-4998-4a15-a14d-ae7a8f24875a + The cursor behaves a bit differently compared to native. On web when using the arrow keys we can enter into selection state that you can achieve on iOS by double clicking on the image. The native version "skips" over the image. Changing this would require writing low level plugins for ProseMirror which can often introduce subtle bugs and would be difficult to get right. That said, I'm open to changing this if it’s considered crucial. https://github.com/user-attachments/assets/91e6ec3a-a6a6-4f53-86fe-8d827cefa734 ## Test Plan + Run `yarn test:e2e:web` make sure all the new e2e tests pass. + Run `yarn example-web dev` and play around with images. ## Screenshots / Videos Include any visual proof that helps reviewers understand the change — UI updates, bug reproduction or the result of the fix. ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ❌ | | Web | ✅ | ## Checklist - [x] E2E tests are passing - [x] Required E2E tests have been added (if applicable)
1 parent e9999fc commit 4f63738

29 files changed

Lines changed: 628 additions & 28 deletions
2.84 KB
Loading
3.05 KB
Loading
557 Bytes
Loading
1.98 KB
Loading
2.87 KB
Loading
2.65 KB
Loading
1.19 KB
Loading
2 KB
Loading
2.2 KB
Loading

.playwright/tests/images.spec.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { test, expect } 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+
const VISIBILITY_TIMEOUT_MS = 15_000;
12+
13+
test.describe('images', () => {
14+
test.beforeEach(async ({ page }) => {
15+
await gotoVisualRegression(page);
16+
17+
const routePattern = '**/pw-e2e-ok.png';
18+
const pngBody = Buffer.from(
19+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
20+
'base64'
21+
);
22+
await page.route(routePattern, async (route) => {
23+
await route.fulfill({
24+
status: 200,
25+
contentType: 'image/png',
26+
body: pngBody,
27+
});
28+
});
29+
});
30+
31+
test('empty src shows placeholder only', async ({ page }) => {
32+
const snapshotName = 'images-placeholder-empty-src.png';
33+
await setEditorHtml(
34+
page,
35+
'<html><p>Hi <img src="" width="40" height="40" /> bye</p></html>'
36+
);
37+
38+
await expect(page.locator('[data-eti-image-placeholder]')).toBeVisible({
39+
timeout: VISIBILITY_TIMEOUT_MS,
40+
});
41+
await expect(page.locator('.eti-inline-image-img')).toHaveCount(0);
42+
43+
await expect(editorLocator(page)).toHaveScreenshot(snapshotName);
44+
});
45+
46+
test('broken image URL shows placeholder after onError', async ({ page }) => {
47+
const routePattern = '**/pw-e2e-broken.png';
48+
const snapshotName = 'images-placeholder-broken-url.png';
49+
await page.route(routePattern, (route) => route.abort());
50+
51+
await setEditorHtml(
52+
page,
53+
'<html><p><img src="/pw-e2e-broken.png" width="40" height="40" /></p></html>'
54+
);
55+
56+
await expect(page.locator('[data-eti-image-placeholder]')).toBeVisible({
57+
timeout: VISIBILITY_TIMEOUT_MS,
58+
});
59+
await expect(page.locator('.eti-inline-image-img')).toHaveCount(0);
60+
61+
await expect(editorLocator(page)).toHaveScreenshot(snapshotName);
62+
});
63+
64+
test('real PNG shows img element', async ({ page }) => {
65+
const snapshotName = 'images-inline-routed-png.png';
66+
67+
await setEditorHtml(
68+
page,
69+
'<html><p><img src="/pw-e2e-ok.png" width="32" height="32" /></p></html>'
70+
);
71+
72+
await expect(page.locator('.eti-inline-image-img')).toBeVisible({
73+
timeout: VISIBILITY_TIMEOUT_MS,
74+
});
75+
await expect(page.locator('[data-eti-image-placeholder]')).toHaveCount(0);
76+
77+
await expect(editorLocator(page)).toHaveScreenshot(snapshotName);
78+
});
79+
80+
test('image attrs round-trip in serialized HTML', async ({ page }) => {
81+
await setEditorHtml(
82+
page,
83+
'<html><p><img src="https://example.com/pic.png" width="120" height="60" alt="x" /></p></html>'
84+
);
85+
86+
await expect
87+
.poll(async () => getSerializedHtml(page))
88+
.toMatch(/<img[^>]*src="https:\/\/example\.com\/pic\.png"/i);
89+
await expect
90+
.poll(async () => getSerializedHtml(page))
91+
.toMatch(/width="120"/i);
92+
await expect
93+
.poll(async () => getSerializedHtml(page))
94+
.toMatch(/height="60"/i);
95+
await expect
96+
.poll(async () => getSerializedHtml(page))
97+
.not.toMatch(/alt="x"/i);
98+
});
99+
100+
test.describe('visual: image with blocks and lists', () => {
101+
const visualBlockListCases = [
102+
{
103+
name: 'paragraph with placeholder image',
104+
snapshot: 'images-paragraph-placeholder.png',
105+
html: '<html><p>Before <img src="" width="40" height="40" /> after</p></html>',
106+
},
107+
{
108+
name: 'blockquote with placeholder image',
109+
snapshot: 'images-blockquote-placeholder.png',
110+
html: '<html><blockquote><p>Before <img src="" width="40" height="40" /> after</p></blockquote></html>',
111+
},
112+
{
113+
name: 'unordered list item with placeholder image',
114+
snapshot: 'images-ul-placeholder.png',
115+
html: '<html><ul><li><p>Item <img src="" width="40" height="40" /> end</p></li></ul></html>',
116+
},
117+
{
118+
name: 'ordered list item with placeholder image',
119+
snapshot: 'images-ol-placeholder.png',
120+
html: '<html><ol><li><p>Step <img src="" width="40" height="40" /> done</p></li></ol></html>',
121+
},
122+
{
123+
name: 'loaded image inside list (routed PNG)',
124+
snapshot: 'images-loaded-in-list.png',
125+
html: '<html><ul><li><p>See <img src="/pw-e2e-ok.png" width="28" height="28" /> tiny</p></li></ul></html>',
126+
},
127+
{
128+
name: 'placeholder image inside checkbox list',
129+
snapshot: 'images-checkbox-list-placeholder.png',
130+
html: '<html><ul data-type="checkbox"><li checked>Before <img src="" width="40" height="40" /> after</li></ul></html>',
131+
},
132+
] as const;
133+
134+
for (const row of visualBlockListCases) {
135+
test(row.name, async ({ page }) => {
136+
await setEditorHtml(page, row.html);
137+
138+
await expect(page.locator('.eti-inline-image')).toBeVisible({
139+
timeout: VISIBILITY_TIMEOUT_MS,
140+
});
141+
142+
await expect(editorLocator(page)).toHaveScreenshot(row.snapshot);
143+
});
144+
}
145+
});
146+
147+
test('strip marks on image: bold italic strike underline code leave img unwrapped', async ({
148+
page,
149+
}) => {
150+
const expectedHtml =
151+
'<html><p><code><b><i><u><s>Alpha </s></u></i></b></code><img src="" width="48" height="48"/><code><b><i><u><s> Beta</s></u></i></b></code></p></html>';
152+
const toolbarOrder = [
153+
'bold',
154+
'italic',
155+
'strikeThrough',
156+
'underline',
157+
'inlineCode',
158+
] as const;
159+
160+
await setEditorHtml(
161+
page,
162+
'<html><p>Alpha <img src="" width="48" height="48"/> Beta</p></html>'
163+
);
164+
165+
await expect(page.locator('.eti-inline-image')).toBeVisible({
166+
timeout: VISIBILITY_TIMEOUT_MS,
167+
});
168+
169+
const editor = editorLocator(page);
170+
for (const key of toolbarOrder) {
171+
await editor.click();
172+
await editor.press('Meta+A');
173+
await toolbarButton(page, key).click();
174+
await expect
175+
.poll(
176+
async () => {
177+
const cls =
178+
(await toolbarButton(page, key).getAttribute('class')) ?? '';
179+
return cls.includes('toolbar-btn--active');
180+
},
181+
{ timeout: VISIBILITY_TIMEOUT_MS }
182+
)
183+
.toBe(true);
184+
}
185+
186+
await expect.poll(async () => getSerializedHtml(page)).toBe(expectedHtml);
187+
});
188+
});

0 commit comments

Comments
 (0)