From 2a9cb08d08f7e60d7ed67a8a83c9b1bd31f02bfb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 25 Sep 2023 15:46:29 -0500 Subject: [PATCH 1/7] lexical: restore EmojiNode, fix Android backspace! --- src/features/compose/editor/index.tsx | 1 + .../compose/editor/nodes/emoji-node.tsx | 5 ++++- .../editor/plugins/autosuggest-plugin.tsx | 19 +++++++++++++------ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/features/compose/editor/index.tsx b/src/features/compose/editor/index.tsx index c5bcfb48e..d35762e6d 100644 --- a/src/features/compose/editor/index.tsx +++ b/src/features/compose/editor/index.tsx @@ -49,6 +49,7 @@ interface IComposeEditor { } const theme: InitialConfigType['theme'] = { + emoji: 'select-none', 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', link: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue', diff --git a/src/features/compose/editor/nodes/emoji-node.tsx b/src/features/compose/editor/nodes/emoji-node.tsx index 7f7d71767..9e2971206 100644 --- a/src/features/compose/editor/nodes/emoji-node.tsx +++ b/src/features/compose/editor/nodes/emoji-node.tsx @@ -95,7 +95,10 @@ class EmojiNode extends DecoratorNode { } -const $createEmojiNode = (name = '', src: string): EmojiNode => $applyNodeReplacement(new EmojiNode(name, src)); +function $createEmojiNode (name = '', src: string): EmojiNode { + const node = new EmojiNode(name, src); + return $applyNodeReplacement(node); +} const $isEmojiNode = ( node: LexicalNode | null | undefined, diff --git a/src/features/compose/editor/plugins/autosuggest-plugin.tsx b/src/features/compose/editor/plugins/autosuggest-plugin.tsx index f13fcaf55..222c1e979 100644 --- a/src/features/compose/editor/plugins/autosuggest-plugin.tsx +++ b/src/features/compose/editor/plugins/autosuggest-plugin.tsx @@ -18,6 +18,7 @@ import { KEY_ESCAPE_COMMAND, KEY_TAB_COMMAND, LexicalEditor, + LexicalNode, RangeSelection, TextNode, } from 'lexical'; @@ -40,6 +41,7 @@ import { selectAccount } from 'soapbox/selectors'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import AutosuggestAccount from '../../components/autosuggest-account'; +import { $createEmojiNode } from '../nodes/emoji-node'; import { $createMentionNode } from '../nodes/mention-node'; import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; @@ -309,6 +311,15 @@ const AutosuggestPlugin = ({ /** Offset for the beginning of the matched text, including the token. */ const offset = leadOffset - 1; + /** Replace the matched text with the given node. */ + function replaceMatch(replaceWith: LexicalNode) { + const result = (node as TextNode).splitText(offset, offset + matchingString.length); + const textNode = result[1] ?? result[0]; + const replacedNode = textNode.replace(replaceWith); + replacedNode.insertAfter(new TextNode(' ')); + replacedNode.selectNext(); + } + if (typeof suggestion === 'object') { if (!suggestion.id) return; dispatch(useEmoji(suggestion)); // eslint-disable-line react-hooks/rules-of-hooks @@ -316,18 +327,14 @@ const AutosuggestPlugin = ({ if (isNativeEmoji(suggestion)) { node.spliceText(offset, matchingString.length, `${suggestion.native} `, true); } else { - node.spliceText(offset, matchingString.length, `${suggestion.colons} `, true); + replaceMatch($createEmojiNode(suggestion.colons, suggestion.imageUrl)); } } else if (suggestion[0] === '#') { node.setTextContent(`${suggestion} `); node.select(); } else { const acct = selectAccount(getState(), suggestion)!.acct; - const result = (node as TextNode).splitText(offset, offset + matchingString.length); - const textNode = result[1] ?? result[0]; - const mentionNode = textNode.replace($createMentionNode(`@${acct}`)); - mentionNode.insertAfter(new TextNode(' ')); - mentionNode.selectNext(); + replaceMatch($createMentionNode(`@${acct}`)); } dispatch(clearComposeSuggestions(composeId)); From 4b5602a08614620a97f172dd4c963ea7cd571ed8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 25 Sep 2023 16:02:13 -0500 Subject: [PATCH 2/7] lexical: render native emojis as Twemoji --- .../compose/editor/nodes/emoji-node.tsx | 39 +++++++++---------- .../editor/plugins/autosuggest-plugin.tsx | 12 ++---- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/src/features/compose/editor/nodes/emoji-node.tsx b/src/features/compose/editor/nodes/emoji-node.tsx index 9e2971206..00f1962c9 100644 --- a/src/features/compose/editor/nodes/emoji-node.tsx +++ b/src/features/compose/editor/nodes/emoji-node.tsx @@ -1,7 +1,8 @@ import { $applyNodeReplacement, DecoratorNode } from 'lexical'; import React from 'react'; -import { Emoji } from 'soapbox/components/ui'; +import { Emoji as Component } from 'soapbox/components/ui'; +import { isNativeEmoji, type Emoji } from 'soapbox/features/emoji'; import type { DOMExportOutput, @@ -13,29 +14,26 @@ import type { } from 'lexical'; type SerializedEmojiNode = Spread<{ - name: string - src: string + data: Emoji type: 'emoji' version: 1 }, SerializedLexicalNode>; class EmojiNode extends DecoratorNode { - __name: string; - __src: string; + __emoji: Emoji; static getType(): 'emoji' { return 'emoji'; } static clone(node: EmojiNode): EmojiNode { - return new EmojiNode(node.__name, node.__src, node.__key); + return new EmojiNode(node.__emoji, node.__key); } - constructor(name: string, src: string, key?: NodeKey) { + constructor(emoji: Emoji, key?: NodeKey) { super(key); - this.__name = name; - this.__src = src; + this.__emoji = emoji; } createDOM(config: EditorConfig): HTMLElement { @@ -60,16 +58,13 @@ class EmojiNode extends DecoratorNode { return { element }; } - static importJSON(serializedNode: SerializedEmojiNode): EmojiNode { - const { name, src } = serializedNode; - const node = $createEmojiNode(name, src); - return node; + static importJSON({ data }: SerializedEmojiNode): EmojiNode { + return $createEmojiNode(data); } exportJSON(): SerializedEmojiNode { return { - name: this.__name, - src: this.__src, + data: this.__emoji, type: 'emoji', version: 1, }; @@ -88,15 +83,19 @@ class EmojiNode extends DecoratorNode { } decorate(): JSX.Element { - return ( - - ); + const emoji = this.__emoji; + + if (isNativeEmoji(emoji)) { + return ; + } else { + return ; + } } } -function $createEmojiNode (name = '', src: string): EmojiNode { - const node = new EmojiNode(name, src); +function $createEmojiNode(emoji: Emoji): EmojiNode { + const node = new EmojiNode(emoji); return $applyNodeReplacement(node); } diff --git a/src/features/compose/editor/plugins/autosuggest-plugin.tsx b/src/features/compose/editor/plugins/autosuggest-plugin.tsx index 222c1e979..aa42ff306 100644 --- a/src/features/compose/editor/plugins/autosuggest-plugin.tsx +++ b/src/features/compose/editor/plugins/autosuggest-plugin.tsx @@ -33,9 +33,8 @@ import React, { import ReactDOM from 'react-dom'; import { clearComposeSuggestions, fetchComposeSuggestions } from 'soapbox/actions/compose'; -import { useEmoji } from 'soapbox/actions/emojis'; +import { useEmoji as chooseEmoji } from 'soapbox/actions/emojis'; import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji'; -import { isNativeEmoji } from 'soapbox/features/emoji'; import { useAppDispatch, useCompose } from 'soapbox/hooks'; import { selectAccount } from 'soapbox/selectors'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; @@ -322,13 +321,8 @@ const AutosuggestPlugin = ({ if (typeof suggestion === 'object') { if (!suggestion.id) return; - dispatch(useEmoji(suggestion)); // eslint-disable-line react-hooks/rules-of-hooks - - if (isNativeEmoji(suggestion)) { - node.spliceText(offset, matchingString.length, `${suggestion.native} `, true); - } else { - replaceMatch($createEmojiNode(suggestion.colons, suggestion.imageUrl)); - } + dispatch(chooseEmoji(suggestion)); + replaceMatch($createEmojiNode(suggestion)); } else if (suggestion[0] === '#') { node.setTextContent(`${suggestion} `); node.select(); From f3783f1a509fca084ea9014831abf85cc89d4a89 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 25 Sep 2023 16:06:09 -0500 Subject: [PATCH 3/7] AutosuggestPlugin: don't autosuggest hashtags for now https://gitlab.com/soapbox-pub/soapbox/-/issues/1527 --- src/features/compose/editor/plugins/autosuggest-plugin.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/features/compose/editor/plugins/autosuggest-plugin.tsx b/src/features/compose/editor/plugins/autosuggest-plugin.tsx index aa42ff306..24a9f2627 100644 --- a/src/features/compose/editor/plugins/autosuggest-plugin.tsx +++ b/src/features/compose/editor/plugins/autosuggest-plugin.tsx @@ -342,11 +342,6 @@ const AutosuggestPlugin = ({ if (!node) return null; - if (['hashtag'].includes(node.getType())) { - const matchingString = node.getTextContent(); - return { leadOffset: 0, matchingString }; - } - if (node.getType() === 'text') { const [leadOffset, matchingString] = textAtCursorMatchesToken( node.getTextContent(), From cefe9adc055a3b098eab960a43c1c74d5f2b51fa Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 25 Sep 2023 16:12:51 -0500 Subject: [PATCH 4/7] EmojiNode: fix getTextContent, remove unused exportDOM --- .../compose/editor/nodes/emoji-node.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/features/compose/editor/nodes/emoji-node.tsx b/src/features/compose/editor/nodes/emoji-node.tsx index 00f1962c9..4b1f0ddf6 100644 --- a/src/features/compose/editor/nodes/emoji-node.tsx +++ b/src/features/compose/editor/nodes/emoji-node.tsx @@ -5,7 +5,6 @@ import { Emoji as Component } from 'soapbox/components/ui'; import { isNativeEmoji, type Emoji } from 'soapbox/features/emoji'; import type { - DOMExportOutput, EditorConfig, LexicalNode, NodeKey, @@ -50,14 +49,6 @@ class EmojiNode extends DecoratorNode { return false; } - exportDOM(): DOMExportOutput { - const element = document.createElement('img'); - element.setAttribute('src', this.__src); - element.setAttribute('alt', this.__name); - element.classList.add('h-4', 'w-4'); - return { element }; - } - static importJSON({ data }: SerializedEmojiNode): EmojiNode { return $createEmojiNode(data); } @@ -79,12 +70,16 @@ class EmojiNode extends DecoratorNode { } getTextContent(): string { - return this.__name; + const emoji = this.__emoji; + if (isNativeEmoji(emoji)) { + return emoji.native; + } else { + return emoji.colons; + } } decorate(): JSX.Element { const emoji = this.__emoji; - if (isNativeEmoji(emoji)) { return ; } else { From 0b0a548f8cd450e2f19da2327817426915fd1dbe Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 25 Sep 2023 16:19:08 -0500 Subject: [PATCH 5/7] EmojiPickerDropdown: minor refactoring --- src/features/emoji/components/emoji-picker-dropdown.tsx | 6 ++---- .../emoji/containers/emoji-picker-dropdown-container.tsx | 1 - 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/features/emoji/components/emoji-picker-dropdown.tsx b/src/features/emoji/components/emoji-picker-dropdown.tsx index 3f486502e..3ab977e57 100644 --- a/src/features/emoji/components/emoji-picker-dropdown.tsx +++ b/src/features/emoji/components/emoji-picker-dropdown.tsx @@ -5,7 +5,7 @@ import { createSelector } from 'reselect'; import { useEmoji } from 'soapbox/actions/emojis'; import { changeSetting } from 'soapbox/actions/settings'; -import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useTheme } from 'soapbox/hooks'; import { RootState } from 'soapbox/store'; import { buildCustomEmojis } from '../../emoji'; @@ -130,10 +130,8 @@ const EmojiPickerDropdown: React.FC = ({ }) => { const intl = useIntl(); const dispatch = useAppDispatch(); - const settings = useSettings(); const title = intl.formatMessage(messages.emoji); - const userTheme = settings.get('themeMode'); - const theme = (userTheme === 'dark' || userTheme === 'light') ? userTheme : 'auto'; + const theme = useTheme(); const customEmojis = useAppSelector((state) => getCustomEmojis(state)); const frequentlyUsedEmojis = useAppSelector((state) => getFrequentlyUsedEmojis(state)); diff --git a/src/features/emoji/containers/emoji-picker-dropdown-container.tsx b/src/features/emoji/containers/emoji-picker-dropdown-container.tsx index 7bc3cd055..af662f7ad 100644 --- a/src/features/emoji/containers/emoji-picker-dropdown-container.tsx +++ b/src/features/emoji/containers/emoji-picker-dropdown-container.tsx @@ -18,7 +18,6 @@ const EmojiPickerDropdownContainer = ( ) => { const intl = useIntl(); const title = intl.formatMessage(messages.emoji); - const [visible, setVisible] = useState(false); const { x, y, strategy, refs, update } = useFloating({ From cdda5363f6e9a6280afd06f39943160f8f23ce3d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 25 Sep 2023 16:20:47 -0500 Subject: [PATCH 6/7] actions: useEmoji --> chooseEmoji --- src/actions/compose.ts | 4 ++-- src/actions/emojis.ts | 10 +++++----- .../compose/editor/plugins/autosuggest-plugin.tsx | 2 +- .../emoji/components/emoji-picker-dropdown.tsx | 4 ++-- src/reducers/settings.ts | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/actions/compose.ts b/src/actions/compose.ts index 85b2d4c5d..fef615386 100644 --- a/src/actions/compose.ts +++ b/src/actions/compose.ts @@ -13,7 +13,7 @@ import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures, parseVersion } from 'soapbox/utils/features'; -import { useEmoji } from './emojis'; +import { chooseEmoji } from './emojis'; import { importFetchedAccounts } from './importer'; import { uploadFile, updateMedia } from './media'; import { openModal, closeModal } from './modals'; @@ -631,7 +631,7 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons; startPosition = position - 1; - dispatch(useEmoji(suggestion)); + dispatch(chooseEmoji(suggestion)); } else if (typeof suggestion === 'string' && suggestion[0] === '#') { completion = suggestion; startPosition = position - 1; diff --git a/src/actions/emojis.ts b/src/actions/emojis.ts index f3d33ac61..2a959fcc3 100644 --- a/src/actions/emojis.ts +++ b/src/actions/emojis.ts @@ -3,12 +3,12 @@ import { saveSettings } from './settings'; import type { Emoji } from 'soapbox/features/emoji'; import type { AppDispatch } from 'soapbox/store'; -const EMOJI_USE = 'EMOJI_USE'; +const EMOJI_CHOOSE = 'EMOJI_CHOOSE'; -const useEmoji = (emoji: Emoji) => +const chooseEmoji = (emoji: Emoji) => (dispatch: AppDispatch) => { dispatch({ - type: EMOJI_USE, + type: EMOJI_CHOOSE, emoji, }); @@ -16,6 +16,6 @@ const useEmoji = (emoji: Emoji) => }; export { - EMOJI_USE, - useEmoji, + EMOJI_CHOOSE, + chooseEmoji, }; diff --git a/src/features/compose/editor/plugins/autosuggest-plugin.tsx b/src/features/compose/editor/plugins/autosuggest-plugin.tsx index 24a9f2627..c4419eff0 100644 --- a/src/features/compose/editor/plugins/autosuggest-plugin.tsx +++ b/src/features/compose/editor/plugins/autosuggest-plugin.tsx @@ -33,7 +33,7 @@ import React, { import ReactDOM from 'react-dom'; import { clearComposeSuggestions, fetchComposeSuggestions } from 'soapbox/actions/compose'; -import { useEmoji as chooseEmoji } from 'soapbox/actions/emojis'; +import { chooseEmoji } from 'soapbox/actions/emojis'; import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji'; import { useAppDispatch, useCompose } from 'soapbox/hooks'; import { selectAccount } from 'soapbox/selectors'; diff --git a/src/features/emoji/components/emoji-picker-dropdown.tsx b/src/features/emoji/components/emoji-picker-dropdown.tsx index 3ab977e57..f384c4e19 100644 --- a/src/features/emoji/components/emoji-picker-dropdown.tsx +++ b/src/features/emoji/components/emoji-picker-dropdown.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState, useLayoutEffect } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { createSelector } from 'reselect'; -import { useEmoji } from 'soapbox/actions/emojis'; +import { chooseEmoji } from 'soapbox/actions/emojis'; import { changeSetting } from 'soapbox/actions/settings'; import { useAppDispatch, useAppSelector, useTheme } from 'soapbox/hooks'; import { RootState } from 'soapbox/store'; @@ -160,7 +160,7 @@ const EmojiPickerDropdown: React.FC = ({ } as CustomEmoji; } - dispatch(useEmoji(pickedEmoji)); // eslint-disable-line react-hooks/rules-of-hooks + dispatch(chooseEmoji(pickedEmoji)); if (onPickEmoji) { onPickEmoji(pickedEmoji); diff --git a/src/reducers/settings.ts b/src/reducers/settings.ts index f124b38a5..e7946ca37 100644 --- a/src/reducers/settings.ts +++ b/src/reducers/settings.ts @@ -3,7 +3,7 @@ import { AnyAction } from 'redux'; import { ME_FETCH_SUCCESS } from 'soapbox/actions/me'; -import { EMOJI_USE } from '../actions/emojis'; +import { EMOJI_CHOOSE } from '../actions/emojis'; import { NOTIFICATIONS_FILTER_SET } from '../actions/notifications'; import { SEARCH_FILTER_SET } from '../actions/search'; import { @@ -40,7 +40,7 @@ export default function settings(state: State = ImmutableMap({ save return state .setIn(action.path, action.value) .set('saved', false); - case EMOJI_USE: + case EMOJI_CHOOSE: return updateFrequentEmojis(state, action.emoji); case SETTING_SAVE: return state.set('saved', true); From eb875673fd861e0f166a07f9ada2418d934a1953 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 25 Sep 2023 16:31:01 -0500 Subject: [PATCH 7/7] ComposeForm: insert emoji from picker into lexical Fixes https://gitlab.com/soapbox-pub/soapbox/-/issues/1529 --- src/features/compose/components/compose-form.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/features/compose/components/compose-form.tsx b/src/features/compose/components/compose-form.tsx index 0994ca00b..6e0bda14f 100644 --- a/src/features/compose/components/compose-form.tsx +++ b/src/features/compose/components/compose-form.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { CLEAR_EDITOR_COMMAND, type LexicalEditor } from 'lexical'; +import { CLEAR_EDITOR_COMMAND, TextNode, type LexicalEditor } from 'lexical'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; @@ -11,7 +11,6 @@ import { clearComposeSuggestions, fetchComposeSuggestions, selectComposeSuggestion, - insertEmojiCompose, uploadCompose, } from 'soapbox/actions/compose'; import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input'; @@ -28,6 +27,7 @@ import ReplyIndicatorContainer from '../containers/reply-indicator-container'; import ScheduleFormContainer from '../containers/schedule-form-container'; import UploadButtonContainer from '../containers/upload-button-container'; import WarningContainer from '../containers/warning-container'; +import { $createEmojiNode } from '../editor/nodes/emoji-node'; import { countableText } from '../util/counter'; import MarkdownButton from './markdown-button'; @@ -46,8 +46,6 @@ import Warning from './warning'; import type { Emoji } from 'soapbox/features/emoji'; -const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; - const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' }, pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' }, @@ -181,10 +179,12 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab }; const handleEmojiPick = (data: Emoji) => { - const position = autosuggestTextareaRef.current!.textarea!.selectionStart; - const needsSpace = !!data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]); + const editor = editorRef.current; + if (!editor) return; - dispatch(insertEmojiCompose(id, position, data, needsSpace)); + editor.update(() => { + editor.getEditorState()._selection?.insertNodes([$createEmojiNode(data), new TextNode(' ')]); + }); }; const onPaste = (files: FileList) => {