feat: add hashtag and mention functionality with corresponding plugins and styles

This commit is contained in:
2025-10-22 18:07:11 +08:00
parent d4713fd69e
commit 721cc48773
8 changed files with 571 additions and 0 deletions

1
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@lexical/code": "^0.37.0", "@lexical/code": "^0.37.0",
"@lexical/hashtag": "^0.37.0",
"@lexical/link": "^0.37.0", "@lexical/link": "^0.37.0",
"@lexical/list": "^0.37.0", "@lexical/list": "^0.37.0",
"@lexical/markdown": "^0.37.0", "@lexical/markdown": "^0.37.0",

View File

@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@lexical/code": "^0.37.0", "@lexical/code": "^0.37.0",
"@lexical/hashtag": "^0.37.0",
"@lexical/link": "^0.37.0", "@lexical/link": "^0.37.0",
"@lexical/list": "^0.37.0", "@lexical/list": "^0.37.0",
"@lexical/markdown": "^0.37.0", "@lexical/markdown": "^0.37.0",

View File

@@ -14,12 +14,16 @@ import { TablePlugin } from '@lexical/react/LexicalTablePlugin';
import { LinkNode, AutoLinkNode } from '@lexical/link'; import { LinkNode, AutoLinkNode } from '@lexical/link';
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'; import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin';
import { HashtagNode } from '@lexical/hashtag';
import { ImageNode } from './nodes/ImageNode'; import { ImageNode } from './nodes/ImageNode';
import { MentionNode } from './nodes/MentionNode';
import ToolbarPlugin from './plugins/ToolbarPlugin'; import ToolbarPlugin from './plugins/ToolbarPlugin';
import MarkdownPlugin from './plugins/MarkdownShortcutPlugin'; import MarkdownPlugin from './plugins/MarkdownShortcutPlugin';
import ImagesPlugin from './plugins/ImagesPlugin'; import ImagesPlugin from './plugins/ImagesPlugin';
import DragDropPastePlugin from './plugins/DragDropPastePlugin'; import DragDropPastePlugin from './plugins/DragDropPastePlugin';
import HashtagPlugin from './plugins/HashtagPlugin';
import MentionsPlugin from './plugins/MentionsPlugin';
import editorTheme from './themes/EditorTheme'; import editorTheme from './themes/EditorTheme';
import './styles/editor.css'; import './styles/editor.css';
@@ -78,6 +82,8 @@ const editorConfig = {
LinkNode, LinkNode,
AutoLinkNode, AutoLinkNode,
ImageNode, ImageNode,
HashtagNode,
MentionNode,
], ],
}; };
@@ -104,6 +110,8 @@ export default function RichTextEditor() {
<TablePlugin /> <TablePlugin />
<ImagesPlugin /> <ImagesPlugin />
<DragDropPastePlugin /> <DragDropPastePlugin />
<HashtagPlugin />
<MentionsPlugin />
<MarkdownPlugin /> <MarkdownPlugin />
</div> </div>
</div> </div>

View 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;
}

View 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;
}

View 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
}
/>
);
}

View File

@@ -351,3 +351,127 @@
.editor-text-justify { .editor-text-justify {
text-align: 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");
}

View File

@@ -40,6 +40,7 @@ const theme: EditorThemeClasses = {
tableCellHeader: 'editor-table-cell-header', tableCellHeader: 'editor-table-cell-header',
hr: 'editor-hr', hr: 'editor-hr',
image: 'editor-image', image: 'editor-image',
hashtag: 'editor-hashtag',
}; };
export default theme; export default theme;