Mentions for everyone! This plugin allows the user to choose an entry from a list. After selection an entry the search text will be replace with the selected entity. The list of suggestions mentions needs to contain at least a name to display. If desired a link and/or an avatar image can be provided.
While the suggestion popover is open, the user can close it by pressing ESC. This will be stored for as long as the the selection stays inside the word that triggered the search. After the selection left this word once the escape behaviour will be reset. The suggestions will appear again once the user selects the word that that triggered the selection.
npm install @draft-js-plugins/editor
npm install @draft-js-plugins/mention
Please checkout the 'Simple Example' further down the page.
The plugin ships with a default styling available at this location in the installed package: node_modules/@draft-js-plugins/mention/lib/plugin.css
npm i style-loader css-loader --save-dev
module.exports = {
module: {
loaders: [
{
test: /plugin\.css$/,
loaders: ['style-loader', 'css'],
},
],
},
};
import '@draft-js-plugins/mention/lib/plugin.css';
MentionSuggestions
. Defaults to MentionSuggestions
.popoverComponent
defaultSuggestionsFilter
. As first argument it takes the search term as a String. The second argument is an array of mentions. The third argument is a trigger that is used to filter multi mentions. The function returns the filter list based on substring matches.import { defaultSuggestionsFilter } from '@draft-js-plugins/mention';
import React, {
ReactElement,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { EditorState } from 'draft-js';
import Editor from '@draft-js-plugins/editor';
import createMentionPlugin, {
defaultSuggestionsFilter,
} from '@draft-js-plugins/mention';
import editorStyles from './SimpleMentionEditor.module.css';
import mentions from './Mentions';
export default function SimpleMentionEditor(): ReactElement {
const ref = useRef<Editor>(null);
const [editorState, setEditorState] = useState(() =>
EditorState.createEmpty()
);
const [open, setOpen] = useState(false);
const [suggestions, setSuggestions] = useState(mentions);
const { MentionSuggestions, plugins } = useMemo(() => {
const mentionPlugin = createMentionPlugin();
// eslint-disable-next-line no-shadow
const { MentionSuggestions } = mentionPlugin;
// eslint-disable-next-line no-shadow
const plugins = [mentionPlugin];
return { plugins, MentionSuggestions };
}, []);
const onOpenChange = useCallback((_open: boolean) => {
setOpen(_open);
}, []);
const onSearchChange = useCallback(({ value }: { value: string }) => {
setSuggestions(defaultSuggestionsFilter(value, mentions));
}, []);
return (
<div
className={editorStyles.editor}
onClick={() => {
ref.current!.focus();
}}
>
<Editor
editorKey={'editor'}
editorState={editorState}
onChange={setEditorState}
plugins={plugins}
ref={ref}
/>
<MentionSuggestions
open={open}
onOpenChange={onOpenChange}
suggestions={suggestions}
onSearchChange={onSearchChange}
onAddMention={() => {
// get the mention object selected
}}
/>
</div>
);
}
import { MentionData } from '@draft-js-plugins/mention';
const mentions: MentionData[] = [
{
name: 'Matthew Russell',
link: 'https://twitter.com/mrussell247',
avatar:
'https://pbs.twimg.com/profile_images/517863945/mattsailing_400x400.jpg',
},
{
name: 'Julian Krispel-Samsel',
link: 'https://twitter.com/juliandoesstuff',
avatar: 'https://avatars2.githubusercontent.com/u/1188186?v=3&s=400',
},
{
name: 'Jyoti Puri',
link: 'https://twitter.com/jyopur',
avatar: 'https://avatars0.githubusercontent.com/u/2182307?v=3&s=400',
},
{
name: 'Max Stoiber',
link: 'https://twitter.com/mxstbr',
avatar: 'https://avatars0.githubusercontent.com/u/7525670?s=200&v=4',
},
{
name: 'Nik Graf',
link: 'https://twitter.com/nikgraf',
avatar: 'https://avatars0.githubusercontent.com/u/223045?v=3&s=400',
},
{
name: 'Pascal Brandt',
link: 'https://twitter.com/psbrandt',
avatar:
'https://pbs.twimg.com/profile_images/688487813025640448/E6O6I011_400x400.png',
},
];
export default mentions;
.editor {
box-sizing: border-box;
border: 1px solid #ddd;
cursor: text;
padding: 16px;
border-radius: 2px;
margin-bottom: 2em;
box-shadow: inset 0px 1px 8px -3px #ABABAB;
background: #fefefe;
}
.editor :global(.public-DraftEditor-content) {
min-height: 140px;
}
import React, {
MouseEvent,
ReactElement,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { EditorState } from 'draft-js';
import Editor from '@draft-js-plugins/editor';
import createMentionPlugin, {
defaultSuggestionsFilter,
MentionData,
MentionPluginTheme,
} from '@draft-js-plugins/mention';
import editorStyles from './CustomMentionEditor.module.css';
import mentionsStyles from './MentionsStyles.module.css';
import mentions from './Mentions';
export interface EntryComponentProps {
className?: string;
onMouseDown(event: MouseEvent): void;
onMouseUp(event: MouseEvent): void;
onMouseEnter(event: MouseEvent): void;
role: string;
id: string;
'aria-selected'?: boolean | 'false' | 'true';
theme?: MentionPluginTheme;
mention: MentionData;
isFocused: boolean;
searchValue?: string;
}
function Entry(props: EntryComponentProps): ReactElement {
const {
mention,
theme,
searchValue, // eslint-disable-line @typescript-eslint/no-unused-vars
isFocused, // eslint-disable-line @typescript-eslint/no-unused-vars
...parentProps
} = props;
return (
<div {...parentProps}>
<div className={theme?.mentionSuggestionsEntryContainer}>
<div className={theme?.mentionSuggestionsEntryContainerLeft}>
<img
src={mention.avatar}
className={theme?.mentionSuggestionsEntryAvatar}
role="presentation"
/>
</div>
<div className={theme?.mentionSuggestionsEntryContainerRight}>
<div className={theme?.mentionSuggestionsEntryText}>
{mention.name}
</div>
<div className={theme?.mentionSuggestionsEntryTitle}>
{mention.title}
</div>
</div>
</div>
</div>
);
}
export default function CustomMentionEditor(): ReactElement {
const ref = useRef<Editor>(null);
const [editorState, setEditorState] = useState(() =>
EditorState.createEmpty()
);
const [open, setOpen] = useState(false);
const [suggestions, setSuggestions] = useState(mentions);
const { MentionSuggestions, plugins } = useMemo(() => {
const mentionPlugin = createMentionPlugin({
entityMutability: 'IMMUTABLE',
theme: mentionsStyles,
mentionPrefix: '@',
supportWhitespace: true,
});
// eslint-disable-next-line no-shadow
const { MentionSuggestions } = mentionPlugin;
// eslint-disable-next-line no-shadow
const plugins = [mentionPlugin];
return { plugins, MentionSuggestions };
}, []);
const onChange = useCallback((_editorState: EditorState) => {
setEditorState(_editorState);
}, []);
const onOpenChange = useCallback((_open: boolean) => {
setOpen(_open);
}, []);
const onSearchChange = useCallback(({ value }: { value: string }) => {
setSuggestions(defaultSuggestionsFilter(value, mentions));
}, []);
return (
<div
className={editorStyles.editor}
onClick={() => {
ref.current!.focus();
}}
>
<Editor
editorKey={'editor'}
editorState={editorState}
onChange={onChange}
plugins={plugins}
ref={ref}
/>
<MentionSuggestions
open={open}
onOpenChange={onOpenChange}
suggestions={suggestions}
onSearchChange={onSearchChange}
onAddMention={() => {
// get the mention object selected
}}
entryComponent={Entry}
popoverContainer={({ children }) => <div>{children}</div>}
/>
</div>
);
}
.mention {
color: #4a85bb;
text-decoration: none;
}
.mentionSuggestions {
border-top: 1px solid #eee;
background: #fff;
border-radius: 2px;
cursor: pointer;
padding-top: 8px;
padding-bottom: 8px;
display: flex;
flex-direction: column;
box-sizing: border-box;
transform-origin: 50% 0%;
transform: scaleY(0);
margin: -16px;
}
.mentionSuggestionsEntryContainer {
display: table;
width: 100%;
}
.mentionSuggestionsEntryContainerLeft,
.mentionSuggestionsEntryContainerRight {
display: table-cell;
vertical-align: middle;
}
.mentionSuggestionsEntryContainerRight {
width: 100%;
padding-left: 8px;
}
.mentionSuggestionsEntry {
padding: 7px 10px 3px 10px;
transition: background-color 0.4s cubic-bezier(.27,1.27,.48,.56);
}
.mentionSuggestionsEntry:active {
background-color: #cce7ff;
}
.mentionSuggestionsEntryFocused {
composes: mentionSuggestionsEntry;
background-color: #e6f3ff;
}
.mentionSuggestionsEntryText,
.mentionSuggestionsEntryTitle {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mentionSuggestionsEntryText {
}
.mentionSuggestionsEntryTitle {
font-size: 80%;
color: #a7a7a7;
}
.mentionSuggestionsEntryAvatar {
display: block;
width: 30px;
height: 30px;
border-radius: 50%;
}
import { MentionData } from '@draft-js-plugins/mention';
const mentions: MentionData[] = [
{
name: 'Matthew Russell',
title: 'Senior Software Engineer',
avatar:
'https://pbs.twimg.com/profile_images/517863945/mattsailing_400x400.jpg',
},
{
name: 'Julian Krispel-Samsel',
title: 'United Kingdom',
avatar: 'https://avatars2.githubusercontent.com/u/1188186?v=3&s=400',
},
{
name: 'Jyoti Puri',
title: 'New Delhi, India',
avatar: 'https://avatars0.githubusercontent.com/u/2182307?v=3&s=400',
},
{
name: 'Max Stoiber',
title:
'Travels around the world, brews coffee, skis mountains and makes stuff on the web.',
avatar: 'https://avatars0.githubusercontent.com/u/7525670?s=200&v=4',
},
{
name: 'Nik Graf',
title: 'Passionate about Software Architecture, UX, Skiing & Triathlons',
avatar: 'https://avatars0.githubusercontent.com/u/223045?v=3&s=400',
},
{
name: 'Pascal Brandt',
title: 'HeathIT hacker and researcher',
avatar:
'https://pbs.twimg.com/profile_images/688487813025640448/E6O6I011_400x400.png',
},
{
name: 'Łukasz Bąk',
title: 'Randomly Generated User',
avatar: 'https://randomuser.me/api/portraits/men/36.jpg',
},
{
name: '佐々木 小次郎',
title: 'Famous Japanese swordsman (SAMURAI)',
avatar:
'https://upload.wikimedia.org/wikipedia/commons/0/08/Sasaki-Ganryu-%28Kojiro%29-by-Utagawa-Kuniyoshi-1845.png',
url: 'https://en.wikipedia.org/wiki/Sasaki_Kojir%C5%8D',
},
];
export default mentions;
.editor {
box-sizing: border-box;
border: 1px solid #ddd;
cursor: text;
padding: 16px;
border-radius: 2px;
margin-bottom: 2em;
box-shadow: inset 0px 1px 8px -3px #ABABAB;
background: #fefefe;
}
.editor :global(.public-DraftEditor-content) {
min-height: 140px;
}
import React, {
ReactElement,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { EditorState } from 'draft-js';
import Editor from '@draft-js-plugins/editor';
import createMentionPlugin from '@draft-js-plugins/mention';
import editorStyles from './RemoteMentionEditor.module.css';
export default function RemoteMentionEditor(): ReactElement {
const ref = useRef<Editor>(null);
const [editorState, setEditorState] = useState(() =>
EditorState.createEmpty()
);
const [open, setOpen] = useState(false);
const [suggestions, setSuggestions] = useState([]);
const { MentionSuggestions, plugins } = useMemo(() => {
const mentionPlugin = createMentionPlugin();
// eslint-disable-next-line no-shadow
const { MentionSuggestions } = mentionPlugin;
// eslint-disable-next-line no-shadow
const plugins = [mentionPlugin];
return { plugins, MentionSuggestions };
}, []);
const onOpenChange = useCallback((_open: boolean) => {
setOpen(_open);
}, []);
const onSearchChange = useCallback(({ value }: { value: string }) => {
// An import statment would break server-side rendering.
require('whatwg-fetch'); // eslint-disable-line global-require
// while you normally would have a dynamic server that takes the value as
// a workaround we use this workaround to show different results
let url = '/data/mentionsA.json';
if (value.length === 1) {
url = '/data/mentionsB.json';
} else if (value.length > 1) {
url = '/data/mentionsC.json';
}
fetch(url)
.then((response) => response.json())
.then((data) => {
setSuggestions(data);
});
}, []);
return (
<div
className={editorStyles.editor}
onClick={() => {
ref.current!.focus();
}}
>
<Editor
editorKey={'editor'}
editorState={editorState}
onChange={setEditorState}
plugins={plugins}
ref={ref}
/>
<MentionSuggestions
open={open}
onOpenChange={onOpenChange}
suggestions={suggestions}
onSearchChange={onSearchChange}
onAddMention={() => {
// get the mention object selected
}}
/>
</div>
);
}
.editor {
box-sizing: border-box;
border: 1px solid #ddd;
cursor: text;
padding: 16px;
border-radius: 2px;
margin-bottom: 2em;
box-shadow: inset 0px 1px 8px -3px #ABABAB;
background: #fefefe;
}
.editor :global(.public-DraftEditor-content) {
min-height: 140px;
}
import React, {
ReactElement,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { EditorState } from 'draft-js';
import Editor from '@draft-js-plugins/editor';
import createMentionPlugin, {
defaultSuggestionsFilter,
} from '@draft-js-plugins/mention';
import editorStyles from './CustomComponentMentionEditor.module.css';
import mentions from './Mentions';
export default function CustomComponentMentionEditor(): ReactElement {
const ref = useRef<Editor>(null);
const [editorState, setEditorState] = useState(() =>
EditorState.createEmpty()
);
const [open, setOpen] = useState(false);
const [suggestions, setSuggestions] = useState(mentions);
const { MentionSuggestions, plugins } = useMemo(() => {
const mentionPlugin = createMentionPlugin({
mentionComponent(mentionProps) {
return (
<span
className={mentionProps.className}
// eslint-disable-next-line no-alert
onClick={() => alert('Clicked on the Mention!')}
>
{mentionProps.children}
</span>
);
},
});
// eslint-disable-next-line no-shadow
const { MentionSuggestions } = mentionPlugin;
// eslint-disable-next-line no-shadow
const plugins = [mentionPlugin];
return { plugins, MentionSuggestions };
}, []);
const onOpenChange = useCallback((_open: boolean) => {
setOpen(_open);
}, []);
const onSearchChange = useCallback(
({ trigger, value }: { trigger: string; value: string }) => {
setSuggestions(defaultSuggestionsFilter(value, mentions, trigger));
},
[]
);
return (
<div
className={editorStyles.editor}
onClick={() => {
ref.current!.focus();
}}
>
<Editor
editorKey={'editor'}
editorState={editorState}
onChange={setEditorState}
plugins={plugins}
ref={ref}
/>
<MentionSuggestions
open={open}
onOpenChange={onOpenChange}
suggestions={suggestions}
onSearchChange={onSearchChange}
onAddMention={() => {
// get the mention object selected
}}
/>
</div>
);
}
.editor {
box-sizing: border-box;
border: 1px solid #ddd;
cursor: text;
padding: 16px;
border-radius: 2px;
margin-bottom: 2em;
box-shadow: inset 0px 1px 8px -3px #ABABAB;
background: #fefefe;
}
.editor :global(.public-DraftEditor-content) {
min-height: 140px;
}
import React, { ReactElement, useRef, useState, useCallback } from 'react';
import { EditorState } from 'draft-js';
import Editor from '@draft-js-plugins/editor';
import createMentionPlugin, {
defaultSuggestionsFilter,
} from '@draft-js-plugins/mention';
import editorStyles from './MultiMentionTriggers.module.css';
import mentions from './Mentions';
const mentionPlugin = createMentionPlugin({
mentionTrigger: ['@', '#'],
mentionPrefix: (trigger) => trigger,
});
const { MentionSuggestions } = mentionPlugin;
const plugins = [mentionPlugin];
interface MentionData {
link?: string;
avatar?: string;
name: string;
id?: null | string | number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[x: string]: any;
}
const SimpleMentionEditor = (): ReactElement => {
const ref = useRef<Editor>(null);
const [editorState, setEditorState] = useState(EditorState.createEmpty());
const [open, setOpen] = useState(false);
const [suggestions, setSuggestions] = useState<MentionData[]>(mentions['@']);
const onChange = useCallback((_editorState: EditorState) => {
setEditorState(_editorState);
}, []);
const onOpenChange = useCallback((_open: boolean) => {
setOpen(_open);
}, []);
const onSearchChange = useCallback(
({ trigger, value }: { trigger: string; value: string }) => {
setSuggestions(
defaultSuggestionsFilter(value, mentions, trigger) as MentionData[]
);
},
[]
);
return (
<div
className={editorStyles.editor}
onClick={() => {
ref.current!.focus();
}}
>
<Editor
editorState={editorState}
onChange={onChange}
plugins={plugins}
ref={ref}
/>
<MentionSuggestions
open={open}
onOpenChange={onOpenChange}
onSearchChange={onSearchChange}
suggestions={suggestions}
onAddMention={() => {
// get the mention object selected
}}
/>
</div>
);
};
export default SimpleMentionEditor;
.editor {
box-sizing: border-box;
border: 1px solid #ddd;
cursor: text;
padding: 16px;
border-radius: 2px;
margin-bottom: 2em;
box-shadow: inset 0px 1px 8px -3px #ABABAB;
background: #fefefe;
}
.editor :global(.public-DraftEditor-content) {
min-height: 140px;
}