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",
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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 {
|
.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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user