Skip to content

Commit

Permalink
Merge pull request #549 from sibizwolle/mention-extension
Browse files Browse the repository at this point in the history
Mention Feature
  • Loading branch information
awcodes authored Feb 15, 2025
2 parents e13b84f + ad93056 commit cae77e2
Show file tree
Hide file tree
Showing 20 changed files with 949 additions and 41 deletions.
132 changes: 132 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,138 @@ TiptapEditor::make('content')
->showOnlyCurrentPlaceholder(false)
```

### Placeholders

You can easily set a placeholder, the Filament way:

```php
TiptapEditor::make('content')
->placeholder('Write something...')
```

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.

```php
TiptapEditor::make('content')
->nodePlaceholders([
'paragraph' => 'Start writing your paragraph...',
'heading' => 'Insert a heading...',
])
```

The `->showOnlyCurrentPlaceholder()` method allows you to control whether placeholders are shown for all nodes simultaneously or only for the currently active node.

```php
TiptapEditor::make('content')
// All nodes will immediately be displayed, instead of only the selected node
->showOnlyCurrentPlaceholder(false)
```

### Mentions

The [Tiptap Mention extension](https://tiptap.dev/docs/editor/extensions/nodes/mention) has been integrated into this package.

#### Static Mentions

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:

```php
TiptapEditor::make(name: 'content')
->mentionItems([
// The simplest mention item: a label and a id
new MentionItem(label: 'Banana', id: 1),

// Add a href to make the mention clickable in the final HTML output
new MentionItem(id: 1, label: 'Strawberry', href: 'https://filamentphp.com'),

// Include additional data to be stored in the final JSON output
new MentionItem(id: 1, label: 'Strawberry', data: ['type' => 'fruit_mentions']),
])
```

Alternatively, you can use arrays instead of `MentionItem` objects:

```php
TiptapEditor::make(name: 'content')
->mentionItems([
['label' => 'Apple', 'id' => 1],
['label' => 'Banana', 'id' => 2],
['label' => 'Strawberry', 'id' => 3],
])
```

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.

```php
TiptapEditor::make(name: 'content')
// You can also use MentionSearchStrategy::Tokenized
->mentionSearchStrategy(MentionSearchStrategy::StartsWith)
```

#### Dynamic Mentions
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:

```php
use FilamentTiptapEditor\Concerns\HasFormMentions;

