Lexical: Cleanup, move TypeaheadMenuPlugin and parts of MentionPlugin to new AutosuggestPlugin

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2023-03-30 18:17:12 +02:00
parent 4ccfab3e5e
commit e30ea97aab
4 changed files with 115 additions and 132 deletions

View file

@ -26,6 +26,7 @@ import { setEditorState } from 'soapbox/actions/compose';
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
import nodes from './nodes';
import { AutosuggestPlugin } from './plugins/autosuggest-plugin';
import DraggableBlockPlugin from './plugins/draggable-block-plugin';
import FloatingLinkEditorPlugin from './plugins/floating-link-editor-plugin';
import FloatingTextFormatToolbarPlugin from './plugins/floating-text-format-toolbar-plugin';
@ -140,7 +141,8 @@ const ComposeEditor = React.forwardRef<string, any>(({ composeId, condensed, onF
/>
<HistoryPlugin />
<HashtagPlugin />
<MentionPlugin composeId={composeId} suggestionsHidden={suggestionsHidden} setSuggestionsHidden={setSuggestionsHidden} />
<MentionPlugin />
<AutosuggestPlugin composeId={composeId} suggestionsHidden={suggestionsHidden} setSuggestionsHidden={setSuggestionsHidden} />
{features.richText && <LinkPlugin />}
{features.richText && floatingAnchorElem && (
<>

View file

@ -7,6 +7,7 @@
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import clsx from 'clsx';
import {
$getSelection,
$isRangeSelection,
@ -22,6 +23,14 @@ import React, {
useRef,
useState,
} from 'react';
import ReactDOM from 'react-dom';
import { fetchComposeSuggestions } from 'soapbox/actions/compose';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import AutosuggestAccount from '../../components/autosuggest-account';
import { getMentionMatch } from './mention-plugin';
export type QueryMatch = {
leadOffset: number
@ -269,46 +278,95 @@ function useMenuAnchorRef(
return anchorElementRef;
}
export type TypeaheadMenuPluginProps = {
menuRenderFn: MenuRenderFn
triggerFn: TriggerFn
onOpen?: (resolution: Resolution) => void
onClose?: () => void
};
export type TriggerFn = (
text: string,
editor: LexicalEditor,
) => QueryMatch | null;
export function TypeaheadMenuPlugin({
onOpen,
onClose,
menuRenderFn,
triggerFn,
}: TypeaheadMenuPluginProps): JSX.Element | null {
export type AutosuggestPluginProps = {
composeId: string
suggestionsHidden: boolean
setSuggestionsHidden: (value: boolean) => void
};
export function AutosuggestPlugin({
composeId,
suggestionsHidden,
setSuggestionsHidden,
}: AutosuggestPluginProps): JSX.Element | null {
const { suggestions } = useCompose(composeId);
const dispatch = useAppDispatch();
const [editor] = useLexicalComposerContext();
const [resolution, setResolution] = useState<Resolution | null>(null);
const [selectedSuggestion] = useState(0);
const anchorElementRef = useMenuAnchorRef(
resolution,
setResolution,
);
const onSelectSuggestion: React.MouseEventHandler<HTMLDivElement> = (e) => {
e.preventDefault();
const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index') as any);
editor.update(() => {
dispatch((_, getState) => {
const state = editor.getEditorState();
const node = (state._selection as RangeSelection)?.anchor?.getNode();
const content = getState().accounts.get(suggestion)!.acct;
node.setTextContent(`@${content} `);
node.select();
});
});
};
const checkForMentionMatch = useCallback((text: string) => {
const matchArr = getMentionMatch(text);
if (!matchArr) return null;
dispatch(fetchComposeSuggestions(composeId, matchArr[0]));
return {
leadOffset: matchArr.index,
matchingString: matchArr[0],
};
}, []);
const renderSuggestion = (suggestion: string, i: number) => {
const inner = <AutosuggestAccount id={suggestion} />;
const key = suggestion;
return (
<div
role='button'
tabIndex={0}
key={key}
data-index={i}
className={clsx({
'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true,
'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion,
})}
onMouseDown={onSelectSuggestion}
>
{inner}
</div>
);
};
const closeTypeahead = useCallback(() => {
setResolution(null);
if (onClose && resolution !== null) {
onClose();
}
}, [onClose, resolution]);
}, [resolution]);
const openTypeahead = useCallback(
(res: Resolution) => {
setResolution(res);
if (onOpen && resolution === null) {
onOpen(res);
}
},
[onOpen, resolution],
[resolution],
);
useEffect(() => {
@ -322,7 +380,7 @@ export function TypeaheadMenuPlugin({
return;
}
const match = triggerFn(text, editor);
const match = checkForMentionMatch(text);
if (
match !== null &&
@ -350,16 +408,34 @@ export function TypeaheadMenuPlugin({
};
}, [
editor,
triggerFn,
resolution,
closeTypeahead,
openTypeahead,
]);
useEffect(() => {
if (suggestions && suggestions.size > 0) setSuggestionsHidden(false);
}, [suggestions]);
return resolution === null || editor === null ? null : (
<LexicalPopoverMenu
anchorElementRef={anchorElementRef}
menuRenderFn={menuRenderFn}
menuRenderFn={(anchorElementRef) =>
anchorElementRef.current
? ReactDOM.createPortal(
<div
className={clsx({
'mt-6 fixed z-1000 shadow bg-white dark:bg-gray-900 rounded-lg py-1 space-y-0 dark:ring-2 dark:ring-primary-700 focus:outline-none': true,
hidden: suggestionsHidden || suggestions.isEmpty(),
block: !suggestionsHidden && !suggestions.isEmpty(),
})}
>
{suggestions.map(renderSuggestion)}
</div>,
anchorElementRef.current,
)
: null
}
/>
);
}

View file

@ -159,13 +159,13 @@ const FloatingLinkEditor = ({
return (
<div
ref={editorRef}
className='absolute top-0 left-0 z-10 w-full max-w-sm rounded-lg bg-white opacity-0 shadow-md transition-opacity will-change-transform'
className='absolute top-0 left-0 z-10 w-full max-w-sm rounded-lg bg-white opacity-0 shadow-md transition-opacity will-change-transform dark:bg-gray-900'
>
<div className='relative my-2 mx-3 box-border block rounded-2xl border-0 bg-gray-100 py-2 px-3 text-sm text-gray-800 outline-0 dark:bg-gray-800 dark:text-gray-100'>
{isEditMode ? (
<>
<input
className='-my-2 -mx-3 w-full border-0 bg-transparent py-2 px-3 text-sm text-gray-900 outline-0'
className='-my-2 -mx-3 w-full border-0 bg-transparent py-2 px-3 text-sm text-gray-900 outline-0 dark:text-gray-100'
ref={inputRef}
value={linkUrl}
onChange={(event) => {

View file

@ -8,55 +8,25 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity';
import clsx from 'clsx';
import React, { useCallback, useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import { useCallback, useEffect } from 'react';
import { fetchComposeSuggestions } from 'soapbox/actions/compose';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import AutosuggestAccount from '../../components/autosuggest-account';
import { $createMentionNode, MentionNode } from '../nodes/mention-node';
import { TypeaheadMenuPlugin } from './typeahead-menu-plugin';
import type { RangeSelection, TextNode } from 'lexical';
import type { TextNode } from 'lexical';
const REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i');
export const MentionPlugin: React.FC<{
composeId: string
suggestionsHidden: boolean
setSuggestionsHidden: (value: boolean) => void
}> = ({
composeId, suggestionsHidden, setSuggestionsHidden,
}): JSX.Element | null => {
const { suggestions } = useCompose(composeId);
const dispatch = useAppDispatch();
export const getMentionMatch = (text: string) => {
const matchArr = REGEX.exec(text);
if (!matchArr) return null;
return matchArr;
};
export const MentionPlugin = (): JSX.Element | null => {
const [editor] = useLexicalComposerContext();
const [selectedSuggestion] = useState(0);
const onSelectSuggestion: React.MouseEventHandler<HTMLDivElement> = (e) => {
e.preventDefault();
const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index') as any);
editor.update(() => {
dispatch((_, getState) => {
const state = editor.getEditorState();
const node = (state._selection as RangeSelection)?.anchor?.getNode();
const content = getState().accounts.get(suggestion)!.acct;
node.setTextContent(`@${content} `);
node.select();
});
});
};
useEffect(() => {
if (!editor.hasNodes([MentionNode])) {
throw new Error('MentionPlugin: MentionNode not registered on editor');
@ -67,13 +37,6 @@ export const MentionPlugin: React.FC<{
return $createMentionNode(textNode.getTextContent());
}, []);
const getMentionMatch = (text: string) => {
const matchArr = REGEX.exec(text);
if (!matchArr) return null;
return matchArr;
};
const getEntityMatch = useCallback((text: string) => {
const matchArr = getMentionMatch(text);
@ -88,69 +51,11 @@ export const MentionPlugin: React.FC<{
};
}, []);
const checkForMentionMatch = useCallback((text: string) => {
const matchArr = getMentionMatch(text);
if (!matchArr) return null;
dispatch(fetchComposeSuggestions(composeId, matchArr[0]));
return {
leadOffset: matchArr.index,
matchingString: matchArr[0],
};
}, []);
useLexicalTextEntity<MentionNode>(
getEntityMatch,
MentionNode,
createMentionNode,
);
const renderSuggestion = (suggestion: string, i: number) => {
const inner = <AutosuggestAccount id={suggestion} />;
const key = suggestion;
return (
<div
role='button'
tabIndex={0}
key={key}
data-index={i}
className={clsx({
'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true,
'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion,
})}
onMouseDown={onSelectSuggestion}
>
{inner}
</div>
);
};
useEffect(() => {
if (suggestions && suggestions.size > 0) setSuggestionsHidden(false);
}, [suggestions]);
return (
<TypeaheadMenuPlugin
triggerFn={checkForMentionMatch}
menuRenderFn={(anchorElementRef) =>
anchorElementRef.current
? ReactDOM.createPortal(
<div
className={clsx({
'mt-6 fixed z-1000 shadow bg-white dark:bg-gray-900 rounded-lg py-1 space-y-0 dark:ring-2 dark:ring-primary-700 focus:outline-none': true,
hidden: suggestionsHidden || suggestions.isEmpty(),
block: !suggestionsHidden && !suggestions.isEmpty(),
})}
>
{suggestions.map(renderSuggestion)}
</div>,
anchorElementRef.current,
)
: null
}
/>
);
return null;
};