Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1089f35
added mentions to rich editor
Aug 22, 2025
303de5f
Updated documentation
Aug 22, 2025
ed33db0
updated doc
Aug 22, 2025
4029925
Update rich-editor.blade.php
Aug 22, 2025
960b2fa
Update RichEditor.php
Aug 22, 2025
6b7933c
added to gitignore
Sep 5, 2025
d4ed299
updated git ignore
Sep 5, 2025
a1a65d4
updated gitignore
Sep 5, 2025
487327a
updated rich editor to allow multiple chars
Sep 5, 2025
031c02f
added multiple chars to mentions and limit results shown
Sep 5, 2025
5db9558
Merge branch 'add-mentions-to-rich-editor' of github.com:bmspereira-0…
Sep 5, 2025
0b323fc
revert gitignore changes
Sep 5, 2025
ff37d73
added the dist folder :/
Sep 5, 2025
119a6a7
Merge branch '4.x' into add-mentions-to-rich-editor
bmspereira-07 Sep 5, 2025
ef35200
Merge branch '4.x' into add-mentions-to-rich-editor
bmspereira-07 Sep 10, 2025
9b60c12
added possibilite to fetch data async from DB.
Sep 17, 2025
808bc73
Merge branch '4.x' into add-mentions-to-rich-editor
bmspereira-07 Sep 17, 2025
dbd1e9d
updated docs
Sep 17, 2025
f613098
Merge branch 'add-mentions-to-rich-editor' of github.com:bmspereira-0…
Sep 17, 2025
29f1323
implementation with MentionProvider and async fetch. Labels are fetch…
Sep 17, 2025
f0ad500
fix phpstan errors
Sep 17, 2025
9e2788f
fixed extrainputs render
Sep 18, 2025
a509c15
Merge branch '4.x' into add-mentions-to-rich-editor
bmspereira-07 Sep 18, 2025
4ef0806
Merge branch '4.x' into add-mentions-to-rich-editor
bmspereira-07 Sep 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/forms/docs/10-rich-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,36 @@ RichEditor::make('content')
->activePanel('mergeTags')
```

## Using mentions

Mentions let users type `@` to search and insert inline references (e.g., users, teams). The inserted mention is an inline, non-editable token rendered as text like `@Jane Doe`.

Mentions are built into the rich editor. To enable them, provide a list of items using `mentionItems()`:

```php
use Filament\Forms\Components\RichEditor;

