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
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