Skip to content

Commit 5729cf4

Browse files
authored
Merge pull request #6300 from ueberdosis/feature/mention-multiple-trigger
Multiple triggers on mention component
2 parents df3d999 + 40d2f3b commit 5729cf4

File tree

17 files changed

+1245
-74
lines changed

17 files changed

+1245
-74
lines changed

.changeset/wise-books-kiss.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tiptap/extension-mention': minor
3+
---
4+
5+
Support multiple triggers in Mention extension

demos/src/Commands/SetContent/React/index.spec.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ context('/src/Commands/SetContent/React/', () => {
106106
editor.commands.setContent('<p><span data-type="mention" data-id="1" data-label="John Doe">@John Doe</span></p>')
107107
cy.get('.tiptap').should(
108108
'contain.html',
109-
'<span data-type="mention" data-id="1" data-label="John Doe" contenteditable="false">@John Doe</span>',
109+
'<span data-type="mention" data-id="1" data-label="John Doe" data-mention-suggestion-char="@" contenteditable="false">@John Doe</span>',
110110
)
111111
})
112112
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import './MentionList.scss'
2+
3+
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
4+
5+
export default forwardRef((props, ref) => {
6+
const [selectedIndex, setSelectedIndex] = useState(0)
7+
8+
const selectItem = index => {
9+
const item = props.items[index]
10+
11+
if (item) {
12+
props.command({ id: item })
13+
}
14+
}
15+
16+
const upHandler = () => {
17+
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
18+
}
19+
20+
const downHandler = () => {
21+
setSelectedIndex((selectedIndex + 1) % props.items.length)
22+
}
23+
24+
const enterHandler = () => {
25+
selectItem(selectedIndex)
26+
}
27+
28+
useEffect(() => setSelectedIndex(0), [props.items])
29+
30+
useImperativeHandle(ref, () => ({
31+
onKeyDown: ({ event }) => {
32+
if (event.key === 'ArrowUp') {
33+
upHandler()
34+
return true
35+
}
36+
37+
if (event.key === 'ArrowDown') {
38+
downHandler()
39+
return true
40+
}
41+
42+
if (event.key === 'Enter') {
43+
enterHandler()
44+
return true
45+
}
46+
47+
return false
48+
},
49+
}))
50+
51+
return (
52+
<div className="dropdown-menu">
53+
{props.items.length ? (
54+
props.items.map((item, index) => (
55+
<button
56+
className={index === selectedIndex ? 'is-selected' : ''}
57+
key={index}
58+
onClick={() => selectItem(index)}
59+
>
60+
{item}
61+
</button>
62+
))
63+
) : (
64+
<div className="item">No result</div>
65+
)}
66+
</div>
67+
)
68+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/* Dropdown menu */
2+
.dropdown-menu {
3+
background: var(--white);
4+
border: 1px solid var(--gray-1);
5+
border-radius: 0.7rem;
6+
box-shadow: var(--shadow);
7+
display: flex;
8+
flex-direction: column;
9+
gap: 0.1rem;
10+
overflow: auto;
11+
padding: 0.4rem;
12+
position: relative;
13+
14+
button {
15+
align-items: center;
16+
background-color: transparent;
17+
display: flex;
18+
gap: 0.25rem;
19+
text-align: left;
20+
width: 100%;
21+
22+
&:hover,
23+
&:hover.is-selected {
24+
background-color: var(--gray-3);
25+
}
26+
27+
&.is-selected {
28+
background-color: var(--gray-2);
29+
}
30+
}
31+
}

demos/src/Examples/MultiMention/React/index.html

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import './styles.scss'
2+
3+
import Document from '@tiptap/extension-document'
4+
import Mention from '@tiptap/extension-mention'
5+
import Paragraph from '@tiptap/extension-paragraph'
6+
import Text from '@tiptap/extension-text'
7+
import { EditorContent, useEditor } from '@tiptap/react'
8+
import React from 'react'
9+
10+
import suggestions from './suggestions.js'
11+
12+
export default () => {
13+
const editor = useEditor({
14+
extensions: [
15+
Document,
16+
Paragraph,
17+
Text,
18+
Mention.configure({
19+
HTMLAttributes: {
20+
class: 'mention',
21+
},
22+
suggestions,
23+
}),
24+
],
25+
content: `
26+
<p>Hi everyone! Don’t forget the daily stand up at 8 AM.</p>
27+
<p>We will talk about the movies: <span data-type="mention" data-id="Dirty Dancing" data-mention-suggestion-char="#"></span>, <span data-type="mention" data-id="Pirates of the Caribbean" data-mention-suggestion-char="#"></span> and <span data-type="mention" data-id="The Matrix" data-mention-suggestion-char="#"></span>.</p>
28+
<p><span data-type="mention" data-id="Jennifer Grey"></span> Would you mind to share what you’ve been working on lately? We fear not much happened since <span data-type="mention" data-id="Dirty Dancing" data-mention-suggestion-char="#"></span>.</p>
29+
<p><span data-type="mention" data-id="Winona Ryder"></span> <span data-type="mention" data-id="Axl Rose"></span> Let’s go through your most important points quickly.</p>
30+
<p>I have a meeting with <span data-type="mention" data-id="Christina Applegate"></span> and don’t want to come late.</p>
31+
<p>– Thanks, your big boss</p>
32+
`,
33+
})
34+
35+
if (!editor) {
36+
return null
37+
}
38+
39+
return <EditorContent editor={editor} />
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
context('/src/Examples/MultiMention/React/', () => {
2+
beforeEach(() => {
3+
cy.visit('/src/Examples/MultiMention/React/')
4+
})
5+
6+
describe('Person mentions (@)', () => {
7+
it('should insert a person mention', () => {
8+
cy.get('.tiptap').then(([{ editor }]) => {
9+
editor.commands.setContent('<p><span data-type="mention" data-id="Lea Thompson">@Lea Thompson</span></p>')
10+
cy.get('.tiptap').should(
11+
'contain.html',
12+
'<span class="mention" data-type="mention" data-id="Lea Thompson" data-mention-suggestion-char="@" contenteditable="false">@Lea Thompson</span>',
13+
)
14+
})
15+
})
16+
17+
it("should open a dropdown menu when I type '@'", () => {
18+
cy.get('.tiptap').type('{selectall}{backspace}@')
19+
cy.get('.dropdown-menu').should('exist')
20+
})
21+
22+
it('should display the correct person options in the dropdown menu', () => {
23+
cy.get('.tiptap').type('{selectall}{backspace}@')
24+
cy.get('.dropdown-menu').should('exist')
25+
cy.get('.dropdown-menu button').should('have.length', 5)
26+
cy.get('.dropdown-menu button:nth-child(1)')
27+
.should('contain.text', 'Lea Thompson')
28+
.and('have.class', 'is-selected')
29+
cy.get('.dropdown-menu button:nth-child(2)').should('contain.text', 'Cyndi Lauper')
30+
cy.get('.dropdown-menu button:nth-child(3)').should('contain.text', 'Tom Cruise')
31+
cy.get('.dropdown-menu button:nth-child(4)').should('contain.text', 'Madonna')
32+
cy.get('.dropdown-menu button:nth-child(5)').should('contain.text', 'Jerry Hall')
33+
})
34+
35+
it('should insert Cyndi Lauper mention when clicking on her option', () => {
36+
cy.get('.tiptap').type('{selectall}{backspace}@')
37+
cy.get('.dropdown-menu').should('exist')
38+
cy.get('.dropdown-menu button:nth-child(2)').contains('Cyndi Lauper').click()
39+
40+
cy.get('.tiptap').should(
41+
'contain.html',
42+
'<span class="mention" data-type="mention" data-id="Cyndi Lauper" data-mention-suggestion-char="@" contenteditable="false">@Cyndi Lauper</span>',
43+
)
44+
})
45+
46+
it('should close the dropdown menu when I move the cursor outside the editor', () => {
47+
cy.get('.tiptap').type('{selectall}{backspace}@')
48+
cy.get('.dropdown-menu').should('exist')
49+
cy.get('.tiptap').type('{moveToStart}')
50+
cy.get('.dropdown-menu').should('not.exist')
51+
})
52+
53+
it('should close the dropdown menu when I press the escape key', () => {
54+
cy.get('.tiptap').type('{selectall}{backspace}@')
55+
cy.get('.dropdown-menu').should('exist')
56+
cy.get('.tiptap').type('{esc}')
57+
cy.get('.dropdown-menu').should('not.exist')
58+
})
59+
60+
it('should insert Tom Cruise when selecting his option with the arrow keys and pressing the enter key', () => {
61+
cy.get('.tiptap').type('{selectall}{backspace}@')
62+
cy.get('.dropdown-menu').should('exist')
63+
cy.get('.tiptap').type('{downarrow}{downarrow}')
64+
cy.get('.dropdown-menu button:nth-child(3)').should('have.class', 'is-selected')
65+
cy.get('.tiptap').type('{enter}')
66+
67+
cy.get('.tiptap').should(
68+
'contain.html',
69+
'<span class="mention" data-type="mention" data-id="Tom Cruise" data-mention-suggestion-char="@" contenteditable="false">@Tom Cruise</span>',
70+
)
71+
})
72+
73+
it('should show a "No result" message when I search for a person that is not in the list', () => {
74+
cy.get('.tiptap').type('{selectall}{backspace}@nonexistent')
75+
cy.get('.dropdown-menu').should('exist')
76+
cy.get('.dropdown-menu').should('contain.text', 'No result')
77+
})
78+
79+
it('should only show the Madonna option in the dropdown when I type "@mado"', () => {
80+
cy.get('.tiptap').type('{selectall}{backspace}@mado')
81+
cy.get('.dropdown-menu').should('exist')
82+
cy.get('.dropdown-menu button').should('have.length', 1)
83+
cy.get('.dropdown-menu button:nth-child(1)').should('contain.text', 'Madonna')
84+
})
85+
86+
it('should insert Madonna when I type "@mado" and hit enter', () => {
87+
cy.get('.tiptap').type('{selectall}{backspace}@mado{enter}')
88+
cy.get('.tiptap').should(
89+
'contain.html',
90+
'<span class="mention" data-type="mention" data-id="Madonna" data-mention-suggestion-char="@" contenteditable="false">@Madonna</span>',
91+
)
92+
})
93+
})
94+
95+
describe('Movie mentions (#)', () => {
96+
it('should insert a movie mention', () => {
97+
cy.get('.tiptap').then(([{ editor }]) => {
98+
editor.commands.setContent(
99+
'<p><span data-type="mention" data-id="The Matrix" data-mention-suggestion-char="#">#The Matrix</span></p>',
100+
)
101+
cy.get('.tiptap').should(
102+
'contain.html',
103+
'<span class="mention" data-type="mention" data-id="The Matrix" data-mention-suggestion-char="#" contenteditable="false">#The Matrix</span>',
104+
)
105+
})
106+
})
107+
108+
it("should open a dropdown menu when I type '#'", () => {
109+
cy.get('.tiptap').type('{selectall}{backspace}#')
110+
cy.get('.dropdown-menu').should('exist')
111+
})
112+
113+
it('should display the correct movie options in the dropdown menu', () => {
114+
cy.get('.tiptap').type('{selectall}{backspace}#')
115+
cy.get('.dropdown-menu').should('exist')
116+
cy.get('.dropdown-menu button').should('have.length', 3)
117+
cy.get('.dropdown-menu button:nth-child(1)')
118+
.should('contain.text', 'Dirty Dancing')
119+
.and('have.class', 'is-selected')
120+
cy.get('.dropdown-menu button:nth-child(2)').should('contain.text', 'Pirates of the Caribbean')
121+
cy.get('.dropdown-menu button:nth-child(3)').should('contain.text', 'The Matrix')
122+
})
123+
124+
it('should insert Pirates of the Caribbean mention when clicking on its option', () => {
125+
cy.get('.tiptap').type('{selectall}{backspace}#')
126+
cy.get('.dropdown-menu').should('exist')
127+
cy.get('.dropdown-menu button:nth-child(2)').contains('Pirates of the Caribbean').click()
128+
129+
cy.get('.tiptap').should(
130+
'contain.html',
131+
'<span class="mention" data-type="mention" data-id="Pirates of the Caribbean" data-mention-suggestion-char="#" contenteditable="false">#Pirates of the Caribbean</span>',
132+
)
133+
})
134+
135+
it('should close the dropdown menu when I move the cursor outside the editor', () => {
136+
cy.get('.tiptap').type('{selectall}{backspace}#')
137+
cy.get('.dropdown-menu').should('exist')
138+
cy.get('.tiptap').type('{moveToStart}')
139+
cy.get('.dropdown-menu').should('not.exist')
140+
})
141+
142+
it('should close the dropdown menu when I press the escape key', () => {
143+
cy.get('.tiptap').type('{selectall}{backspace}#')
144+
cy.get('.dropdown-menu').should('exist')
145+
cy.get('.tiptap').type('{esc}')
146+
cy.get('.dropdown-menu').should('not.exist')
147+
})
148+
149+
it('should insert The Matrix when selecting its option with the arrow keys and pressing the enter key', () => {
150+
cy.get('.tiptap').type('{selectall}{backspace}#')
151+
cy.get('.dropdown-menu').should('exist')
152+
cy.get('.tiptap').type('{downarrow}{downarrow}')
153+
cy.get('.dropdown-menu button:nth-child(3)').should('have.class', 'is-selected')
154+
cy.get('.tiptap').type('{enter}')
155+
156+
cy.get('.tiptap').should(
157+
'contain.html',
158+
'<span class="mention" data-type="mention" data-id="The Matrix" data-mention-suggestion-char="#" contenteditable="false">#The Matrix</span>',
159+
)
160+
})
161+
162+
it('should show a "No result" message when I search for a movie that is not in the list', () => {
163+
cy.get('.tiptap').type('{selectall}{backspace}#nonexistent')
164+
cy.get('.dropdown-menu').should('exist')
165+
cy.get('.dropdown-menu').should('contain.text', 'No result')
166+
})
167+
168+
it('should only show the Dirty Dancing option in the dropdown when I type "#dir"', () => {
169+
cy.get('.tiptap').type('{selectall}{backspace}#dir')
170+
cy.get('.dropdown-menu').should('exist')
171+
cy.get('.dropdown-menu button').should('have.length', 1)
172+
cy.get('.dropdown-menu button:nth-child(1)').should('contain.text', 'Dirty Dancing')
173+
})
174+
175+
it('should insert Dirty Dancing when I type "#dir" and hit enter', () => {
176+
cy.get('.tiptap').type('{selectall}{backspace}#dir{enter}')
177+
cy.get('.tiptap').should(
178+
'contain.html',
179+
'<span class="mention" data-type="mention" data-id="Dirty Dancing" data-mention-suggestion-char="#" contenteditable="false">#Dirty Dancing</span>',
180+
)
181+
})
182+
})
183+
184+
describe('Interaction between mention types', () => {
185+
it('should support both mention types in the same document', () => {
186+
cy.get('.tiptap').then(([{ editor }]) => {
187+
editor.commands.setContent(
188+
'<p><span data-type="mention" data-id="Madonna">@Madonna</span> starred in <span data-type="mention" data-id="Dirty Dancing" data-mention-suggestion-char="#">#Dirty Dancing</span></p>',
189+
)
190+
191+
cy.get('.tiptap').should(
192+
'contain.html',
193+
'<span class="mention" data-type="mention" data-id="Madonna" data-mention-suggestion-char="@" contenteditable="false">@Madonna</span>',
194+
)
195+
cy.get('.tiptap').should(
196+
'contain.html',
197+
'<span class="mention" data-type="mention" data-id="Dirty Dancing" data-mention-suggestion-char="#" contenteditable="false">#Dirty Dancing</span>',
198+
)
199+
})
200+
})
201+
202+
it('should allow switching between mention types', () => {
203+
cy.get('.tiptap').type('{selectall}{backspace}@')
204+
cy.get('.dropdown-menu').should('exist')
205+
cy.get('.dropdown-menu button:nth-child(1)').should('contain.text', 'Lea Thompson')
206+
207+
// Close the dropdown by moving cursor
208+
cy.get('.tiptap').type('{moveToStart}')
209+
cy.get('.dropdown-menu').should('not.exist')
210+
211+
// Open a new dropdown with #
212+
cy.get('.tiptap').type('{selectall}{backspace}#')
213+
cy.get('.dropdown-menu').should('exist')
214+
cy.get('.dropdown-menu button:nth-child(1)').should('contain.text', 'Dirty Dancing')
215+
})
216+
217+
it('should insert both types of mentions in sequence', () => {
218+
cy.get('.tiptap').type('{selectall}{backspace}@mado{enter} likes #the{enter}')
219+
220+
cy.get('.tiptap').should(
221+
'contain.html',
222+
'<span class="mention" data-type="mention" data-id="Madonna" data-mention-suggestion-char="@" contenteditable="false">@Madonna</span> likes <span class="mention" data-type="mention" data-id="The Matrix" data-mention-suggestion-char="#" contenteditable="false">#The Matrix</span>',
223+
)
224+
})
225+
})
226+
})

0 commit comments

Comments
 (0)