RichEditor::make('content')
->mentionItems([
// Strings
'Marketing', 'Sales', 'Support',

// Or objects with an id and label (recommended)
['id' => 1, 'label' => 'Jane Doe'],
['id' => 2, 'label' => 'John Smith'],

])
// or Model Query
->mentionItems(fn () => User::all()->map(fn ($item) => ['id' => $item['id'], 'label' => $item['name']])->toArray())
```

- Typing `@` opens a dropdown that filters as you type.
- Selecting an item inserts an inline span with a ```data-type="mention"``` attribute at the cursor.
- Items can be simple strings or associative arrays with `id` and `label` (or `name`). When both are present, the label is displayed and the id is stored.
- You may pass a closure to `mentionItems()` to compute items dynamically.

When rendering, mentions are output as inline text. If you output raw HTML from the editor yourself, remember to sanitize it.

## Registering rich content attributes

There are elements of the rich editor configuration that apply to both the editor and the renderer. For example, if you are using [private images](#using-private-images-in-the-editor), [custom blocks](#using-custom-blocks), [merge tags](#using-merge-tags), or [plugins](#extending-the-rich-editor), you need to ensure that the same configuration is used in both places. To do this, Filament provides you with a way to register rich content attributes that can be used in both the editor and the renderer.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { mergeAttributes, Node } from '@tiptap/core'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import Suggestion from '@tiptap/suggestion'
import getMentionSuggestion from './mention-suggestion.js'

const getSuggestionOptions = function ({
editor: tiptapEditor,
overrideSuggestionOptions,
extensionName,
}) {
const pluginKey = new PluginKey()

return {
editor: tiptapEditor,
char: '@',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be cool to be able to customize the character.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ is quite common. What other char do you have in mind?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pluginKey,
command: ({ editor, range, props }) => {
const nodeAfter = editor.view.state.selection.$to.nodeAfter
const overrideSpace = nodeAfter?.text?.startsWith(' ')

if (overrideSpace) {
range.to += 1
}

editor
.chain()
.focus()
.insertContentAt(range, [
{
type: extensionName,
attrs: { ...props },
},
{
type: 'text',
text: ' ',
},
])
.run()

editor.view.dom.ownerDocument.defaultView
?.getSelection()
?.collapseToEnd()
},
allow: ({ state, range }) => {
const $from = state.doc.resolve(range.from)
const type = state.schema.nodes[extensionName]
const allow = !!$from.parent.type.contentMatch.matchType(type)

return allow
},
...overrideSuggestionOptions,
}
}

export default Node.create({
name: 'mention',

priority: 101,

addStorage() {
return {
suggestions: [],
getSuggestionFromChar: () => null,
}
},

addOptions() {
return {
HTMLAttributes: {},
renderText({ node }) {
return `@${node.attrs.label ?? node.attrs.id}`
},
deleteTriggerWithBackspace: false,
renderHTML({ options, node }) {
return [
'span',
mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
`@${node.attrs.label ?? node.attrs.id}`,
]
},
suggestions: [],
suggestion: {},
}
},

group: 'inline',

inline: true,

selectable: false,

atom: true,

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

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

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

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

parseHTML() {
return [
{
tag: `span[data-type="${this.name}"]`,
},
]
},

renderHTML({ node, HTMLAttributes }) {
const suggestion = this.editor?.extensionStorage?.[this.name]?.getSuggestionFromChar('@')

const mergedOptions = { ...this.options }

mergedOptions.HTMLAttributes = mergeAttributes(
{ 'data-type': this.name },
this.options.HTMLAttributes,
HTMLAttributes,
)

const html = this.options.renderHTML({
options: mergedOptions,
node,
suggestion,
})

if (typeof html === 'string') {
return [
'span',
mergeAttributes(
{ 'data-type': this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
html,
]
}
return html
},

renderText({ node }) {
const args = {
options: this.options,
node,
suggestion: this.editor?.extensionStorage?.[this.name]?.getSuggestionFromChar('@'),
}

return this.options.renderText(args)
},

addKeyboardShortcuts() {
return {
Backspace: () =>
this.editor.commands.command(({ tr, state }) => {
let isMention = false
const { selection } = state
const { empty, anchor } = selection

if (!empty) {
return false
}

let mentionNode = new ProseMirrorNode()
let mentionPos = 0

state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
if (node.type.name === this.name) {
isMention = true
mentionNode = node
mentionPos = pos
return false
}
})

if (isMention) {
tr.insertText(
this.options.deleteTriggerWithBackspace ? '' : '@',
mentionPos,
mentionPos + mentionNode.nodeSize,
)
}

return isMention
}),
}
},

addProseMirrorPlugins() {
return [
...this.storage.suggestions.map(Suggestion),
new Plugin({}),
]
},

onBeforeCreate() {
this.storage.suggestions = (
this.options.suggestions.length ? this.options.suggestions : [this.options.suggestion]
).map((suggestion) => {
const normalized = typeof suggestion.items === 'function' || typeof suggestion.render === 'function'
? suggestion
: getMentionSuggestion({ items: suggestion.items ?? [] })

return getSuggestionOptions({
editor: this.editor,
overrideSuggestionOptions: normalized,
extensionName: this.name,
})
})

this.storage.getSuggestionFromChar = (char) => {
const suggestion = this.storage.suggestions.find((s) => s.char === char)
if (suggestion) {
return suggestion
}
if (this.storage.suggestions.length) {
return this.storage.suggestions[0]
}

return null
}
},
})


11 changes: 11 additions & 0 deletions packages/forms/resources/js/components/rich-editor/extensions.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import TextAlign from '@tiptap/extension-text-align'
import Underline from '@tiptap/extension-underline'

import getMergeTagSuggestion from './merge-tag-suggestion.js'
import Mention from './extension-mention.js'
import getMentionSuggestion from './mention-suggestion.js'

export default async ({
customExtensionUrls,
Expand All @@ -43,6 +45,7 @@ export default async ({
key,
mergeTags,
noMergeTagSearchResultsMessage,
mentions = [],
placeholder,
statePath,
uploadingFileMessage,
Expand Down Expand Up @@ -96,6 +99,14 @@ export default async ({
}),
]
: []),
...(mentions.length
? [
Mention.configure({
HTMLAttributes: { class: 'fi-fo-rich-editor-mention' },
suggestion: getMentionSuggestion({ items: mentions }),
}),
]
: []),
OrderedList,
Paragraph,
Placeholder.configure({
Expand Down
Loading