Merge branch 'emoji-node' into 'main'

lexical: restore EmojiNode, fix Android backspace!

Closes #1529

See merge request soapbox-pub/soapbox!2758
environments/review-main-yi2y9f/deployments/4056
Alex Gleason 2023-09-25 21:38:16 +00:00
commit 33c775709b
9 zmienionych plików z 62 dodań i 71 usunięć

Wyświetl plik

@ -13,7 +13,7 @@ import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth'; import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures, parseVersion } from 'soapbox/utils/features'; import { getFeatures, parseVersion } from 'soapbox/utils/features';
import { useEmoji } from './emojis'; import { chooseEmoji } from './emojis';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts } from './importer';
import { uploadFile, updateMedia } from './media'; import { uploadFile, updateMedia } from './media';
import { openModal, closeModal } from './modals'; import { openModal, closeModal } from './modals';
@ -631,7 +631,7 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str
completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons; completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons;
startPosition = position - 1; startPosition = position - 1;
dispatch(useEmoji(suggestion)); dispatch(chooseEmoji(suggestion));
} else if (typeof suggestion === 'string' && suggestion[0] === '#') { } else if (typeof suggestion === 'string' && suggestion[0] === '#') {
completion = suggestion; completion = suggestion;
startPosition = position - 1; startPosition = position - 1;

Wyświetl plik

@ -3,12 +3,12 @@ import { saveSettings } from './settings';
import type { Emoji } from 'soapbox/features/emoji'; import type { Emoji } from 'soapbox/features/emoji';
import type { AppDispatch } from 'soapbox/store'; 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: AppDispatch) => {
dispatch({ dispatch({
type: EMOJI_USE, type: EMOJI_CHOOSE,
emoji, emoji,
}); });
@ -16,6 +16,6 @@ const useEmoji = (emoji: Emoji) =>
}; };
export { export {
EMOJI_USE, EMOJI_CHOOSE,
useEmoji, chooseEmoji,
}; };

Wyświetl plik

