feat: add hashtag and mention functionality with corresponding plugins and styles
This commit is contained in:
1
package-lock.json
generated
1
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@lexical/code": "^0.37.0",
|
||||
"@lexical/hashtag": "^0.37.0",
|
||||
"@lexical/link": "^0.37.0",
|
||||
"@lexical/list": "^0.37.0",
|
||||
"@lexical/markdown": "^0.37.0",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@lexical/code": "^0.37.0",
|
||||
"@lexical/hashtag": "^0.37.0",
|
||||
"@lexical/link": "^0.37.0",
|
||||
"@lexical/list": "^0.37.0",
|
||||
"@lexical/markdown": "^0.37.0",
|
||||
|
||||
@@ -14,12 +14,16 @@ import { TablePlugin } from '@lexical/react/LexicalTablePlugin';
|
||||
import { LinkNode, AutoLinkNode } from '@lexical/link';
|
||||
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
||||
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin';
|
||||
import { HashtagNode } from '@lexical/hashtag';
|
||||
|
||||
import { ImageNode } from './nodes/ImageNode';
|
||||
import { MentionNode } from './nodes/MentionNode';
|
||||
import ToolbarPlugin from './plugins/ToolbarPlugin';
|
||||
import MarkdownPlugin from './plugins/MarkdownShortcutPlugin';
|
||||
import ImagesPlugin from './plugins/ImagesPlugin';
|
||||
import DragDropPastePlugin from './plugins/DragDropPastePlugin';
|
||||
import HashtagPlugin from './plugins/HashtagPlugin';
|
||||
import MentionsPlugin from './plugins/MentionsPlugin';
|
||||
import editorTheme from './themes/EditorTheme';
|
||||
import './styles/editor.css';
|
||||
|
||||
@@ -78,6 +82,8 @@ const editorConfig = {
|
||||
LinkNode,
|
||||
AutoLinkNode,
|
||||
ImageNode,
|
||||
HashtagNode,
|
||||
MentionNode,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -104,6 +110,8 @@ export default function RichTextEditor() {
|
||||
<TablePlugin />
|
||||
<ImagesPlugin />
|
||||
<DragDropPastePlugin />
|
||||
<HashtagPlugin />
|
||||
<MentionsPlugin />
|
||||
<MarkdownPlugin />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
130
src/editor/nodes/MentionNode.ts
Normal file
130
src/editor/nodes/MentionNode.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
type DOMConversionMap,
|
||||
type DOMConversionOutput,
|
||||
type DOMExportOutput,
|
||||
type EditorConfig,
|
||||
type LexicalNode,
|
||||
type NodeKey,
|
||||
type SerializedTextNode,
|
||||
type Spread,
|
||||
TextNode,
|
||||
} from 'lexical';
|
||||
|
||||
export type SerializedMentionNode = Spread<
|
||||
{
|
||||
mentionName: string;
|
||||
},
|
||||
SerializedTextNode
|
||||
>;
|
||||
|
||||
function $convertMentionElement(
|
||||
domNode: HTMLElement,
|
||||
): DOMConversionOutput | null {
|
||||
const textContent = domNode.textContent;
|
||||
const mentionName = domNode.getAttribute('data-lexical-mention-name');
|
||||
|
||||
if (textContent !== null) {
|
||||
const node = $createMentionNode(
|
||||
typeof mentionName === 'string' ? mentionName : textContent,
|
||||
textContent,
|
||||
);
|
||||
return {
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const mentionStyle = 'background-color: rgba(24, 119, 232, 0.2)';
|
||||
|
||||
export class MentionNode extends TextNode {
|
||||
__mention: string;
|
||||
|
||||
static getType(): string {
|
||||
return 'mention';
|
||||
}
|
||||
|
||||
static clone(node: MentionNode): MentionNode {
|
||||
return new MentionNode(node.__mention, node.__text, node.__key);
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedMentionNode): MentionNode {
|
||||
return $createMentionNode(serializedNode.mentionName).updateFromJSON(
|
||||
serializedNode,
|
||||
);
|
||||
}
|
||||
|
||||
constructor(mentionName: string, text?: string, key?: NodeKey) {
|
||||
super(text ?? mentionName, key);
|
||||
this.__mention = mentionName;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedMentionNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
mentionName: this.__mention,
|
||||
};
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const dom = super.createDOM(config);
|
||||
dom.style.cssText = mentionStyle;
|
||||
dom.className = 'mention';
|
||||
dom.spellcheck = false;
|
||||
|
||||
return dom;
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('span');
|
||||
element.setAttribute('data-lexical-mention', 'true');
|
||||
if (this.__text !== this.__mention) {
|
||||
element.setAttribute('data-lexical-mention-name', this.__mention);
|
||||
}
|
||||
element.textContent = this.__text;
|
||||
return {element};
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
span: (domNode: HTMLElement) => {
|
||||
if (!domNode.hasAttribute('data-lexical-mention')) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversion: $convertMentionElement,
|
||||
priority: 1,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
isTextEntity(): true {
|
||||
return true;
|
||||
}
|
||||
|
||||
canInsertTextBefore(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
canInsertTextAfter(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function $createMentionNode(
|
||||
mentionName: string,
|
||||
textContent?: string,
|
||||
): MentionNode {
|
||||
const mentionNode = new MentionNode(mentionName, textContent ?? mentionName);
|
||||
mentionNode.setMode('segmented').toggleDirectionless();
|
||||
return $applyNodeReplacement(mentionNode);
|
||||
}
|
||||
|
||||
export function $isMentionNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is MentionNode {
|
||||
return node instanceof MentionNode;
|
||||
}
|
||||
18
src/editor/plugins/HashtagPlugin.tsx
Normal file
18
src/editor/plugins/HashtagPlugin.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type {JSX} from 'react';
|
||||
|
||||
import {HashtagNode, registerLexicalHashtag} from '@lexical/hashtag';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
export default function HashtagPlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([HashtagNode])) {
|
||||
throw new Error('HashtagPlugin: HashtagNode not registered on editor');
|
||||
}
|
||||
return registerLexicalHashtag(editor);
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
288
src/editor/plugins/MentionsPlugin.tsx
Normal file
288
src/editor/plugins/MentionsPlugin.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import type {JSX} from 'react';
|
||||
import type {MenuTextMatch} from '@lexical/react/LexicalTypeaheadMenuPlugin';
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {
|
||||
LexicalTypeaheadMenuPlugin,
|
||||
MenuOption,
|
||||
useBasicTypeaheadTriggerMatch,
|
||||
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
|
||||
import {TextNode} from 'lexical';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
||||
import {$createMentionNode} from '../nodes/MentionNode';
|
||||
|
||||
const PUNCTUATION =
|
||||
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';
|
||||
|
||||
const TRIGGERS = ['@'].join('');
|
||||
|
||||
// Chars we expect to see in a mention (non-space, non-punctuation).
|
||||
const VALID_CHARS = '[^' + TRIGGERS + PUNCTUATION + '\\s]';
|
||||
|
||||
// Non-standard series of chars. Each series must be preceded and followed by
|
||||
// a valid char.
|
||||
const VALID_JOINS =
|
||||
'(?:' +
|
||||
'\\.[ |$]|' + // E.g. "r. " in "Mr. Smith"
|
||||
' |' + // E.g. " " in "Josh Duck"
|
||||
'[' +
|
||||
PUNCTUATION +
|
||||
']|' + // E.g. "-' in "Salier-Hellendag"
|
||||
')';
|
||||
|
||||
const LENGTH_LIMIT = 75;
|
||||
|
||||
const AtSignMentionsRegex = new RegExp(
|
||||
'(^|\\s|\\()(' +
|
||||
'[' +
|
||||
TRIGGERS +
|
||||
']' +
|
||||
'((?:' +
|
||||
VALID_CHARS +
|
||||
VALID_JOINS +
|
||||
'){0,' +
|
||||
LENGTH_LIMIT +
|
||||
'})' +
|
||||
')$',
|
||||
);
|
||||
|
||||
// 50 is the longest alias length limit.
|
||||
const ALIAS_LENGTH_LIMIT = 50;
|
||||
|
||||
// Regex used to match alias.
|
||||
const AtSignMentionsRegexAliasRegex = new RegExp(
|
||||
'(^|\\s|\\()(' +
|
||||
'[' +
|
||||
TRIGGERS +
|
||||
']' +
|
||||
'((?:' +
|
||||
VALID_CHARS +
|
||||
'){0,' +
|
||||
ALIAS_LENGTH_LIMIT +
|
||||
'})' +
|
||||
')$',
|
||||
);
|
||||
|
||||
// At most, 5 suggestions are shown in the popup.
|
||||
const SUGGESTION_LIST_LENGTH_LIMIT = 5;
|
||||
|
||||
const mentionsCache = new Map();
|
||||
|
||||
const dummyMentionsData = [
|
||||
'Alice Johnson',
|
||||
'Bob Smith',
|
||||
'Charlie Brown',
|
||||
'David Wilson',
|
||||
'Emma Davis',
|
||||
'Frank Miller',
|
||||
'Grace Lee',
|
||||
'Henry Taylor',
|
||||
'Isabella Martinez',
|
||||
'Jack Anderson',
|
||||
];
|
||||
|
||||
const dummyLookupService = {
|
||||
search(string: string, callback: (results: Array<string>) => void): void {
|
||||
setTimeout(() => {
|
||||
const results = dummyMentionsData.filter((mention) =>
|
||||
mention.toLowerCase().includes(string.toLowerCase()),
|
||||
);
|
||||
callback(results);
|
||||
}, 100);
|
||||
},
|
||||
};
|
||||
|
||||
function useMentionLookupService(mentionString: string | null) {
|
||||
const [results, setResults] = useState<Array<string>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const cachedResults = mentionsCache.get(mentionString);
|
||||
|
||||
if (mentionString == null) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedResults === null) {
|
||||
return;
|
||||
} else if (cachedResults !== undefined) {
|
||||
setResults(cachedResults);
|
||||
return;
|
||||
}
|
||||
|
||||
mentionsCache.set(mentionString, null);
|
||||
dummyLookupService.search(mentionString, (newResults) => {
|
||||
mentionsCache.set(mentionString, newResults);
|
||||
setResults(newResults);
|
||||
});
|
||||
}, [mentionString]);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function checkForAtSignMentions(
|
||||
text: string,
|
||||
minMatchLength: number,
|
||||
): MenuTextMatch | null {
|
||||
let match = AtSignMentionsRegex.exec(text);
|
||||
|
||||
if (match === null) {
|
||||
match = AtSignMentionsRegexAliasRegex.exec(text);
|
||||
}
|
||||
if (match !== null) {
|
||||
// The strategy ignores leading whitespace but we need to know it's
|
||||
// length to add it to the leadOffset
|
||||
const maybeLeadingWhitespace = match[1];
|
||||
|
||||
const matchingString = match[3];
|
||||
if (matchingString.length >= minMatchLength) {
|
||||
return {
|
||||
leadOffset: match.index + maybeLeadingWhitespace.length,
|
||||
matchingString,
|
||||
replaceableString: match[2],
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPossibleQueryMatch(text: string): MenuTextMatch | null {
|
||||
return checkForAtSignMentions(text, 1);
|
||||
}
|
||||
|
||||
class MentionTypeaheadOption extends MenuOption {
|
||||
name: string;
|
||||
picture: JSX.Element;
|
||||
|
||||
constructor(name: string, picture: JSX.Element) {
|
||||
super(name);
|
||||
this.name = name;
|
||||
this.picture = picture;
|
||||
}
|
||||
}
|
||||
|
||||
function MentionsTypeaheadMenuItem({
|
||||
index,
|
||||
isSelected,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
option,
|
||||
}: {
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onMouseEnter: () => void;
|
||||
option: MentionTypeaheadOption;
|
||||
}) {
|
||||
let className = 'item';
|
||||
if (isSelected) {
|
||||
className += ' selected';
|
||||
}
|
||||
return (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
className={className}
|
||||
ref={option.setRefElement}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={'typeahead-item-' + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}>
|
||||
{option.picture}
|
||||
<span className="text">{option.name}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MentionsPlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
const [queryString, setQueryString] = useState<string | null>(null);
|
||||
|
||||
const results = useMentionLookupService(queryString);
|
||||
|
||||
const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
|
||||
minLength: 0,
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
results
|
||||
.map(
|
||||
(result) =>
|
||||
new MentionTypeaheadOption(result, <i className="icon user" />),
|
||||
)
|
||||
.slice(0, SUGGESTION_LIST_LENGTH_LIMIT),
|
||||
[results],
|
||||
);
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(
|
||||
selectedOption: MentionTypeaheadOption,
|
||||
nodeToReplace: TextNode | null,
|
||||
closeMenu: () => void,
|
||||
) => {
|
||||
editor.update(() => {
|
||||
const mentionNode = $createMentionNode(selectedOption.name);
|
||||
if (nodeToReplace) {
|
||||
nodeToReplace.replace(mentionNode);
|
||||
}
|
||||
mentionNode.select();
|
||||
closeMenu();
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const checkForMentionMatch = useCallback(
|
||||
(text: string) => {
|
||||
const slashMatch = checkForSlashTriggerMatch(text, editor);
|
||||
if (slashMatch !== null) {
|
||||
return null;
|
||||
}
|
||||
return getPossibleQueryMatch(text);
|
||||
},
|
||||
[checkForSlashTriggerMatch, editor],
|
||||
);
|
||||
|
||||
return (
|
||||
<LexicalTypeaheadMenuPlugin<MentionTypeaheadOption>
|
||||
onQueryChange={setQueryString}
|
||||
onSelectOption={onSelectOption}
|
||||
triggerFn={checkForMentionMatch}
|
||||
options={options}
|
||||
menuRenderFn={(
|
||||
anchorElementRef,
|
||||
{selectedIndex, selectOptionAndCleanUp, setHighlightedIndex},
|
||||
) =>
|
||||
anchorElementRef.current && results.length
|
||||
? ReactDOM.createPortal(
|
||||
<div className="typeahead-popover mentions-menu">
|
||||
<ul>
|
||||
{options.map((option, i: number) => (
|
||||
<MentionsTypeaheadMenuItem
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => {
|
||||
setHighlightedIndex(i);
|
||||
selectOptionAndCleanUp(option);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHighlightedIndex(i);
|
||||
}}
|
||||
key={option.key}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>,
|
||||
anchorElementRef.current,
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -351,3 +351,127 @@
|
||||
.editor-text-justify {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
/* Hashtag styles */
|
||||
.editor-hashtag {
|
||||
background-color: rgba(88, 144, 255, 0.15);
|
||||
border-bottom: 1px solid rgba(88, 144, 255, 0.3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Mention styles */
|
||||
.mention {
|
||||
background-color: rgba(24, 119, 232, 0.2);
|
||||
color: #1877e8;
|
||||
border-radius: 4px;
|
||||
padding: 1px 3px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mention:focus {
|
||||
box-shadow: rgb(180 213 255) 0px 0px 0px 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Typeahead popover styles */
|
||||
.typeahead-popover {
|
||||
background: #fff;
|
||||
box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.typeahead-popover ul {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.typeahead-popover ul::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.typeahead-popover ul {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.typeahead-popover ul li {
|
||||
margin: 0;
|
||||
min-width: 180px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.typeahead-popover ul li.selected {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.typeahead-popover li {
|
||||
margin: 0 8px 0 8px;
|
||||
padding: 8px;
|
||||
color: #050505;
|
||||
cursor: pointer;
|
||||
line-height: 16px;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
align-content: center;
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.typeahead-popover li.active {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.typeahead-popover li:first-child {
|
||||
border-radius: 8px 8px 0px 0px;
|
||||
}
|
||||
|
||||
.typeahead-popover li:last-child {
|
||||
border-radius: 0px 0px 8px 8px;
|
||||
}
|
||||
|
||||
.typeahead-popover li:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.typeahead-popover li .text {
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
flex-grow: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.typeahead-popover li .icon {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
user-select: none;
|
||||
margin-right: 8px;
|
||||
line-height: 16px;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.mentions-menu {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.typeahead-popover li .icon.user {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23666'%3E%3Cpath d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ const theme: EditorThemeClasses = {
|
||||
tableCellHeader: 'editor-table-cell-header',
|
||||
hr: 'editor-hr',
|
||||
image: 'editor-image',
|
||||
hashtag: 'editor-hashtag',
|
||||
};
|
||||
|
||||
export default theme;
|
||||
|
||||
Reference in New Issue
Block a user