class YourClass
{
use HasFormMentions;
```

Next, you can provide dynamic suggestions using the `getMentionItemsUsing()` method. Here's an example:

```php
TiptapEditor::make(name: 'content')
->getMentionItemsUsing(function (string $query) {
// Get suggestions based of the $query
return User::search($query)->get()->map(fn ($user) => new MentionItem(
id: $user->id,
label: $user->name
))->take(5)->toArray();
})
```

There is a default debounce time to prevent excessive searches. You can adjust this duration to suit your needs:

```php
TiptapEditor::make(name: 'content')
->mentionDebounce(debounceInMs: 300)
```

#### Adding image prefixes to mention items

You may add images as a prefix to your mention items:

```php
TiptapEditor::make(name: 'content')
->mentionItems([
new MentionItem(id: 1, label: 'John Doe', image: 'YOUR_IMAGE_URL'),

// Optional: Show rounded image, useful for avatars
new MentionItem(id: 1, label: 'John Doe', image: 'YOUR_IMAGE_URL', roundedImage: true),
])
```

#### Additional Mention Features
You can customize a few other aspects of the mention feature:

```php
TiptapEditor::make(name: 'content')
// Customize the "No results found" message
->emptyMentionItemsMessage("No users found")

// Set a custom placeholder message. Note: if you set a placeholder, then it will ONLY show suggestions when the query is not empty.
->mentionItemsPlaceholder("Search for users...")

// Customize how many mention items should be shown at once, 8 by default. Is nullable and only works with static suggestions.
->maxMentionItems()

// Set a custom character trigger for mentioning. This is '@' by default
->mentionTrigger('#')

```

## Custom Extensions

You can add your own extensions to the editor by creating the necessary files and adding them to the config file extensions array.
Expand Down
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@tiptap/extension-text-align": "^2.0.0",
"@tiptap/extension-text-style": "^2.0.0",
"@tiptap/extension-underline": "^2.0.0",
"@tiptap/extension-mention": "^2.0.0",
"@tiptap/pm": "^2.1.12",
"@tiptap/suggestion": "^2.1.12",
"alpinejs": "^3.10.5",
Expand Down
4 changes: 4 additions & 0 deletions resources/css/plugin.css
Original file line number Diff line number Diff line change
Expand Up @@ -697,3 +697,7 @@
span[data-type="mergeTag"] {
@apply bg-gray-100 dark:bg-gray-800 px-2 py-1 mx-1 rounded;
}

.tiptap-editor .mention {
@apply bg-primary-600 bg-opacity-10 text-primary-600 px-1 py-0.5 rounded-md box-decoration-clone;
}
2 changes: 1 addition & 1 deletion resources/dist/filament-tiptap-editor.css

Large diffs are not rendered by default.

117 changes: 78 additions & 39 deletions resources/dist/filament-tiptap-editor.js

Large diffs are not rendered by default.

204 changes: 204 additions & 0 deletions resources/js/extensions/Mention/Mention.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import Suggestion from '@tiptap/suggestion'
import tippy from 'tippy.js'
import { Mention } from '@tiptap/extension-mention'
import getContent from './get-content.js'

let _query = ''
let debounceTimeout;

export const CustomMention = Mention.extend({

addOptions() {
return {
...this.parent?.(),
HTMLAttributes: {
class: 'mention',
},
}
},

addAttributes() {
return {
...this.parent?.(),
href: {
default: null,
parseHTML: element => element.getAttribute('data-href'),
renderHTML: attributes => {
if (!attributes.href) {
return {}
}

return {
'data-href': attributes.href,
}
},
},
type: {
default: null,
parseHTML: element => element.getAttribute('data-type'),
renderHTML: attributes => {
if (!attributes.type) {
return {}
}

return {
'data-type': attributes.type,
}
},
},
target: {
default: null,
parseHTML: element => element.getAttribute('data-target'),
renderHTML: attributes => {
if (!attributes.target) {
return {}
}

return {
'data-target': attributes.target,
}
},
},
data: {
default: [],
parseHTML: element => element.getAttribute('data-mention-data'),
renderHTML: attributes => {
if (!attributes.data) {
return {}
}

return {
'data-data': attributes.data,
}
},
},
}
},

addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: this.options.mentionTrigger ?? '@',
items: async ({ query }) => {
_query = query

window.dispatchEvent(new CustomEvent('update-mention-query', { detail: { query: query } }))

if(this.options.mentionItemsPlaceholder && !query) {
return [];
}

if (this.options.getMentionItemsUsingEnabled) {
window.dispatchEvent(new CustomEvent('mention-loading-start'));
clearTimeout(debounceTimeout);
return new Promise((resolve) => {
debounceTimeout = setTimeout(async () => {
const results = await this.options.getSearchResultsUsing(_query);
resolve(results);
}, this.options.mentionDebounce);
});
}

let result = [];

switch (this.options.mentionSearchStrategy) {
case 'starts_with':
result = this.options.mentionItems
.filter((item) => item['label'].toLowerCase().startsWith(query.toLowerCase()));
break;

case 'tokenized':
let tokens = query.toLowerCase().split(/\s+/);
result = this.options.mentionItems.filter((item) =>
tokens.every(token => item['label'].toLowerCase().includes(token))
);
break;
}

if (this.options.maxMentionItems) {
result = result.slice(0, this.options.maxMentionItems)
}

return result
},
command: ({ editor, range, props }) => {
let currentPosition = editor.state.selection.$anchor.pos;
let deleteFrom = currentPosition - _query.length - 1;

editor
.chain()
.focus()
.deleteRange({ from: deleteFrom, to: currentPosition })
.insertContentAt(deleteFrom, [
{
type: 'mention',
attrs: props,
},
{
type: 'text',
text: ' ',
},
])
.run()

window.getSelection()?.collapseToEnd()

_query = '';
},
render: () => {
let component
let popup

return {
onBeforeStart: (props) => {
component = getContent(
props,
this.options.emptyMentionItemsMessage,
this.options.mentionItemsPlaceholder,
_query
)
if (!props.clientRect) {
return
}

popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: () => component,
allowHTML: true,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},

onStart: (props) => {
window.dispatchEvent(new CustomEvent('update-props', { detail: props }));
},

onUpdate(props) {
window.dispatchEvent(new CustomEvent('update-props', { detail: props }));
if (!props.clientRect) {
return
}
},

onKeyDown(props) {
window.dispatchEvent(new CustomEvent('suggestion-keydown', { detail: props }))
if (['ArrowUp', 'ArrowDown', 'Enter'].includes(props.event.key)) {
return true
}
return false
},

onExit() {
popup[0].destroy()
},
}
},
}),
]
},
})
Loading

0 comments on commit cae77e2

Please sign in to comment.