From 7681134d7fe84a6b47676029a6cf48a432af0e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 1 Apr 2023 13:40:15 +0200 Subject: [PATCH] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/compose/editor/index.tsx | 4 ++ app/soapbox/features/compose/editor/nodes.ts | 2 + .../compose/editor/nodes/emoji-node.tsx | 71 +++++++++++++++++++ .../compose/editor/nodes/mention-node.tsx | 14 ++-- .../editor/plugins/autosuggest-plugin.tsx | 37 ++++++---- .../compose/editor/plugins/emoji-plugin.tsx | 61 ++++++++++++++++ .../compose/editor/plugins/mention-plugin.tsx | 4 +- 7 files changed, 169 insertions(+), 24 deletions(-) create mode 100644 app/soapbox/features/compose/editor/nodes/emoji-node.tsx create mode 100644 app/soapbox/features/compose/editor/plugins/emoji-plugin.tsx diff --git a/app/soapbox/features/compose/editor/index.tsx b/app/soapbox/features/compose/editor/index.tsx index b2479a5875..490d564c82 100644 --- a/app/soapbox/features/compose/editor/index.tsx +++ b/app/soapbox/features/compose/editor/index.tsx @@ -15,6 +15,7 @@ import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'; import { HashtagPlugin } from '@lexical/react/LexicalHashtagPlugin'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; +import { ListPlugin } from '@lexical/react/LexicalListPlugin'; import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import clsx from 'clsx'; @@ -28,6 +29,7 @@ import { useAppDispatch, useFeatures } from 'soapbox/hooks'; import nodes from './nodes'; import { AutosuggestPlugin } from './plugins/autosuggest-plugin'; import DraggableBlockPlugin from './plugins/draggable-block-plugin'; +import { EmojiPlugin } from './plugins/emoji-plugin'; import FloatingLinkEditorPlugin from './plugins/floating-link-editor-plugin'; import FloatingTextFormatToolbarPlugin from './plugins/floating-text-format-toolbar-plugin'; import { MentionPlugin } from './plugins/mention-plugin'; @@ -141,9 +143,11 @@ const ComposeEditor = React.forwardRef(({ composeId, condensed, onF /> + {features.richText && } + {features.richText && } {features.richText && floatingAnchorElem && ( <> diff --git a/app/soapbox/features/compose/editor/nodes.ts b/app/soapbox/features/compose/editor/nodes.ts index cf263cbb3c..57b31103c8 100644 --- a/app/soapbox/features/compose/editor/nodes.ts +++ b/app/soapbox/features/compose/editor/nodes.ts @@ -14,6 +14,7 @@ import { ListItemNode, ListNode } from '@lexical/list'; import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'; import { HeadingNode, QuoteNode } from '@lexical/rich-text'; +import { EmojiNode } from './nodes/emoji-node'; import { MentionNode } from './nodes/mention-node'; import type { Klass, LexicalNode } from 'lexical'; @@ -29,6 +30,7 @@ const ComposeNodes: Array> = [ ListNode, HorizontalRuleNode, HashtagNode, + EmojiNode, MentionNode, ]; diff --git a/app/soapbox/features/compose/editor/nodes/emoji-node.tsx b/app/soapbox/features/compose/editor/nodes/emoji-node.tsx new file mode 100644 index 0000000000..42889d74a1 --- /dev/null +++ b/app/soapbox/features/compose/editor/nodes/emoji-node.tsx @@ -0,0 +1,71 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { addClassNamesToElement } from '@lexical/utils'; +import { $applyNodeReplacement, TextNode } from 'lexical'; + +import type { + EditorConfig, + LexicalNode, + NodeKey, + SerializedTextNode, +} from 'lexical'; + +class EmojiNode extends TextNode { + + static getType(): string { + return 'emoji'; + } + + static clone(node: EmojiNode): EmojiNode { + return new EmojiNode(node.__text, node.__key); + } + + constructor(text: string, key?: NodeKey) { + super(text, key); + } + + createDOM(config: EditorConfig): HTMLElement { + const element = super.createDOM(config); + addClassNamesToElement(element, config.theme.emoji); + return element; + } + + static importJSON(serializedNode: SerializedTextNode): EmojiNode { + const node = $createEmojiNode(serializedNode.text); + node.setFormat(serializedNode.format); + node.setDetail(serializedNode.detail); + node.setMode(serializedNode.mode); + node.setStyle(serializedNode.style); + return node; + } + + exportJSON(): SerializedTextNode { + return { + ...super.exportJSON(), + type: 'emoji', + }; + } + + canInsertTextBefore(): boolean { + return false; + } + + isTextEntity(): true { + return true; + } + +} + +const $createEmojiNode = (text = ''): EmojiNode => $applyNodeReplacement(new EmojiNode(text).setMode('token')); + +const $isEmojiNode = ( + node: LexicalNode | null | undefined, +): node is EmojiNode => node instanceof EmojiNode; + +export { EmojiNode, $createEmojiNode, $isEmojiNode }; diff --git a/app/soapbox/features/compose/editor/nodes/mention-node.tsx b/app/soapbox/features/compose/editor/nodes/mention-node.tsx index 8ef5b78544..b3400da294 100644 --- a/app/soapbox/features/compose/editor/nodes/mention-node.tsx +++ b/app/soapbox/features/compose/editor/nodes/mention-node.tsx @@ -16,7 +16,7 @@ import type { SerializedTextNode, } from 'lexical'; -export class MentionNode extends TextNode { +class MentionNode extends TextNode { static getType(): string { return 'mention'; @@ -62,12 +62,10 @@ export class MentionNode extends TextNode { } -export function $createMentionNode(text = ''): MentionNode { - return $applyNodeReplacement(new MentionNode(text)); -} +const $createMentionNode = (text = ''): MentionNode => $applyNodeReplacement(new MentionNode(text)); -export function $isMentionNode( +const $isMentionNode = ( node: LexicalNode | null | undefined, -): node is MentionNode { - return node instanceof MentionNode; -} +): node is MentionNode => node instanceof MentionNode; + +export { MentionNode, $createMentionNode, $isMentionNode }; diff --git a/app/soapbox/features/compose/editor/plugins/autosuggest-plugin.tsx b/app/soapbox/features/compose/editor/plugins/autosuggest-plugin.tsx index e70af44516..65bb1dbc0d 100644 --- a/app/soapbox/features/compose/editor/plugins/autosuggest-plugin.tsx +++ b/app/soapbox/features/compose/editor/plugins/autosuggest-plugin.tsx @@ -26,11 +26,17 @@ import React, { import ReactDOM from 'react-dom'; import { fetchComposeSuggestions } from 'soapbox/actions/compose'; +import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji'; import { useAppDispatch, useCompose } from 'soapbox/hooks'; import AutosuggestAccount from '../../components/autosuggest-account'; -import { getMentionMatch } from './mention-plugin'; +import { MENTION_REGEX } from './mention-plugin'; + +import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; + + +const EMOJI_REGEX = new RegExp('(^|$|(?:^|\\s))([:])([a-z\\d_-]+([:]?))', 'i'); export type QueryMatch = { leadOffset: number @@ -73,7 +79,7 @@ function getQueryTextForSearch(editor: LexicalEditor): string | null { const state = editor.getEditorState(); const node = (state._selection as RangeSelection)?.anchor?.getNode(); - if (node && node.getType() === 'mention') return node.getTextContent(); + if (node && (node.getType() === 'mention' || node.getType() === 'text')) return node.getTextContent(); return null; } @@ -278,11 +284,6 @@ function useMenuAnchorRef( return anchorElementRef; } -export type TriggerFn = ( - text: string, - editor: LexicalEditor, -) => QueryMatch | null; - export type AutosuggestPluginProps = { composeId: string suggestionsHidden: boolean @@ -324,12 +325,12 @@ export function AutosuggestPlugin({ }); }; - const checkForMentionMatch = useCallback((text: string) => { - const matchArr = getMentionMatch(text); + const checkForMatch = useCallback((text: string) => { + const matchArr = MENTION_REGEX.exec(text) || EMOJI_REGEX.exec(text); if (!matchArr) return null; - dispatch(fetchComposeSuggestions(composeId, matchArr[0])); + dispatch(fetchComposeSuggestions(composeId, matchArr[0]?.trim())); return { leadOffset: matchArr.index, @@ -337,9 +338,17 @@ export function AutosuggestPlugin({ }; }, []); - const renderSuggestion = (suggestion: string, i: number) => { - const inner = ; - const key = suggestion; + const renderSuggestion = (suggestion: AutoSuggestion, i: number) => { + let inner; + let key; + + if (typeof suggestion === 'object') { + inner = ; + key = suggestion.id; + } else { + inner = ; + key = suggestion; + } return (
{ + const matchArr = REGEX.exec(text); + + if (!matchArr) return null; + return matchArr; +}; + +export const EmojiPlugin = (): JSX.Element | null => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([EmojiNode])) { + throw new Error('EmojiPlugin: EmojiNode not registered on editor'); + } + }, [editor]); + + const createEmojiNode = useCallback((textNode: TextNode): EmojiNode => { + return $createEmojiNode(textNode.getTextContent()); + }, []); + + const getEntityMatch = useCallback((text: string) => { + const matchArr = getEmojiMatch(text); + + if (!matchArr) return null; + + const emojiLength = matchArr[3].length + 1; + const startOffset = matchArr.index + matchArr[1].length; + const endOffset = startOffset + emojiLength; + return { + end: endOffset, + start: startOffset, + }; + }, []); + + useLexicalTextEntity( + getEntityMatch, + EmojiNode, + createEmojiNode, + ); + + return null; +}; diff --git a/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx b/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx index c46cb88981..18cbec4670 100644 --- a/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx +++ b/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx @@ -15,10 +15,10 @@ import { $createMentionNode, MentionNode } from '../nodes/mention-node'; import type { TextNode } from 'lexical'; -const REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i'); +export const MENTION_REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i'); export const getMentionMatch = (text: string) => { - const matchArr = REGEX.exec(text); + const matchArr = MENTION_REGEX.exec(text); if (!matchArr) return null; return matchArr;