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;