@ -1,5 +1,5 @@
import clsx from 'clsx'; 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 React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
@ -11,7 +11,6 @@ import {
clearComposeSuggestions, clearComposeSuggestions,
fetchComposeSuggestions, fetchComposeSuggestions,
selectComposeSuggestion, selectComposeSuggestion,
insertEmojiCompose,
uploadCompose, uploadCompose,
} from 'soapbox/actions/compose'; } from 'soapbox/actions/compose';
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input'; 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 ScheduleFormContainer from '../containers/schedule-form-container';
import UploadButtonContainer from '../containers/upload-button-container'; import UploadButtonContainer from '../containers/upload-button-container';
import WarningContainer from '../containers/warning-container'; import WarningContainer from '../containers/warning-container';
import { $createEmojiNode } from '../editor/nodes/emoji-node';
import { countableText } from '../util/counter'; import { countableText } from '../util/counter';
import MarkdownButton from './markdown-button'; import MarkdownButton from './markdown-button';
@ -46,8 +46,6 @@ import Warning from './warning';
import type { Emoji } from 'soapbox/features/emoji'; 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({ const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' }, placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' }, pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' },
@ -181,10 +179,12 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
}; };
const handleEmojiPick = (data: Emoji) => { const handleEmojiPick = (data: Emoji) => {
const position = autosuggestTextareaRef.current!.textarea!.selectionStart; const editor = editorRef.current;
const needsSpace = !!data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]); if (!editor) return;
dispatch(insertEmojiCompose(id, position, data, needsSpace)); editor.update(() => {
editor.getEditorState()._selection?.insertNodes([$createEmojiNode(data), new TextNode(' ')]);
});
}; };
const onPaste = (files: FileList) => { const onPaste = (files: FileList) => {

Wyświetl plik

@ -49,6 +49,7 @@ interface IComposeEditor {
} }
const theme: InitialConfigType['theme'] = { 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', 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', 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', link: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue',

Wyświetl plik

@ -1,10 +1,10 @@
import { $applyNodeReplacement, DecoratorNode } from 'lexical'; import { $applyNodeReplacement, DecoratorNode } from 'lexical';
import React from 'react'; 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 { import type {
DOMExportOutput,
EditorConfig, EditorConfig,
LexicalNode, LexicalNode,
NodeKey, NodeKey,
@ -13,29 +13,26 @@ import type {
} from 'lexical'; } from 'lexical';
type SerializedEmojiNode = Spread<{ type SerializedEmojiNode = Spread<{
name: string data: Emoji
src: string
type: 'emoji' type: 'emoji'
version: 1 version: 1
}, SerializedLexicalNode>; }, SerializedLexicalNode>;
class EmojiNode extends DecoratorNode<JSX.Element> { class EmojiNode extends DecoratorNode<JSX.Element> {
__name: string; __emoji: Emoji;
__src: string;
static getType(): 'emoji' { static getType(): 'emoji' {
return 'emoji'; return 'emoji';
} }
static clone(node: EmojiNode): EmojiNode { 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); super(key);
this.__name = name; this.__emoji = emoji;
this.__src = src;
} }
createDOM(config: EditorConfig): HTMLElement { createDOM(config: EditorConfig): HTMLElement {
@ -52,24 +49,13 @@ class EmojiNode extends DecoratorNode<JSX.Element> {
return false; return false;
} }
exportDOM(): DOMExportOutput { static importJSON({ data }: SerializedEmojiNode): EmojiNode {
const element = document.createElement('img'); return $createEmojiNode(data);
element.setAttribute('src', this.__src);
element.setAttribute('alt', this.__name);
element.classList.add('h-4', 'w-4');
return { element };
}
static importJSON(serializedNode: SerializedEmojiNode): EmojiNode {
const { name, src } = serializedNode;
const node = $createEmojiNode(name, src);
return node;
} }
exportJSON(): SerializedEmojiNode { exportJSON(): SerializedEmojiNode {
return { return {
name: this.__name, data: this.__emoji,
src: this.__src,
type: 'emoji', type: 'emoji',
version: 1, version: 1,
}; };
@ -84,18 +70,29 @@ class EmojiNode extends DecoratorNode<JSX.Element> {
} }
getTextContent(): string { getTextContent(): string {
return this.__name; const emoji = this.__emoji;
if (isNativeEmoji(emoji)) {
return emoji.native;
} else {
return emoji.colons;
}
} }
decorate(): JSX.Element { decorate(): JSX.Element {
return ( const emoji = this.__emoji;
<Emoji src={this.__src} alt={this.__name} className='emojione h-4 w-4' /> if (isNativeEmoji(emoji)) {
); return <Component emoji={emoji.native} alt={emoji.colons} className='emojione h-4 w-4' />;
} else {
return <Component src={emoji.imageUrl} alt={emoji.colons} className='emojione h-4 w-4' />;
}
} }
} }
const $createEmojiNode = (name = '', src: string): EmojiNode => $applyNodeReplacement(new EmojiNode(name, src)); function $createEmojiNode(emoji: Emoji): EmojiNode {
const node = new EmojiNode(emoji);
return $applyNodeReplacement(node);
}
const $isEmojiNode = ( const $isEmojiNode = (
node: LexicalNode | null | undefined, node: LexicalNode | null | undefined,

Wyświetl plik

@ -18,6 +18,7 @@ import {
KEY_ESCAPE_COMMAND, KEY_ESCAPE_COMMAND,
KEY_TAB_COMMAND, KEY_TAB_COMMAND,
LexicalEditor, LexicalEditor,
LexicalNode,
RangeSelection, RangeSelection,
TextNode, TextNode,
} from 'lexical'; } from 'lexical';
@ -32,14 +33,14 @@ import React, {
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { clearComposeSuggestions, fetchComposeSuggestions } from 'soapbox/actions/compose'; import { clearComposeSuggestions, fetchComposeSuggestions } from 'soapbox/actions/compose';
import { useEmoji } from 'soapbox/actions/emojis'; import { chooseEmoji } from 'soapbox/actions/emojis';
import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji'; import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji';
import { isNativeEmoji } from 'soapbox/features/emoji';
import { useAppDispatch, useCompose } from 'soapbox/hooks'; import { useAppDispatch, useCompose } from 'soapbox/hooks';
import { selectAccount } from 'soapbox/selectors'; import { selectAccount } from 'soapbox/selectors';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import AutosuggestAccount from '../../components/autosuggest-account'; import AutosuggestAccount from '../../components/autosuggest-account';
import { $createEmojiNode } from '../nodes/emoji-node';
import { $createMentionNode } from '../nodes/mention-node'; import { $createMentionNode } from '../nodes/mention-node';
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
@ -309,25 +310,25 @@ const AutosuggestPlugin = ({
/** Offset for the beginning of the matched text, including the token. */ /** Offset for the beginning of the matched text, including the token. */
const offset = leadOffset - 1; 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 (typeof suggestion === 'object') {
if (!suggestion.id) return; if (!suggestion.id) return;
dispatch(useEmoji(suggestion)); // eslint-disable-line react-hooks/rules-of-hooks dispatch(chooseEmoji(suggestion));
replaceMatch($createEmojiNode(suggestion));
if (isNativeEmoji(suggestion)) {
node.spliceText(offset, matchingString.length, `${suggestion.native} `, true);
} else {
node.spliceText(offset, matchingString.length, `${suggestion.colons} `, true);
}
} else if (suggestion[0] === '#') { } else if (suggestion[0] === '#') {
node.setTextContent(`${suggestion} `); node.setTextContent(`${suggestion} `);
node.select(); node.select();
} else { } else {
const acct = selectAccount(getState(), suggestion)!.acct; const acct = selectAccount(getState(), suggestion)!.acct;
const result = (node as TextNode).splitText(offset, offset + matchingString.length); replaceMatch($createMentionNode(`@${acct}`));
const textNode = result[1] ?? result[0];
const mentionNode = textNode.replace($createMentionNode(`@${acct}`));
mentionNode.insertAfter(new TextNode(' '));
mentionNode.selectNext();
} }
dispatch(clearComposeSuggestions(composeId)); dispatch(clearComposeSuggestions(composeId));
@ -341,11 +342,6 @@ const AutosuggestPlugin = ({
if (!node) return null; if (!node) return null;
if (['hashtag'].includes(node.getType())) {
const matchingString = node.getTextContent();
return { leadOffset: 0, matchingString };
}
if (node.getType() === 'text') { if (node.getType() === 'text') {
const [leadOffset, matchingString] = textAtCursorMatchesToken( const [leadOffset, matchingString] = textAtCursorMatchesToken(
node.getTextContent(), node.getTextContent(),

Wyświetl plik

@ -3,9 +3,9 @@ import React, { useEffect, useState, useLayoutEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { useEmoji } from 'soapbox/actions/emojis'; import { chooseEmoji } from 'soapbox/actions/emojis';
import { changeSetting } from 'soapbox/actions/settings'; 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 { RootState } from 'soapbox/store';
import { buildCustomEmojis } from '../../emoji'; import { buildCustomEmojis } from '../../emoji';
@ -130,10 +130,8 @@ const EmojiPickerDropdown: React.FC<IEmojiPickerDropdown> = ({
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const settings = useSettings();
const title = intl.formatMessage(messages.emoji); const title = intl.formatMessage(messages.emoji);
const userTheme = settings.get('themeMode'); const theme = useTheme();
const theme = (userTheme === 'dark' || userTheme === 'light') ? userTheme : 'auto';
const customEmojis = useAppSelector((state) => getCustomEmojis(state)); const customEmojis = useAppSelector((state) => getCustomEmojis(state));
const frequentlyUsedEmojis = useAppSelector((state) => getFrequentlyUsedEmojis(state)); const frequentlyUsedEmojis = useAppSelector((state) => getFrequentlyUsedEmojis(state));
@ -162,7 +160,7 @@ const EmojiPickerDropdown: React.FC<IEmojiPickerDropdown> = ({
} as CustomEmoji; } as CustomEmoji;
} }
dispatch(useEmoji(pickedEmoji)); // eslint-disable-line react-hooks/rules-of-hooks dispatch(chooseEmoji(pickedEmoji));
if (onPickEmoji) { if (onPickEmoji) {
onPickEmoji(pickedEmoji); onPickEmoji(pickedEmoji);

Wyświetl plik

@ -18,7 +18,6 @@ const EmojiPickerDropdownContainer = (
) => { ) => {
const intl = useIntl(); const intl = useIntl();
const title = intl.formatMessage(messages.emoji); const title = intl.formatMessage(messages.emoji);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const { x, y, strategy, refs, update } = useFloating<HTMLButtonElement>({ const { x, y, strategy, refs, update } = useFloating<HTMLButtonElement>({

Wyświetl plik

@ -3,7 +3,7 @@ import { AnyAction } from 'redux';
import { ME_FETCH_SUCCESS } from 'soapbox/actions/me'; 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 { NOTIFICATIONS_FILTER_SET } from '../actions/notifications';
import { SEARCH_FILTER_SET } from '../actions/search'; import { SEARCH_FILTER_SET } from '../actions/search';
import { import {
@ -40,7 +40,7 @@ export default function settings(state: State = ImmutableMap<string, any>({ save
return state return state
.setIn(action.path, action.value) .setIn(action.path, action.value)
.set('saved', false); .set('saved', false);
case EMOJI_USE: case EMOJI_CHOOSE:
return updateFrequentEmojis(state, action.emoji); return updateFrequentEmojis(state, action.emoji);
case SETTING_SAVE: case SETTING_SAVE:
return state.set('saved', true); return state.set('saved', true);