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:
parent
4ccfab3e5e
commit
e30ea97aab
4 changed files with 115 additions and 132 deletions
|
@ -26,6 +26,7 @@ import { setEditorState } from 'soapbox/actions/compose';
|
||||||
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
|
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
|
||||||
|
|
||||||
import nodes from './nodes';
|
import nodes from './nodes';
|
||||||
|
import { AutosuggestPlugin } from './plugins/autosuggest-plugin';
|
||||||
import DraggableBlockPlugin from './plugins/draggable-block-plugin';
|
import DraggableBlockPlugin from './plugins/draggable-block-plugin';
|
||||||
import FloatingLinkEditorPlugin from './plugins/floating-link-editor-plugin';
|
import FloatingLinkEditorPlugin from './plugins/floating-link-editor-plugin';
|
||||||
import FloatingTextFormatToolbarPlugin from './plugins/floating-text-format-toolbar-plugin';
|
import FloatingTextFormatToolbarPlugin from './plugins/floating-text-format-toolbar-plugin';
|
||||||
|
@ -140,7 +141,8 @@ const ComposeEditor = React.forwardRef<string, any>(({ composeId, condensed, onF
|
||||||
/>
|
/>
|
||||||
<HistoryPlugin />
|
<HistoryPlugin />
|
||||||
<HashtagPlugin />
|
<HashtagPlugin />
|
||||||
<MentionPlugin composeId={composeId} suggestionsHidden={suggestionsHidden} setSuggestionsHidden={setSuggestionsHidden} />
|
<MentionPlugin />
|
||||||
|
<AutosuggestPlugin composeId={composeId} suggestionsHidden={suggestionsHidden} setSuggestionsHidden={setSuggestionsHidden} />
|
||||||
{features.richText && <LinkPlugin />}
|
{features.richText && <LinkPlugin />}
|
||||||
{features.richText && floatingAnchorElem && (
|
{features.richText && floatingAnchorElem && (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
$getSelection,
|
$getSelection,
|
||||||
$isRangeSelection,
|
$isRangeSelection,
|
||||||
|
@ -22,6 +23,14 @@ import React, {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} 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 = {
|
export type QueryMatch = {
|
||||||
leadOffset: number
|
leadOffset: number
|
||||||
|
@ -269,46 +278,95 @@ function useMenuAnchorRef(
|
||||||
return anchorElementRef;
|
return anchorElementRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TypeaheadMenuPluginProps = {
|
|
||||||
menuRenderFn: MenuRenderFn
|
|
||||||
triggerFn: TriggerFn
|
|
||||||
onOpen?: (resolution: Resolution) => void
|
|
||||||
onClose?: () => void
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TriggerFn = (
|
export type TriggerFn = (
|
||||||
text: string,
|
text: string,
|
||||||
editor: LexicalEditor,
|
editor: LexicalEditor,
|
||||||
) => QueryMatch | null;
|
) => QueryMatch | null;
|
||||||
|
|
||||||
export function TypeaheadMenuPlugin({
|
export type AutosuggestPluginProps = {
|
||||||
onOpen,
|
composeId: string
|
||||||
onClose,
|
suggestionsHidden: boolean
|
||||||
menuRenderFn,
|
setSuggestionsHidden: (value: boolean) => void
|
||||||
triggerFn,
|
};
|
||||||
}: TypeaheadMenuPluginProps): JSX.Element | null {
|
|
||||||
|
export function AutosuggestPlugin({
|
||||||
|
composeId,
|
||||||
|
suggestionsHidden,
|
||||||
|
setSuggestionsHidden,
|
||||||
|
}: AutosuggestPluginProps): JSX.Element | null {
|
||||||
|
const { suggestions } = useCompose(composeId);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
const [resolution, setResolution] = useState<Resolution | null>(null);
|
const [resolution, setResolution] = useState<Resolution | null>(null);
|
||||||
|
const [selectedSuggestion] = useState(0);
|
||||||
const anchorElementRef = useMenuAnchorRef(
|
const anchorElementRef = useMenuAnchorRef(
|
||||||
resolution,
|
resolution,
|
||||||
setResolution,
|
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(() => {
|
const closeTypeahead = useCallback(() => {
|
||||||
setResolution(null);
|
setResolution(null);
|
||||||
if (onClose && resolution !== null) {
|
}, [resolution]);
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, [onClose, resolution]);
|
|
||||||
|
|
||||||
const openTypeahead = useCallback(
|
const openTypeahead = useCallback(
|
||||||
(res: Resolution) => {
|
(res: Resolution) => {
|
||||||
setResolution(res);
|
setResolution(res);
|
||||||
if (onOpen && resolution === null) {
|
|
||||||
onOpen(res);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onOpen, resolution],
|
[resolution],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -322,7 +380,7 @@ export function TypeaheadMenuPlugin({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = triggerFn(text, editor);
|
const match = checkForMentionMatch(text);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
match !== null &&
|
match !== null &&
|
||||||
|
@ -350,16 +408,34 @@ export function TypeaheadMenuPlugin({
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
editor,
|
editor,
|
||||||
triggerFn,
|
|
||||||
resolution,
|
resolution,
|
||||||
closeTypeahead,
|
closeTypeahead,
|
||||||
openTypeahead,
|
openTypeahead,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (suggestions && suggestions.size > 0) setSuggestionsHidden(false);
|
||||||
|
}, [suggestions]);
|
||||||
|
|
||||||
return resolution === null || editor === null ? null : (
|
return resolution === null || editor === null ? null : (
|
||||||
<LexicalPopoverMenu
|
<LexicalPopoverMenu
|
||||||
anchorElementRef={anchorElementRef}
|
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
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -159,13 +159,13 @@ const FloatingLinkEditor = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={editorRef}
|
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'>
|
<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 ? (
|
{isEditMode ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<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}
|
ref={inputRef}
|
||||||
value={linkUrl}
|
value={linkUrl}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
|
|
|
@ -8,55 +8,25 @@
|
||||||
|
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity';
|
import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity';
|
||||||
import clsx from 'clsx';
|
import { useCallback, useEffect } from 'react';
|
||||||
import React, { useCallback, useEffect, 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 { $createMentionNode, MentionNode } from '../nodes/mention-node';
|
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');
|
const REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i');
|
||||||
|
|
||||||
export const MentionPlugin: React.FC<{
|
export const getMentionMatch = (text: string) => {
|
||||||
composeId: string
|
const matchArr = REGEX.exec(text);
|
||||||
suggestionsHidden: boolean
|
|
||||||
setSuggestionsHidden: (value: boolean) => void
|
|
||||||
}> = ({
|
|
||||||
composeId, suggestionsHidden, setSuggestionsHidden,
|
|
||||||
}): JSX.Element | null => {
|
|
||||||
const { suggestions } = useCompose(composeId);
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const [editor] = useLexicalComposerContext();
|
if (!matchArr) return null;
|
||||||
|
return matchArr;
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MentionPlugin = (): JSX.Element | null => {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor.hasNodes([MentionNode])) {
|
if (!editor.hasNodes([MentionNode])) {
|
||||||
throw new Error('MentionPlugin: MentionNode not registered on editor');
|
throw new Error('MentionPlugin: MentionNode not registered on editor');
|
||||||
|
@ -67,13 +37,6 @@ export const MentionPlugin: React.FC<{
|
||||||
return $createMentionNode(textNode.getTextContent());
|
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 getEntityMatch = useCallback((text: string) => {
|
||||||
const matchArr = getMentionMatch(text);
|
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>(
|
useLexicalTextEntity<MentionNode>(
|
||||||
getEntityMatch,
|
getEntityMatch,
|
||||||
MentionNode,
|
MentionNode,
|
||||||
createMentionNode,
|
createMentionNode,
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderSuggestion = (suggestion: string, i: number) => {
|
return null;
|
||||||
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
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue