From f81b5d9aa897cc0b8fe59db40a7c350590f6705a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 11 Mar 2023 00:17:54 +0100 Subject: [PATCH] WIP mentions plugin 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 | 5 +- app/soapbox/features/compose/editor/nodes.ts | 3 + .../compose/editor/nodes/mention-node.tsx | 73 +++++++++++++++++++ .../compose/editor/plugins/mention-plugin.ts | 55 ++++++++++++++ 4 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 app/soapbox/features/compose/editor/nodes/mention-node.tsx create mode 100644 app/soapbox/features/compose/editor/plugins/mention-plugin.ts diff --git a/app/soapbox/features/compose/editor/index.tsx b/app/soapbox/features/compose/editor/index.tsx index d14db6e539..7e7c746104 100644 --- a/app/soapbox/features/compose/editor/index.tsx +++ b/app/soapbox/features/compose/editor/index.tsx @@ -27,7 +27,7 @@ import { useAppDispatch, useFeatures } from 'soapbox/hooks'; import nodes from './nodes'; import FloatingLinkEditorPlugin from './plugins/floating-link-editor-plugin'; import FloatingTextFormatToolbarPlugin from './plugins/floating-text-format-toolbar-plugin'; -import NewMentionsPlugin from './plugins/mention-plugin'; +import { MentionPlugin } from './plugins/mention-plugin'; const StatePlugin = ({ composeId, autoFocus }: { composeId: string, autoFocus: boolean }) => { const dispatch = useAppDispatch(); @@ -55,6 +55,7 @@ const ComposeEditor = React.forwardRef(({ composeId, condensed, onF nodes, theme: { hashtag: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue', + mention: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue', text: { bold: 'font-bold', code: 'font-mono', @@ -130,12 +131,12 @@ const ComposeEditor = React.forwardRef(({ composeId, condensed, onF /> + {features.richText && } {features.richText && floatingAnchorElem && ( <> - )} diff --git a/app/soapbox/features/compose/editor/nodes.ts b/app/soapbox/features/compose/editor/nodes.ts index 7c16273838..9614f8ab58 100644 --- a/app/soapbox/features/compose/editor/nodes.ts +++ b/app/soapbox/features/compose/editor/nodes.ts @@ -13,6 +13,8 @@ import { AutoLinkNode, LinkNode } from '@lexical/link'; import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'; import { HeadingNode, QuoteNode } from '@lexical/rich-text'; +import { MentionNode } from './nodes/mention-node'; + import type { Klass, LexicalNode } from 'lexical'; const ComposeNodes: Array> = [ @@ -24,6 +26,7 @@ const ComposeNodes: Array> = [ LinkNode, HorizontalRuleNode, HashtagNode, + MentionNode, ]; export default ComposeNodes; diff --git a/app/soapbox/features/compose/editor/nodes/mention-node.tsx b/app/soapbox/features/compose/editor/nodes/mention-node.tsx new file mode 100644 index 0000000000..8ef5b78544 --- /dev/null +++ b/app/soapbox/features/compose/editor/nodes/mention-node.tsx @@ -0,0 +1,73 @@ +/** + * 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'; + +export class MentionNode extends TextNode { + + static getType(): string { + return 'mention'; + } + + static clone(node: MentionNode): MentionNode { + return new MentionNode(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.mention); + return element; + } + + static importJSON(serializedNode: SerializedTextNode): MentionNode { + const node = $createMentionNode(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: 'mention', + }; + } + + canInsertTextBefore(): boolean { + return false; + } + + isTextEntity(): true { + return true; + } + +} + +export function $createMentionNode(text = ''): MentionNode { + return $applyNodeReplacement(new MentionNode(text)); +} + +export function $isMentionNode( + node: LexicalNode | null | undefined, +): node is MentionNode { + return node instanceof MentionNode; +} diff --git a/app/soapbox/features/compose/editor/plugins/mention-plugin.ts b/app/soapbox/features/compose/editor/plugins/mention-plugin.ts new file mode 100644 index 0000000000..0d0e0a6048 --- /dev/null +++ b/app/soapbox/features/compose/editor/plugins/mention-plugin.ts @@ -0,0 +1,55 @@ +/** + * 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 { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity'; +import { useCallback, useEffect } from 'react'; + +import { $createMentionNode, MentionNode } from '../nodes/mention-node'; + +import type { TextNode } from 'lexical'; + +const REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i'); + +export function MentionPlugin(): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([MentionNode])) { + throw new Error('MentionPlugin: MentionNode not registered on editor'); + } + }, [editor]); + + const createMentionNode = useCallback((textNode: TextNode): MentionNode => { + return $createMentionNode(textNode.getTextContent()); + }, []); + + const getMentionMatch = useCallback((text: string) => { + const matchArr = REGEX.exec(text); + + if (matchArr === null) { + return null; + } + + const mentionLength = matchArr[3].length + 1; + const startOffset = matchArr.index + matchArr[1].length; + const endOffset = startOffset + mentionLength; + return { + end: endOffset, + start: startOffset, + }; + }, []); + + useLexicalTextEntity( + getMentionMatch, + MentionNode, + createMentionNode, + ); + + return null; +}