diff --git a/package-lock.json b/package-lock.json
index 2951c49..6d2b70a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 61ad39d..71e9466 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/editor/RichTextEditor.tsx b/src/editor/RichTextEditor.tsx
index 0169156..9e97114 100644
--- a/src/editor/RichTextEditor.tsx
+++ b/src/editor/RichTextEditor.tsx
@@ -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() {
+
+
diff --git a/src/editor/nodes/MentionNode.ts b/src/editor/nodes/MentionNode.ts
new file mode 100644
index 0000000..575ce0b
--- /dev/null
+++ b/src/editor/nodes/MentionNode.ts
@@ -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;
+}
diff --git a/src/editor/plugins/HashtagPlugin.tsx b/src/editor/plugins/HashtagPlugin.tsx
new file mode 100644
index 0000000..6d5ba75
--- /dev/null
+++ b/src/editor/plugins/HashtagPlugin.tsx
@@ -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;
+}
diff --git a/src/editor/plugins/MentionsPlugin.tsx b/src/editor/plugins/MentionsPlugin.tsx
new file mode 100644
index 0000000..8ffdc5c
--- /dev/null
+++ b/src/editor/plugins/MentionsPlugin.tsx
@@ -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) => 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>([]);
+
+ 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 (
+
+ {option.picture}
+ {option.name}
+
+ );
+}
+
+export default function MentionsPlugin(): JSX.Element | null {
+ const [editor] = useLexicalComposerContext();
+
+ const [queryString, setQueryString] = useState(null);
+
+ const results = useMentionLookupService(queryString);
+
+ const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
+ minLength: 0,
+ });
+
+ const options = useMemo(
+ () =>
+ results
+ .map(
+ (result) =>
+ new MentionTypeaheadOption(result, ),
+ )
+ .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 (
+
+ onQueryChange={setQueryString}
+ onSelectOption={onSelectOption}
+ triggerFn={checkForMentionMatch}
+ options={options}
+ menuRenderFn={(
+ anchorElementRef,
+ {selectedIndex, selectOptionAndCleanUp, setHighlightedIndex},
+ ) =>
+ anchorElementRef.current && results.length
+ ? ReactDOM.createPortal(
+
+
+ {options.map((option, i: number) => (
+ {
+ setHighlightedIndex(i);
+ selectOptionAndCleanUp(option);
+ }}
+ onMouseEnter={() => {
+ setHighlightedIndex(i);
+ }}
+ key={option.key}
+ option={option}
+ />
+ ))}
+
+
,
+ anchorElementRef.current,
+ )
+ : null
+ }
+ />
+ );
+}
diff --git a/src/editor/styles/editor.css b/src/editor/styles/editor.css
index 223c27e..e6a62a6 100644
--- a/src/editor/styles/editor.css
+++ b/src/editor/styles/editor.css
@@ -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");
+}
diff --git a/src/editor/themes/EditorTheme.ts b/src/editor/themes/EditorTheme.ts
index 7b4b50f..94b1a80 100644
--- a/src/editor/themes/EditorTheme.ts
+++ b/src/editor/themes/EditorTheme.ts
@@ -40,6 +40,7 @@ const theme: EditorThemeClasses = {
tableCellHeader: 'editor-table-cell-header',
hr: 'editor-hr',
image: 'editor-image',
+ hashtag: 'editor-hashtag',
};
export default theme;