Skip to content

Commit cae77e2

Browse files
authored
Merge pull request #549 from sibizwolle/mention-extension
Mention Feature
2 parents e13b84f + ad93056 commit cae77e2

20 files changed

+949
-41
lines changed

README.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,138 @@ TiptapEditor::make('content')
542542
->showOnlyCurrentPlaceholder(false)
543543
```
544544

545+
### Placeholders
546+
547+
You can easily set a placeholder, the Filament way:
548+
549+
```php
550+
TiptapEditor::make('content')
551+
->placeholder('Write something...')
552+
```
553+
554+
You can define specific placeholders for each node type using the `->nodePlaceholders()` method. This method accepts an associative array, where the keys are the node type names, and the values are the corresponding placeholder texts.
555+
556+
```php
557+
TiptapEditor::make('content')
558+
->nodePlaceholders([
559+
'paragraph' => 'Start writing your paragraph...',
560+
'heading' => 'Insert a heading...',
561+
])
562+
```
563+
564+
The `->showOnlyCurrentPlaceholder()` method allows you to control whether placeholders are shown for all nodes simultaneously or only for the currently active node.
565+
566+
```php
567+
TiptapEditor::make('content')
568+
// All nodes will immediately be displayed, instead of only the selected node
569+
->showOnlyCurrentPlaceholder(false)
570+
```
571+
572+
### Mentions
573+
574+
The [Tiptap Mention extension](https://tiptap.dev/docs/editor/extensions/nodes/mention) has been integrated into this package.
575+
576+
#### Static Mentions
577+
578+
You can pass an array of suggestions using `->mentionItems()`. The most convenient way is to use instances of the `MentionItem` object, which accepts several parameters:
579+
580+
```php
581+
TiptapEditor::make(name: 'content')
582+
->mentionItems([
583+
// The simplest mention item: a label and a id
584+
new MentionItem(label: 'Banana', id: 1),
585+
586+
// Add a href to make the mention clickable in the final HTML output
587+
new MentionItem(id: 1, label: 'Strawberry', href: 'https://filamentphp.com'),
588+
589+
// Include additional data to be stored in the final JSON output
590+
new MentionItem(id: 1, label: 'Strawberry', data: ['type' => 'fruit_mentions']),
591+
])
592+
```
593+
594+
Alternatively, you can use arrays instead of `MentionItem` objects:
595+
596+
```php
597+
TiptapEditor::make(name: 'content')
598+
->mentionItems([
599+
['label' => 'Apple', 'id' => 1],
600+
['label' => 'Banana', 'id' => 2],
601+
['label' => 'Strawberry', 'id' => 3],
602+
])
603+
```
604+
605+
You can specify a search strategy for mentions. By default, the search uses a "starts with" approach, matching labels that begin with your query. Alternatively, you can opt for the tokenized strategy, which is suited for matching multiple keywords within a label.
606+
607+
```php
608+
TiptapEditor::make(name: 'content')
609+
// You can also use MentionSearchStrategy::Tokenized
610+
->mentionSearchStrategy(MentionSearchStrategy::StartsWith)
611+
```
612+
613+
#### Dynamic Mentions
614+
In many scenarios, you may want to load mentionable items dynamically, such as through an API. To enable this functionality, start by adding the following trait to your Livewire component:
615+
616+
```php
617+
use FilamentTiptapEditor\Concerns\HasFormMentions;
618+
619+
class YourClass
620+
{
621+
use HasFormMentions;
622+
```
623+
624+
Next, you can provide dynamic suggestions using the `getMentionItemsUsing()` method. Here's an example:
625+
626+
```php
627+
TiptapEditor::make(name: 'content')
628+
->getMentionItemsUsing(function (string $query) {
629+
// Get suggestions based of the $query
630+
return User::search($query)->get()->map(fn ($user) => new MentionItem(
631+
id: $user->id,
632+
label: $user->name
633+
))->take(5)->toArray();
634+
})
635+
```
636+
637+
There is a default debounce time to prevent excessive searches. You can adjust this duration to suit your needs:
638+
639+
```php
640+
TiptapEditor::make(name: 'content')
641+
->mentionDebounce(debounceInMs: 300)
642+
```
643+
644+
#### Adding image prefixes to mention items
645+
646+
You may add images as a prefix to your mention items:
647+
648+
```php
649+
TiptapEditor::make(name: 'content')
650+
->mentionItems([
651+
new MentionItem(id: 1, label: 'John Doe', image: 'YOUR_IMAGE_URL'),
652+
653+
// Optional: Show rounded image, useful for avatars
654+
new MentionItem(id: 1, label: 'John Doe', image: 'YOUR_IMAGE_URL', roundedImage: true),
655+
])
656+
```
657+
658+
#### Additional Mention Features
659+
You can customize a few other aspects of the mention feature:
660+
661+
```php
662+
TiptapEditor::make(name: 'content')
663+
// Customize the "No results found" message
664+
->emptyMentionItemsMessage("No users found")
665+
666+
// Set a custom placeholder message. Note: if you set a placeholder, then it will ONLY show suggestions when the query is not empty.
667+
->mentionItemsPlaceholder("Search for users...")
668+
669+
// Customize how many mention items should be shown at once, 8 by default. Is nullable and only works with static suggestions.
670+
->maxMentionItems()
671+
672+
// Set a custom character trigger for mentioning. This is '@' by default
673+
->mentionTrigger('#')
674+
675+
```
676+
545677
## Custom Extensions
546678

547679
You can add your own extensions to the editor by creating the necessary files and adding them to the config file extensions array.

package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"@tiptap/extension-text-align": "^2.0.0",
5353
"@tiptap/extension-text-style": "^2.0.0",
5454
"@tiptap/extension-underline": "^2.0.0",
55+
"@tiptap/extension-mention": "^2.0.0",
5556
"@tiptap/pm": "^2.1.12",
5657
"@tiptap/suggestion": "^2.1.12",
5758
"alpinejs": "^3.10.5",

resources/css/plugin.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,3 +697,7 @@
697697
span[data-type="mergeTag"] {
698698
@apply bg-gray-100 dark:bg-gray-800 px-2 py-1 mx-1 rounded;
699699
}
700+
701+
.tiptap-editor .mention {
702+
@apply bg-primary-600 bg-opacity-10 text-primary-600 px-1 py-0.5 rounded-md box-decoration-clone;
703+
}

resources/dist/filament-tiptap-editor.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/dist/filament-tiptap-editor.js

Lines changed: 78 additions & 39 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import Suggestion from '@tiptap/suggestion'
2+
import tippy from 'tippy.js'
3+
import { Mention } from '@tiptap/extension-mention'
4+
import getContent from './get-content.js'
5+
6+
let _query = ''
7+
let debounceTimeout;
8+
9+
export const CustomMention = Mention.extend({
10+
11+
addOptions() {
12+
return {
13+
...this.parent?.(),
14+
HTMLAttributes: {
15+
class: 'mention',
16+
},
17+
}
18+
},
19+
20+
addAttributes() {
21+
return {
22+
...this.parent?.(),
23+
href: {
24+
default: null,
25+
parseHTML: element => element.getAttribute('data-href'),
26+
renderHTML: attributes => {
27+
if (!attributes.href) {
28+
return {}
29+
}
30+
31+
return {
32+
'data-href': attributes.href,
33+
}
34+
},
35+
},
36+
type: {
37+
default: null,
38+
parseHTML: element => element.getAttribute('data-type'),
39+
renderHTML: attributes => {
40+
if (!attributes.type) {
41+
return {}
42+
}
43+
44+
return {
45+
'data-type': attributes.type,
46+
}
47+
},
48+
},
49+
target: {
50+
default: null,
51+
parseHTML: element => element.getAttribute('data-target'),
52+
renderHTML: attributes => {
53+
if (!attributes.target) {
54+
return {}
55+
}
56+
57+
return {
58+
'data-target': attributes.target,
59+
}
60+
},
61+
},
62+
data: {
63+
default: [],
64+
parseHTML: element => element.getAttribute('data-mention-data'),
65+
renderHTML: attributes => {
66+
if (!attributes.data) {
67+
return {}
68+
}
69+
70+
return {
71+
'data-data': attributes.data,
72+
}
73+
},
74+
},
75+
}
76+
},
77+
78+
addProseMirrorPlugins() {
79+
return [
80+
Suggestion({
81+
editor: this.editor,
82+
char: this.options.mentionTrigger ?? '@',
83+
items: async ({ query }) => {
84+
_query = query
85+
86+
window.dispatchEvent(new CustomEvent('update-mention-query', { detail: { query: query } }))
87+
88+
if(this.options.mentionItemsPlaceholder && !query) {
89+
return [];
90+
}
91+
92+
if (this.options.getMentionItemsUsingEnabled) {
93+
window.dispatchEvent(new CustomEvent('mention-loading-start'));
94+
clearTimeout(debounceTimeout);
95+
return new Promise((resolve) => {
96+
debounceTimeout = setTimeout(async () => {
97+
const results = await this.options.getSearchResultsUsing(_query);
98+
resolve(results);
99+
}, this.options.mentionDebounce);
100+
});
101+
}
102+
103+
let result = [];
104+
105+
switch (this.options.mentionSearchStrategy) {
106+
case 'starts_with':
107+
result = this.options.mentionItems
108+
.filter((item) => item['label'].toLowerCase().startsWith(query.toLowerCase()));
109+
break;
110+
111+
case 'tokenized':
112+
let tokens = query.toLowerCase().split(/\s+/);
113+
result = this.options.mentionItems.filter((item) =>
114+
tokens.every(token => item['label'].toLowerCase().includes(token))
115+
);
116+
break;
117+
}
118+
119+
if (this.options.maxMentionItems) {
120+
result = result.slice(0, this.options.maxMentionItems)
121+
}
122+
123+
return result
124+
},
125+
command: ({ editor, range, props }) => {
126+
let currentPosition = editor.state.selection.$anchor.pos;
127+
let deleteFrom = currentPosition - _query.length - 1;
128+
129+
editor
130+
.chain()
131+
.focus()
132+
.deleteRange({ from: deleteFrom, to: currentPosition })
133+
.insertContentAt(deleteFrom, [
134+
{
135+
type: 'mention',
136+
attrs: props,
137+
},
138+
{
139+
type: 'text',
140+
text: ' ',
141+
},
142+
])
143+
.run()
144+
145+
window.getSelection()?.collapseToEnd()
146+
147+
_query = '';
148+
},
149+
render: () => {
150+
let component
151+
let popup
152+
153+
return {
154+
onBeforeStart: (props) => {
155+
component = getContent(
156+
props,
157+
this.options.emptyMentionItemsMessage,
158+
this.options.mentionItemsPlaceholder,
159+
_query
160+
)
161+
if (!props.clientRect) {
162+
return
163+
}
164+
165+
popup = tippy('body', {
166+
getReferenceClientRect: props.clientRect,
167+
appendTo: () => document.body,
168+
content: () => component,
169+
allowHTML: true,
170+
showOnCreate: true,
171+
interactive: true,
172+
trigger: 'manual',
173+
placement: 'bottom-start',
174+
})
175+
},
176+
177+
onStart: (props) => {
178+
window.dispatchEvent(new CustomEvent('update-props', { detail: props }));
179+
},
180+
181+
onUpdate(props) {
182+
window.dispatchEvent(new CustomEvent('update-props', { detail: props }));
183+
if (!props.clientRect) {
184+
return
185+
}
186+
},
187+
188+
onKeyDown(props) {
189+
window.dispatchEvent(new CustomEvent('suggestion-keydown', { detail: props }))
190+
if (['ArrowUp', 'ArrowDown', 'Enter'].includes(props.event.key)) {
191+
return true
192+
}
193+
return false
194+
},
195+
196+
onExit() {
197+
popup[0].destroy()
198+
},
199+
}
200+
},
201+
}),
202+
]
203+
},
204+
})

0 commit comments

Comments
 (0)