Lexical: Hashtag, emoji autocompletion

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-lexical-ujdd17/deployments/3115
marcin mikołajczak 2023-04-02 20:50:15 +02:00
rodzic 46ffd053bb
commit 9c49fc3d8a
10 zmienionych plików z 184 dodań i 143 usunięć

Wyświetl plik

@ -6,6 +6,7 @@ import { defineMessages, IntlShape } from 'react-intl';
import api from 'soapbox/api';
import { isNativeEmoji } from 'soapbox/features/emoji';
import emojiSearch from 'soapbox/features/emoji/search';
import { normalizeTag } from 'soapbox/normalizers';
import { tagHistory } from 'soapbox/settings';
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
@ -29,7 +30,7 @@ import type { History } from 'soapbox/types/history';
const { CancelToken, isCancel } = axios;
let cancelFetchComposeSuggestionsAccounts: Canceler;
let cancelFetchComposeSuggestions: Canceler;
const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
@ -500,8 +501,8 @@ const setGroupTimelineVisible = (composeId: string, groupTimelineVisible: boolea
});
const clearComposeSuggestions = (composeId: string) => {
if (cancelFetchComposeSuggestionsAccounts) {
cancelFetchComposeSuggestionsAccounts();
if (cancelFetchComposeSuggestions) {
cancelFetchComposeSuggestions();
}
return {
type: COMPOSE_SUGGESTIONS_CLEAR,
@ -510,12 +511,12 @@ const clearComposeSuggestions = (composeId: string) => {
};
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => {
if (cancelFetchComposeSuggestionsAccounts) {
cancelFetchComposeSuggestionsAccounts(composeId);
if (cancelFetchComposeSuggestions) {
cancelFetchComposeSuggestions(composeId);
}
api(getState).get('/api/v1/accounts/search', {
cancelToken: new CancelToken(cancel => {
cancelFetchComposeSuggestionsAccounts = cancel;
cancelFetchComposeSuggestions = cancel;
}),
params: {
q: token.slice(1),
@ -540,10 +541,37 @@ const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => Ro
};
const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
const state = getState();
const currentTrends = state.trends.items;
if (cancelFetchComposeSuggestions) {
cancelFetchComposeSuggestions(composeId);
}
dispatch(updateSuggestionTags(composeId, token, currentTrends));
const state = getState();
const instance = state.instance;
const { trends } = getFeatures(instance);
if (trends) {
const currentTrends = state.trends.items;
return dispatch(updateSuggestionTags(composeId, token, currentTrends));
}
api(getState).get('/api/v2/search', {
cancelToken: new CancelToken(cancel => {
cancelFetchComposeSuggestions = cancel;
}),
params: {
q: token.slice(1),
limit: 4,
type: 'hashtags',
},
}).then(response => {
dispatch(updateSuggestionTags(composeId, token, response.data?.hashtags.map(normalizeTag)));
}).catch(error => {
if (!isCancel(error)) {
toast.showAlertForError(error);
}
});
};
const fetchComposeSuggestions = (composeId: string, token: string) =>
@ -602,11 +630,11 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str
});
};
const updateSuggestionTags = (composeId: string, token: string, currentTrends: ImmutableList<Tag>) => ({
const updateSuggestionTags = (composeId: string, token: string, tags: ImmutableList<Tag>) => ({
type: COMPOSE_SUGGESTION_TAGS_UPDATE,
id: composeId,
token,
currentTrends,
tags,
});
const updateTagHistory = (composeId: string, tags: string[]) => ({

Wyświetl plik

@ -5,16 +5,21 @@ import { joinPublicPath } from 'soapbox/utils/static';
interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
/** Unicode emoji character. */
emoji: string
emoji?: string
}
/** A single emoji image. */
const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
const { emoji, alt, src, ...rest } = props;
const codepoints = toCodePoints(removeVS16s(emoji));
const filename = codepoints.join('-');
if (!filename) return null;
let filename;
if (emoji) {
const codepoints = toCodePoints(removeVS16s(emoji));
filename = codepoints.join('-');
}
if (!filename && !src) return null;
return (
<img

Wyświetl plik

@ -29,7 +29,6 @@ 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';
@ -143,7 +142,6 @@ const ComposeEditor = React.forwardRef<string, any>(({ composeId, condensed, onF
/>
<HistoryPlugin />
<HashtagPlugin />
<EmojiPlugin />
<MentionPlugin />
<AutosuggestPlugin composeId={composeId} suggestionsHidden={suggestionsHidden} setSuggestionsHidden={setSuggestionsHidden} />
{features.richText && <LinkPlugin />}

Wyświetl plik

@ -1,54 +1,74 @@
/**
* 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 { $applyNodeReplacement, DecoratorNode } from 'lexical';
import React from 'react';
import { addClassNamesToElement } from '@lexical/utils';
import { $applyNodeReplacement, TextNode } from 'lexical';
import { Emoji } from 'soapbox/components/ui';
import type {
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
SerializedTextNode,
SerializedLexicalNode,
Spread,
} from 'lexical';
class EmojiNode extends TextNode {
type SerializedEmojiNode = Spread<{
name: string
src: string
type: 'emoji'
version: 1
}, SerializedLexicalNode>;
static getType(): string {
class EmojiNode extends DecoratorNode<JSX.Element> {
__name: string;
__src: string;
static getType(): 'emoji' {
return 'emoji';
}
static clone(node: EmojiNode): EmojiNode {
return new EmojiNode(node.__text, node.__key);
return new EmojiNode(node.__name, node.__key);
}
constructor(text: string, key?: NodeKey) {
super(text, key);
constructor(name: string, src: string, key?: NodeKey) {
super(key);
this.__name = name;
this.__src = src;
}
createDOM(config: EditorConfig): HTMLElement {
const element = super.createDOM(config);
addClassNamesToElement(element, config.theme.emoji);
return element;
const span = document.createElement('span');
const theme = config.theme;
const className = theme.emoji;
if (className !== undefined) {
span.className = className;
}
return span;
}
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);
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(serializedNode: SerializedEmojiNode): EmojiNode {
const { name, src } =
serializedNode;
const node = $createEmojiNode(name, src);
return node;
}
exportJSON(): SerializedTextNode {
exportJSON(): SerializedEmojiNode {
return {
...super.exportJSON(),
name: this.__name,
src: this.__src,
type: 'emoji',
version: 1,
};
}
@ -60,9 +80,15 @@ class EmojiNode extends TextNode {
return true;
}
decorate(): JSX.Element {
return (
<Emoji src={this.__src} alt={this.__name} className='emojione h-4 w-4' />
);
}
}
const $createEmojiNode = (text = ''): EmojiNode => $applyNodeReplacement(new EmojiNode(text).setMode('token'));
const $createEmojiNode = (name = '', src: string): EmojiNode => $applyNodeReplacement(new EmojiNode(name, src));
const $isEmojiNode = (
node: LexicalNode | null | undefined,

Wyświetl plik

@ -26,18 +26,16 @@ import React, {
import ReactDOM from 'react-dom';
import { fetchComposeSuggestions } from 'soapbox/actions/compose';
import { useEmoji } from 'soapbox/actions/emojis';
import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji';
import { isNativeEmoji } from 'soapbox/features/emoji';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import AutosuggestAccount from '../../components/autosuggest-account';
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
matchingString: string
@ -75,15 +73,6 @@ function tryToPositionRange(leadOffset: number, range: Range): boolean {
return true;
}
function getQueryTextForSearch(editor: LexicalEditor): string | null {
const state = editor.getEditorState();
const node = (state._selection as RangeSelection)?.anchor?.getNode();
if (node && (node.getType() === 'mention' || node.getType() === 'text')) return node.getTextContent();
return null;
}
function isSelectionOnEntityBoundary(
editor: LexicalEditor,
offset: number,
@ -309,34 +298,60 @@ export function AutosuggestPlugin({
const onSelectSuggestion: React.MouseEventHandler<HTMLDivElement> = (e) => {
e.preventDefault();
const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index') as any);
const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index') as any) as AutoSuggestion;
editor.update(() => {
dispatch((_, getState) => {
dispatch((dispatch, getState) => {
const state = editor.getEditorState();
const node = (state._selection as RangeSelection)?.anchor?.getNode();
const content = getState().accounts.get(suggestion)!.acct;
if (typeof suggestion === 'object' && suggestion.id) {
dispatch(useEmoji(suggestion)); // eslint-disable-line react-hooks/rules-of-hooks
node.setTextContent(`@${content} `);
node.select();
const { leadOffset, matchingString } = resolution!.match;
if (isNativeEmoji(suggestion)) {
node.spliceText(leadOffset - 1, matchingString.length, `${suggestion.native} `, true);
} else {
// const completion = suggestion.colons;
// node.replace($createEmojiNode(completion, suggestion.imageUrl));
}
} else if ((suggestion as string)[0] === '#') {
node.setTextContent(`${suggestion} `);
node.select();
} else {
const content = getState().accounts.get(suggestion)!.acct;
node.setTextContent(`@${content} `);
node.select();
}
});
});
};
const checkForMatch = useCallback((text: string) => {
const matchArr = MENTION_REGEX.exec(text) || EMOJI_REGEX.exec(text);
const getQueryTextForSearch = (editor: LexicalEditor) => {
const state = editor.getEditorState();
const node = (state._selection as RangeSelection)?.anchor?.getNode();
if (!matchArr) return null;
if (!node) return null;
dispatch(fetchComposeSuggestions(composeId, matchArr[0]?.trim()));
if (['mention', 'hashtag'].includes(node.getType())) {
const matchingString = node.getTextContent();
return {
leadOffset: matchArr.index,
matchingString: matchArr[0],
};
}, []);
return { leadOffset: 0, matchingString };
}
if (node.getType() === 'text') {
const [leadOffset, matchingString] = textAtCursorMatchesToken(node.getTextContent(), (state._selection as RangeSelection)?.anchor?.offset, [':']);
if (!leadOffset || !matchingString) return null;
return { leadOffset, matchingString };
}
return null;
};
const renderSuggestion = (suggestion: AutoSuggestion, i: number) => {
let inner;
@ -345,6 +360,9 @@ export function AutosuggestPlugin({
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else if (suggestion[0] === '#') {
inner = suggestion;
key = suggestion;
} else {
inner = <AutosuggestAccount id={suggestion} />;
key = suggestion;
@ -382,17 +400,16 @@ export function AutosuggestPlugin({
const updateListener = () => {
editor.getEditorState().read(() => {
const range = document.createRange();
const text = getQueryTextForSearch(editor);
const match = getQueryTextForSearch(editor);
if (!text) {
if (!match) {
closeTypeahead();
return;
}
const match = checkForMatch(text);
dispatch(fetchComposeSuggestions(composeId, match.matchingString.trim()));
if (
match !== null &&
!isSelectionOnEntityBoundary(editor, match.leadOffset)
) {
const isRangePositioned = tryToPositionRange(match.leadOffset, range);

Wyświetl plik

@ -1,61 +0,0 @@
/**
* 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 { $createEmojiNode, EmojiNode } from '../nodes/emoji-node';
import type { TextNode } from 'lexical';
const REGEX = new RegExp('ggfafsdasdf(^|$|(?:^|\\s))([:])([a-z\\d_-]+([:]))', 'i');
export const getEmojiMatch = (text: string) => {
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<EmojiNode>(
getEntityMatch,
EmojiNode,
createEmojiNode,
);
return null;
};

Wyświetl plik

@ -183,11 +183,11 @@ const insertSuggestion = (compose: Compose, position: number, token: string, com
});
};
const updateSuggestionTags = (compose: Compose, token: string, currentTrends: ImmutableList<Tag>) => {
const updateSuggestionTags = (compose: Compose, token: string, tags: ImmutableList<Tag>) => {
const prefix = token.slice(1);
return compose.merge({
suggestions: ImmutableList(currentTrends
suggestions: ImmutableList(tags
.filter((tag) => tag.get('name').toLowerCase().startsWith(prefix.toLowerCase()))
.slice(0, 4)
.map((tag) => '#' + tag.name)),
@ -412,7 +412,7 @@ export default function compose(state = initialState, action: AnyAction) {
case COMPOSE_SUGGESTION_SELECT:
return updateCompose(state, action.id, compose => insertSuggestion(compose, action.position, action.token, action.completion, action.path));
case COMPOSE_SUGGESTION_TAGS_UPDATE:
return updateCompose(state, action.id, compose => updateSuggestionTags(compose, action.token, action.currentTrends));
return updateCompose(state, action.id, compose => updateSuggestionTags(compose, action.token, action.tags));
case COMPOSE_TAG_HISTORY_UPDATE:
return updateCompose(state, action.id, compose => compose.set('tagHistory', ImmutableList(fromJS(action.tags)) as ImmutableList<string>));
case TIMELINE_DELETE:

Wyświetl plik

@ -32,4 +32,4 @@ const textAtCursorMatchesToken = (
}
};
export { textAtCursorMatchesToken };
export { textAtCursorMatchesToken };

Wyświetl plik

@ -62,7 +62,7 @@
"@lexical/react": "^0.9.0",
"@lexical/rich-text": "^0.9.0",
"@lexical/selection": "^0.9.0",
"@lexical/utils": "^0.9.0",
"@lexical/utils": "^0.9.1",
"@metamask/providers": "^10.0.0",
"@popperjs/core": "^2.11.5",
"@reach/combobox": "^0.18.0",

Wyświetl plik

@ -2440,6 +2440,13 @@
dependencies:
"@lexical/utils" "0.9.0"
"@lexical/list@0.9.1":
version "0.9.1"
resolved "https://registry.yarnpkg.com/@lexical/list/-/list-0.9.1.tgz#18eba1e28d818a53661b7381f32b4461f024981e"
integrity sha512-z3wJfDjStesqjvamUgMez+CVvfGrmVqyGgMnCkYBrlfSZ8zlHvz8sJ/6iZkTh7TXIEkCTQpusCrkjUeYccmTxQ==
dependencies:
"@lexical/utils" "0.9.1"
"@lexical/mark@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@lexical/mark/-/mark-0.9.0.tgz#040d3e8d3e2f46160bd4e5b1e6a5489df0c6aa46"
@ -2508,6 +2515,11 @@
resolved "https://registry.yarnpkg.com/@lexical/selection/-/selection-0.9.0.tgz#2c11085f94435c1c71344654a1f2b9a0c3549a22"
integrity sha512-jPLeaqNujER1t61OWmSuUMS010A6Nz49efO8rEJFjMbUK7GB9IDsSDgvE4WnT/yBrXLAickjSiXgUq51LQTYYw==
"@lexical/selection@0.9.1":
version "0.9.1"
resolved "https://registry.yarnpkg.com/@lexical/selection/-/selection-0.9.1.tgz#6d809d9b8c3673992ba6fc6ccd6af5f402f57ba0"
integrity sha512-4eZcH8d4Kq/GswobVNIkM5fKk8YXPNFLCOHIyi0FmHymW/Ku7HLz2+mcOR6EQmk1Ce/4VJ4Oq+d1XlZw4PPTNA==
"@lexical/table@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@lexical/table/-/table-0.9.0.tgz#8f4e0d797c15141e26667cc54d7b640c90f5a9b8"
@ -2515,12 +2527,19 @@
dependencies:
"@lexical/utils" "0.9.0"
"@lexical/table@0.9.1":
version "0.9.1"
resolved "https://registry.yarnpkg.com/@lexical/table/-/table-0.9.1.tgz#06978da97425e46399d48b5d75d19b389c5b6a38"
integrity sha512-BPKmpToQRxv87OtA6W22XwfOBtZf+vG95jp/bj2ecJAW5BVMjHf5ViI3vjXf3Z701giVh1OVXNct8EPkcfvg3w==
dependencies:
"@lexical/utils" "0.9.1"
"@lexical/text@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@lexical/text/-/text-0.9.0.tgz#ee90b42d8558123c917f561cfbfb804dac25e0a4"
integrity sha512-+OHoB1Qb2SajGTEm9K4I3hDtCmeLr0Bs62psra6qYE/lil0Y8brrEXB5egppDpm6mKtiMzGrkBDMpcsKw/kiWA==
"@lexical/utils@0.9.0", "@lexical/utils@^0.9.0":
"@lexical/utils@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@lexical/utils/-/utils-0.9.0.tgz#923e79af94566844442bc8699aba89f7d1cf5779"
integrity sha512-s4BrBKrd7VHexLSYdSKrRAVmuxmfVV49MYx/khILGnNQKYS2O8PuERCXi+IRi8Ac5ASELO6d28Y2my8NMM1CYA==
@ -2529,6 +2548,15 @@
"@lexical/selection" "0.9.0"
"@lexical/table" "0.9.0"
"@lexical/utils@0.9.1", "@lexical/utils@^0.9.1":
version "0.9.1"
resolved "https://registry.yarnpkg.com/@lexical/utils/-/utils-0.9.1.tgz#5fd58468da890e7ed5260c502f66fd19ec64e9b3"
integrity sha512-cszNQufH6UZxOVew7DTELM63wjj8qOiGiOFklMmM6QmkirHb2fK4LxeLjaKiwP5DkA0A1NDNz8yuuMXqA/mWPQ==
dependencies:
"@lexical/list" "0.9.1"
"@lexical/selection" "0.9.1"
"@lexical/table" "0.9.1"
"@lexical/yjs@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@lexical/yjs/-/yjs-0.9.0.tgz#88d90c346102bf46f5ee2ef6a6c763bef1f12bf6"