Skip to content

Commit ec13aa7

Browse files
authored
feat(web): mentions (#594)
# Summary + **what is missing**: tests, mention styling. Those will be separate PRs as this one is already big enough. + adds mentions to web version of the library + adds controls in the example-web app for mentions + implemented all ref functions related to mentions: `setMention` and `startMention` + implemented all props related to mentions: `onMentionDetected`, `onStartMention`, `onChangeMention`, `onEndMention` + mentions are created as a custom mark, with fully custom state tracking and logic for: removing marks when a single character changes, striping partial mentions during paste etc. All of this custom logic lives in `pmPlugins/mentionPlugin` ## Test Plan + Run example app and test out mentions you can also verify logs related to mentions in the console ## Screenshots / Videos https://github.com/user-attachments/assets/b9028481-4f46-4307-b2a3-1eeeab7c220f ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ❌ | | Web | ✅ | ## Checklist - [x] E2E tests are passing - [ ] Required E2E tests have been added (if applicable)
1 parent 15f6bfb commit ec13aa7

42 files changed

Lines changed: 1786 additions & 139 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
3.12 KB
Loading

.playwright/tests/images.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ test.describe('images', () => {
169169
const editor = editorLocator(page);
170170
for (const key of toolbarOrder) {
171171
await editor.click();
172+
await expect(
173+
editor.locator('[contenteditable="true"]').first()
174+
).toBeFocused();
172175
await editor.press('Meta+A');
173176
await toolbarButton(page, key).click();
174177
await expect

.playwright/tests/mentions.spec.ts

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
import {
3+
editorLocator,
4+
gotoVisualRegression,
5+
setEditorHtml,
6+
} from '../helpers/visual-regression';
7+
8+
test.setTimeout(90_000);
9+
10+
const sel = {
11+
editorContent: '[data-testid="mention-test-editor"] .eti-editor',
12+
eventType: '[data-testid="mention-event-type"]',
13+
eventIndicator: '[data-testid="mention-event-indicator"]',
14+
eventText: '[data-testid="mention-event-text"]',
15+
htmlOutput: '[data-testid="mention-html-output"]',
16+
detectedCount: '[data-testid="mention-detected-count"]',
17+
detectedText: '[data-testid="mention-detected-text"]',
18+
detectedIndicator: '[data-testid="mention-detected-indicator"]',
19+
blurTarget: '[data-testid="mention-blur-target"]',
20+
setUserButton: '[data-testid="mention-set-user-button"]',
21+
setChannelButton: '[data-testid="mention-set-channel-button"]',
22+
startUserButton: '[data-testid="mention-start-user-button"]',
23+
startChannelButton: '[data-testid="mention-start-channel-button"]',
24+
} as const;
25+
26+
async function gotoMentionTest(page: Page): Promise<void> {
27+
await page.goto('/test-mentions');
28+
await page.waitForSelector(sel.editorContent);
29+
}
30+
31+
function mentionEditor(page: Page) {
32+
return page.locator(sel.editorContent);
33+
}
34+
35+
function eventType(page: Page) {
36+
return page.locator(sel.eventType);
37+
}
38+
function eventIndicator(page: Page) {
39+
return page.locator(sel.eventIndicator);
40+
}
41+
function eventText(page: Page) {
42+
return page.locator(sel.eventText);
43+
}
44+
function htmlOutput(page: Page) {
45+
return page.locator(sel.htmlOutput);
46+
}
47+
function detectedCount(page: Page) {
48+
return page.locator(sel.detectedCount);
49+
}
50+
function detectedText(page: Page) {
51+
return page.locator(sel.detectedText);
52+
}
53+
function detectedIndicator(page: Page) {
54+
return page.locator(sel.detectedIndicator);
55+
}
56+
57+
test('@ triggers start event', async ({ page }) => {
58+
await gotoMentionTest(page);
59+
const editor = mentionEditor(page);
60+
await editor.click();
61+
await editor.pressSequentially('@', { delay: 80 });
62+
await expect(eventType(page)).toHaveText('start');
63+
await expect(eventIndicator(page)).toHaveText('@');
64+
await expect(eventText(page)).toHaveText('');
65+
});
66+
67+
test('# triggers start event', async ({ page }) => {
68+
await gotoMentionTest(page);
69+
const editor = mentionEditor(page);
70+
await editor.click();
71+
await editor.pressSequentially('#', { delay: 80 });
72+
await expect(eventType(page)).toHaveText('start');
73+
await expect(eventIndicator(page)).toHaveText('#');
74+
});
75+
76+
test('query text updates on each keystroke', async ({ page }) => {
77+
await gotoMentionTest(page);
78+
const editor = mentionEditor(page);
79+
await editor.click();
80+
await editor.pressSequentially('@Jane', { delay: 80 });
81+
await expect(eventText(page)).toHaveText('Jane');
82+
});
83+
84+
test('one space in query keeps mention active', async ({ page }) => {
85+
await gotoMentionTest(page);
86+
const editor = mentionEditor(page);
87+
await editor.click();
88+
await editor.pressSequentially('@a ', { delay: 80 });
89+
await expect(eventType(page)).toHaveText('change');
90+
});
91+
92+
test('two spaces end the mention', async ({ page }) => {
93+
await gotoMentionTest(page);
94+
const editor = mentionEditor(page);
95+
await editor.click();
96+
await editor.pressSequentially('@a ', { delay: 80 });
97+
await expect(eventType(page)).toHaveText('end');
98+
});
99+
100+
test('blur ends an active mention', async ({ page }) => {
101+
await gotoMentionTest(page);
102+
const editor = mentionEditor(page);
103+
await editor.click();
104+
await editor.pressSequentially('@Jane', { delay: 80 });
105+
await page.locator(sel.blurTarget).click();
106+
await expect(eventType(page)).toHaveText('end');
107+
});
108+
109+
test('setMention outputs correct HTML attributes', async ({ page }) => {
110+
await gotoMentionTest(page);
111+
const editor = mentionEditor(page);
112+
await editor.click();
113+
await editor.pressSequentially('@', { delay: 80 });
114+
await page.locator(sel.setUserButton).click();
115+
await expect
116+
.poll(async () => {
117+
const html = await htmlOutput(page).textContent();
118+
return (
119+
html?.includes('indicator="@"') &&
120+
html?.includes('text="Jane"') &&
121+
html?.includes('id="1"')
122+
);
123+
})
124+
.toBe(true);
125+
});
126+
127+
test('typing after setMention is not wrapped in mention', async ({ page }) => {
128+
await gotoMentionTest(page);
129+
const editor = mentionEditor(page);
130+
await editor.click();
131+
await editor.pressSequentially('@', { delay: 80 });
132+
await page.locator(sel.setUserButton).click();
133+
await expect
134+
.poll(async () =>
135+
(await htmlOutput(page).textContent())?.includes('<mention')
136+
)
137+
.toBe(true);
138+
await editor.click();
139+
await editor.press('End');
140+
await editor.pressSequentially(' more', { delay: 80 });
141+
await expect
142+
.poll(async () => {
143+
const html = await htmlOutput(page).textContent();
144+
return (
145+
html?.includes('Jane</mention>') && !html?.includes('more</mention>')
146+
);
147+
})
148+
.toBe(true);
149+
});
150+
151+
test('setMention with # indicator outputs correct HTML attributes', async ({
152+
page,
153+
}) => {
154+
await gotoMentionTest(page);
155+
const editor = mentionEditor(page);
156+
await editor.click();
157+
await editor.pressSequentially('#', { delay: 80 });
158+
await page.locator(sel.setChannelButton).click();
159+
await expect
160+
.poll(async () => {
161+
const html = await htmlOutput(page).textContent();
162+
return (
163+
html?.includes('indicator="#"') &&
164+
html?.includes('text="general"') &&
165+
html?.includes('id="42"')
166+
);
167+
})
168+
.toBe(true);
169+
});
170+
171+
test('startMention (user) fires onStartMention', async ({ page }) => {
172+
await gotoMentionTest(page);
173+
const editor = mentionEditor(page);
174+
await editor.click();
175+
await page.locator(sel.startUserButton).click();
176+
await expect(eventType(page)).toHaveText('start');
177+
await expect(eventIndicator(page)).toHaveText('@');
178+
});
179+
180+
test('startMention (channel) fires onStartMention', async ({ page }) => {
181+
await gotoMentionTest(page);
182+
const editor = mentionEditor(page);
183+
await editor.click();
184+
await page.locator(sel.startChannelButton).click();
185+
await expect(eventType(page)).toHaveText('start');
186+
await expect(eventIndicator(page)).toHaveText('#');
187+
});
188+
189+
test('entering a mention fires onMentionDetected with correct data', async ({
190+
page,
191+
}) => {
192+
await gotoMentionTest(page);
193+
const editor = mentionEditor(page);
194+
await editor.click();
195+
await editor.pressSequentially('@', { delay: 80 });
196+
await page.locator(sel.setUserButton).click();
197+
await expect
198+
.poll(async () =>
199+
(await htmlOutput(page).textContent())?.includes('<mention')
200+
)
201+
.toBe(true);
202+
await editor.click();
203+
await editor.press('End');
204+
await editor.press('ArrowLeft'); // skip trailing space after mention
205+
await editor.press('ArrowLeft'); // caret inside mention text
206+
await expect(detectedCount(page)).toHaveText('1');
207+
await expect(detectedText(page)).toHaveText('Jane');
208+
await expect(detectedIndicator(page)).toHaveText('@');
209+
});
210+
211+
test('moving within the same mention does not re-fire onMentionDetected', async ({
212+
page,
213+
}) => {
214+
await gotoMentionTest(page);
215+
const editor = mentionEditor(page);
216+
await editor.click();
217+
await editor.pressSequentially('@', { delay: 80 });
218+
await page.locator(sel.setUserButton).click();
219+
await expect
220+
.poll(async () =>
221+
(await htmlOutput(page).textContent())?.includes('<mention')
222+
)
223+
.toBe(true);
224+
await editor.click();
225+
await editor.press('End');
226+
await editor.press('ArrowLeft');
227+
await editor.press('ArrowLeft'); // enter mention - count = 1
228+
await editor.press('ArrowLeft'); // move within mention
229+
await editor.press('ArrowRight'); // move within mention
230+
await expect(detectedCount(page)).toHaveText('1');
231+
});
232+
233+
test('moving out of a mention does not increment detected count', async ({
234+
page,
235+
}) => {
236+
await gotoMentionTest(page);
237+
const editor = mentionEditor(page);
238+
await editor.click();
239+
await editor.pressSequentially('@', { delay: 80 });
240+
await page.locator(sel.setUserButton).click();
241+
await expect
242+
.poll(async () =>
243+
(await htmlOutput(page).textContent())?.includes('<mention')
244+
)
245+
.toBe(true);
246+
await editor.click();
247+
await editor.press('Home');
248+
await editor.press('ArrowRight');
249+
await editor.press('ArrowRight');
250+
await editor.press('ArrowRight');
251+
await editor.press('End');
252+
await editor.press('Enter');
253+
await expect(detectedCount(page)).toHaveText('1');
254+
});
255+
256+
test('mention renders correctly', async ({ page }) => {
257+
await gotoVisualRegression(page);
258+
await setEditorHtml(
259+
page,
260+
'<html><p>Hello <mention indicator="@" text="@Jane" id="1">@Jane</mention> world</p></html>'
261+
);
262+
await expect(editorLocator(page)).toHaveScreenshot('mention-visual.png');
263+
});

.playwright/tests/strictMarks.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,4 +313,29 @@ test.describe('strict marks', () => {
313313
})
314314
.toBe(true);
315315
});
316+
317+
test('typing after the last mention character does not extend the mention', async ({
318+
page,
319+
}) => {
320+
await setEditorHtml(
321+
page,
322+
'<html><p><mention text="@Jane" indicator="@" id="1" type="user">@Jane</mention></p></html>'
323+
);
324+
325+
const editor = editorLocator(page);
326+
await editor.click();
327+
await editor.press('End');
328+
await editor.pressSequentially(' after', { delay: 80 });
329+
330+
await expect
331+
.poll(async () => {
332+
const html = await getSerializedHtml(page);
333+
return (
334+
html.includes(
335+
'<mention text="@Jane" indicator="@" id="1" type="user">@Jane</mention>'
336+
) && !html.includes('@Jane after</mention>')
337+
);
338+
})
339+
.toBe(true);
340+
});
316341
});

.playwright/tests/testLinks.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ test.describe('test-links onLinkDetected', () => {
338338

339339
const editor = page.locator(sel.editorInner);
340340
await editor.click();
341+
await expect(editor.locator('.ProseMirror')).toBeFocused();
341342
await editor.press('End');
342343
await editor.press('Enter');
343344

apps/example-web/src/App.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ body {
3737
margin: 0 0 24px;
3838
}
3939

40+
.editor-mention-host {
41+
position: relative;
42+
width: 100%;
43+
align-self: stretch;
44+
}
45+
46+
.editor-mention-host--mention-open {
47+
z-index: 100;
48+
}
49+
4050
.btn {
4151
flex: 1;
4252
padding: 16px;

0 commit comments

Comments
 (0)