Lexical: Autosuggest: Still needs cleanup
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
8e31499763
commit
750306ee0e
2 changed files with 35 additions and 699 deletions
|
@ -7,11 +7,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
import {
|
|
||||||
QueryMatch,
|
|
||||||
TypeaheadOption,
|
|
||||||
useBasicTypeaheadTriggerMatch,
|
|
||||||
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
|
|
||||||
import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity';
|
import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
@ -29,153 +24,6 @@ import type { RangeSelection, TextNode } from 'lexical';
|
||||||
|
|
||||||
const REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i');
|
const REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i');
|
||||||
|
|
||||||
const PUNCTUATION =
|
|
||||||
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';
|
|
||||||
const NAME = '\\b[A-Z][^\\s' + PUNCTUATION + ']';
|
|
||||||
|
|
||||||
const DocumentMentionsRegex = {
|
|
||||||
NAME,
|
|
||||||
PUNCTUATION,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CapitalizedNameMentionsRegex = new RegExp(
|
|
||||||
'(^|[^#])((?:' + DocumentMentionsRegex.NAME + '{' + 1 + ',})$)',
|
|
||||||
);
|
|
||||||
|
|
||||||
const PUNC = DocumentMentionsRegex.PUNCTUATION;
|
|
||||||
|
|
||||||
const TRIGGERS = ['@'].join('');
|
|
||||||
|
|
||||||
// Chars we expect to see in a mention (non-space, non-punctuation).
|
|
||||||
const VALID_CHARS = '[^' + TRIGGERS + PUNC + '\\s]';
|
|
||||||
|
|
||||||
const AtSignMentionsRegex = REGEX;
|
|
||||||
|
|
||||||
// 50 is the longest alias length limit.
|
|
||||||
const ALIAS_LENGTH_LIMIT = 50;
|
|
||||||
|
|
||||||
// Regex used to match alias.
|
|
||||||
const AtSignMentionsRegexAliasRegex = new RegExp(
|
|
||||||
'(^|\\s|\\()(' +
|
|
||||||
'[' +
|
|
||||||
TRIGGERS +
|
|
||||||
']' +
|
|
||||||
'((?:' +
|
|
||||||
VALID_CHARS +
|
|
||||||
'){0,' +
|
|
||||||
ALIAS_LENGTH_LIMIT +
|
|
||||||
'})' +
|
|
||||||
')$',
|
|
||||||
);
|
|
||||||
|
|
||||||
const mentionsCache = new Map();
|
|
||||||
|
|
||||||
const dummyMentionsData = ['Test'];
|
|
||||||
|
|
||||||
const dummyLookupService = {
|
|
||||||
search(string: string, callback: (results: Array<string>) => void): void {
|
|
||||||
setTimeout(() => {
|
|
||||||
const results = dummyMentionsData.filter((mention) =>
|
|
||||||
mention.toLowerCase().includes(string.toLowerCase()),
|
|
||||||
);
|
|
||||||
callback(results);
|
|
||||||
}, 500);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const useMentionLookupService = (mentionString: string | null) => {
|
|
||||||
const [results, setResults] = useState<Array<string>>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cachedResults = mentionsCache.get(mentionString);
|
|
||||||
|
|
||||||
if (mentionString === null) {
|
|
||||||
setResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cachedResults === null) {
|
|
||||||
return;
|
|
||||||
} else if (cachedResults !== undefined) {
|
|
||||||
setResults(cachedResults);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
mentionsCache.set(mentionString, null);
|
|
||||||
dummyLookupService.search(mentionString, (newResults) => {
|
|
||||||
mentionsCache.set(mentionString, newResults);
|
|
||||||
setResults(newResults);
|
|
||||||
});
|
|
||||||
}, [mentionString]);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkForCapitalizedNameMentions = (
|
|
||||||
text: string,
|
|
||||||
minMatchLength: number,
|
|
||||||
): QueryMatch | null => {
|
|
||||||
const match = CapitalizedNameMentionsRegex.exec(text);
|
|
||||||
if (match !== null) {
|
|
||||||
// The strategy ignores leading whitespace but we need to know it's
|
|
||||||
// length to add it to the leadOffset
|
|
||||||
const maybeLeadingWhitespace = match[1];
|
|
||||||
|
|
||||||
const matchingString = match[2];
|
|
||||||
if (matchingString !== null && matchingString.length >= minMatchLength) {
|
|
||||||
return {
|
|
||||||
leadOffset: match.index + maybeLeadingWhitespace.length,
|
|
||||||
matchingString,
|
|
||||||
replaceableString: matchingString,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkForAtSignMentions = (
|
|
||||||
text: string,
|
|
||||||
minMatchLength: number,
|
|
||||||
): QueryMatch | null => {
|
|
||||||
let match = AtSignMentionsRegex.exec(text);
|
|
||||||
if (match === null) {
|
|
||||||
match = AtSignMentionsRegexAliasRegex.exec(text);
|
|
||||||
}
|
|
||||||
if (match !== null) {
|
|
||||||
// The strategy ignores leading whitespace but we need to know it's
|
|
||||||
// length to add it to the leadOffset
|
|
||||||
const maybeLeadingWhitespace = match[1];
|
|
||||||
|
|
||||||
const matchingString = match[3];
|
|
||||||
if (matchingString.length >= minMatchLength) {
|
|
||||||
return {
|
|
||||||
leadOffset: match.index + maybeLeadingWhitespace.length,
|
|
||||||
matchingString,
|
|
||||||
replaceableString: match[2],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPossibleQueryMatch = (text: string): QueryMatch | null => {
|
|
||||||
const match = checkForAtSignMentions(text, 1);
|
|
||||||
return match === null ? checkForCapitalizedNameMentions(text, 3) : match;
|
|
||||||
};
|
|
||||||
|
|
||||||
class MentionTypeaheadOption extends TypeaheadOption {
|
|
||||||
|
|
||||||
name: string;
|
|
||||||
picture: JSX.Element;
|
|
||||||
|
|
||||||
constructor(name: string, picture: JSX.Element) {
|
|
||||||
super(name);
|
|
||||||
this.name = name;
|
|
||||||
this.picture = picture;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MentionPlugin: React.FC<{
|
export const MentionPlugin: React.FC<{
|
||||||
composeId: string
|
composeId: string
|
||||||
suggestionsHidden: boolean
|
suggestionsHidden: boolean
|
||||||
|
@ -188,19 +36,8 @@ export const MentionPlugin: React.FC<{
|
||||||
|
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
const [queryString, setQueryString] = useState<string | null>(null);
|
|
||||||
const [selectedSuggestion] = useState(0);
|
const [selectedSuggestion] = useState(0);
|
||||||
|
|
||||||
const results = useMentionLookupService(queryString);
|
|
||||||
|
|
||||||
const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
|
|
||||||
minLength: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const options = [new MentionTypeaheadOption('', <i className='icon user' />)];
|
|
||||||
|
|
||||||
const onSelectOption = useCallback(() => { }, [editor]);
|
|
||||||
|
|
||||||
const onSelectSuggestion: React.MouseEventHandler<HTMLDivElement> = (e) => {
|
const onSelectSuggestion: React.MouseEventHandler<HTMLDivElement> = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
@ -220,15 +57,6 @@ export const MentionPlugin: React.FC<{
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkForMentionMatch = useCallback(
|
|
||||||
(text: string) => {
|
|
||||||
const mentionMatch = getPossibleQueryMatch(text);
|
|
||||||
const slashMatch = checkForSlashTriggerMatch(text, editor);
|
|
||||||
return !slashMatch && mentionMatch ? mentionMatch : null;
|
|
||||||
},
|
|
||||||
[checkForSlashTriggerMatch, editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
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');
|
||||||
|
@ -239,14 +67,17 @@ export const MentionPlugin: React.FC<{
|
||||||
return $createMentionNode(textNode.getTextContent());
|
return $createMentionNode(textNode.getTextContent());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getMentionMatch = useCallback((text: string) => {
|
const getMentionMatch = (text: string) => {
|
||||||
const matchArr = REGEX.exec(text);
|
const matchArr = REGEX.exec(text);
|
||||||
|
|
||||||
if (matchArr === null) {
|
if (!matchArr) return null;
|
||||||
return null;
|
return matchArr;
|
||||||
}
|
};
|
||||||
|
|
||||||
dispatch(fetchComposeSuggestions(composeId, matchArr[0]));
|
const getEntityMatch = useCallback((text: string) => {
|
||||||
|
const matchArr = getMentionMatch(text);
|
||||||
|
|
||||||
|
if (!matchArr) return null;
|
||||||
|
|
||||||
const mentionLength = matchArr[3].length + 1;
|
const mentionLength = matchArr[3].length + 1;
|
||||||
const startOffset = matchArr.index + matchArr[1].length;
|
const startOffset = matchArr.index + matchArr[1].length;
|
||||||
|
@ -257,8 +88,21 @@ 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>(
|
||||||
getMentionMatch,
|
getEntityMatch,
|
||||||
MentionNode,
|
MentionNode,
|
||||||
createMentionNode,
|
createMentionNode,
|
||||||
);
|
);
|
||||||
|
@ -289,19 +133,12 @@ export const MentionPlugin: React.FC<{
|
||||||
}, [suggestions]);
|
}, [suggestions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TypeaheadMenuPlugin<MentionTypeaheadOption>
|
<TypeaheadMenuPlugin
|
||||||
onQueryChange={setQueryString}
|
|
||||||
onSelectOption={onSelectOption}
|
|
||||||
triggerFn={checkForMentionMatch}
|
triggerFn={checkForMentionMatch}
|
||||||
options={options}
|
menuRenderFn={(anchorElementRef) =>
|
||||||
menuRenderFn={(
|
anchorElementRef.current
|
||||||
anchorElementRef,
|
|
||||||
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
|
|
||||||
) =>
|
|
||||||
anchorElementRef.current && results.length
|
|
||||||
? ReactDOM.createPortal(
|
? ReactDOM.createPortal(
|
||||||
<div
|
<div
|
||||||
// style={setPortalPosition()}
|
|
||||||
className={clsx({
|
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,
|
'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(),
|
hidden: suggestionsHidden || suggestions.isEmpty(),
|
||||||
|
|
|
@ -7,32 +7,18 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
import { mergeRegister } from '@lexical/utils';
|
|
||||||
import {
|
import {
|
||||||
$getNodeByKey,
|
|
||||||
$getSelection,
|
$getSelection,
|
||||||
$isRangeSelection,
|
$isRangeSelection,
|
||||||
$isTextNode,
|
$isTextNode,
|
||||||
COMMAND_PRIORITY_LOW,
|
|
||||||
createCommand,
|
|
||||||
KEY_ARROW_DOWN_COMMAND,
|
|
||||||
KEY_ARROW_UP_COMMAND,
|
|
||||||
KEY_ENTER_COMMAND,
|
|
||||||
KEY_ESCAPE_COMMAND,
|
|
||||||
KEY_TAB_COMMAND,
|
|
||||||
LexicalCommand,
|
|
||||||
LexicalEditor,
|
LexicalEditor,
|
||||||
NodeKey,
|
|
||||||
RangeSelection,
|
RangeSelection,
|
||||||
TextNode,
|
|
||||||
} from 'lexical';
|
} from 'lexical';
|
||||||
import React, {
|
import React, {
|
||||||
MutableRefObject,
|
MutableRefObject,
|
||||||
ReactPortal,
|
ReactPortal,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
@ -40,7 +26,6 @@ import React, {
|
||||||
export type QueryMatch = {
|
export type QueryMatch = {
|
||||||
leadOffset: number
|
leadOffset: number
|
||||||
matchingString: string
|
matchingString: string
|
||||||
replaceableString: string
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Resolution = {
|
export type Resolution = {
|
||||||
|
@ -48,61 +33,10 @@ export type Resolution = {
|
||||||
getRect: () => DOMRect
|
getRect: () => DOMRect
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PUNCTUATION =
|
export type MenuRenderFn = (
|
||||||
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';
|
|
||||||
|
|
||||||
export class TypeaheadOption {
|
|
||||||
|
|
||||||
key: string;
|
|
||||||
ref?: MutableRefObject<HTMLElement | null>;
|
|
||||||
|
|
||||||
constructor(key: string) {
|
|
||||||
this.key = key;
|
|
||||||
this.ref = { current: null };
|
|
||||||
this.setRefElement = this.setRefElement.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
setRefElement(element: HTMLElement | null) {
|
|
||||||
this.ref = { current: element };
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MenuRenderFn<TOption extends TypeaheadOption> = (
|
|
||||||
anchorElementRef: MutableRefObject<HTMLElement | null>,
|
anchorElementRef: MutableRefObject<HTMLElement | null>,
|
||||||
itemProps: {
|
|
||||||
selectedIndex: number | null
|
|
||||||
selectOptionAndCleanUp: (option: TOption) => void
|
|
||||||
setHighlightedIndex: (index: number) => void
|
|
||||||
options: Array<TOption>
|
|
||||||
},
|
|
||||||
matchingString: string,
|
|
||||||
) => ReactPortal | JSX.Element | null;
|
) => ReactPortal | JSX.Element | null;
|
||||||
|
|
||||||
const scrollIntoViewIfNeeded = (target: HTMLElement) => {
|
|
||||||
const container = document.getElementById('typeahead-menu');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const typeaheadContainerNode = container.querySelector('.typeahead-popover');
|
|
||||||
if (!typeaheadContainerNode) return;
|
|
||||||
|
|
||||||
const typeaheadRect = typeaheadContainerNode.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (typeaheadRect.top + typeaheadRect.height > window.innerHeight) {
|
|
||||||
typeaheadContainerNode.scrollIntoView({
|
|
||||||
block: 'center',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeaheadRect.top < 0) {
|
|
||||||
typeaheadContainerNode.scrollIntoView({
|
|
||||||
block: 'center',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
target.scrollIntoView({ block: 'nearest' });
|
|
||||||
};
|
|
||||||
|
|
||||||
function tryToPositionRange(leadOffset: number, range: Range): boolean {
|
function tryToPositionRange(leadOffset: number, range: Range): boolean {
|
||||||
const domSelection = window.getSelection();
|
const domSelection = window.getSelection();
|
||||||
if (domSelection === null || !domSelection.isCollapsed) {
|
if (domSelection === null || !domSelection.isCollapsed) {
|
||||||
|
@ -135,63 +69,6 @@ function getQueryTextForSearch(editor: LexicalEditor): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Walk backwards along user input and forward through entity title to try
|
|
||||||
* and replace more of the user's text with entity.
|
|
||||||
*/
|
|
||||||
function getFullMatchOffset(
|
|
||||||
documentText: string,
|
|
||||||
entryText: string,
|
|
||||||
offset: number,
|
|
||||||
): number {
|
|
||||||
let triggerOffset = offset;
|
|
||||||
for (let i = triggerOffset; i <= entryText.length; i++) {
|
|
||||||
if (documentText.substr(-i) === entryText.substr(0, i)) {
|
|
||||||
triggerOffset = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return triggerOffset;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Split Lexical TextNode and return a new TextNode only containing matched text.
|
|
||||||
* Common use cases include: removing the node, replacing with a new node.
|
|
||||||
*/
|
|
||||||
function splitNodeContainingQuery(match: QueryMatch): TextNode | null {
|
|
||||||
const selection = $getSelection();
|
|
||||||
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const anchor = selection.anchor;
|
|
||||||
if (!['mention', 'text'].includes(anchor.type)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const anchorNode = anchor.getNode();
|
|
||||||
if (anchor.type === 'text' && !anchorNode.isSimpleText()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const selectionOffset = anchor.offset;
|
|
||||||
const textContent = anchorNode.getTextContent().slice(0, selectionOffset);
|
|
||||||
const characterOffset = match.replaceableString.length;
|
|
||||||
const queryOffset = getFullMatchOffset(
|
|
||||||
textContent,
|
|
||||||
match.matchingString,
|
|
||||||
characterOffset,
|
|
||||||
);
|
|
||||||
const startOffset = selectionOffset - queryOffset;
|
|
||||||
if (startOffset < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let newNode;
|
|
||||||
if (startOffset === 0) {
|
|
||||||
[newNode] = anchorNode.splitText(selectionOffset);
|
|
||||||
} else {
|
|
||||||
[, newNode] = anchorNode.splitText(startOffset, selectionOffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSelectionOnEntityBoundary(
|
function isSelectionOnEntityBoundary(
|
||||||
editor: LexicalEditor,
|
editor: LexicalEditor,
|
||||||
offset: number,
|
offset: number,
|
||||||
|
@ -314,265 +191,19 @@ export function useDynamicPositioning(
|
||||||
}, [targetElement, editor, onVisibilityChange, onReposition, resolution]);
|
}, [targetElement, editor, onVisibilityChange, onReposition, resolution]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{
|
function LexicalPopoverMenu({
|
||||||
index: number
|
|
||||||
option: TypeaheadOption
|
|
||||||
}> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND');
|
|
||||||
|
|
||||||
function LexicalPopoverMenu<TOption extends TypeaheadOption>({
|
|
||||||
close,
|
|
||||||
editor,
|
|
||||||
anchorElementRef,
|
anchorElementRef,
|
||||||
resolution,
|
|
||||||
options,
|
|
||||||
menuRenderFn,
|
menuRenderFn,
|
||||||
onSelectOption,
|
|
||||||
}: {
|
}: {
|
||||||
close: () => void
|
|
||||||
editor: LexicalEditor
|
|
||||||
anchorElementRef: MutableRefObject<HTMLElement>
|
anchorElementRef: MutableRefObject<HTMLElement>
|
||||||
resolution: Resolution
|
menuRenderFn: MenuRenderFn
|
||||||
options: Array<TOption>
|
|
||||||
menuRenderFn: MenuRenderFn<TOption>
|
|
||||||
onSelectOption: (
|
|
||||||
option: TOption,
|
|
||||||
textNodeContainingQuery: TextNode | null,
|
|
||||||
closeMenu: () => void,
|
|
||||||
matchingString: string,
|
|
||||||
) => void
|
|
||||||
}): JSX.Element | null {
|
}): JSX.Element | null {
|
||||||
const [selectedIndex, setHighlightedIndex] = useState<null | number>(null);
|
return menuRenderFn(anchorElementRef);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHighlightedIndex(0);
|
|
||||||
}, [resolution.match.matchingString]);
|
|
||||||
|
|
||||||
const selectOptionAndCleanUp = useCallback(
|
|
||||||
(selectedEntry: TOption) => {
|
|
||||||
editor.update(() => {
|
|
||||||
const textNodeContainingQuery = splitNodeContainingQuery(resolution.match);
|
|
||||||
|
|
||||||
onSelectOption(
|
|
||||||
selectedEntry,
|
|
||||||
textNodeContainingQuery,
|
|
||||||
close,
|
|
||||||
resolution.match.matchingString,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[close, editor, resolution.match, onSelectOption],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateSelectedIndex = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const rootElem = editor.getRootElement();
|
|
||||||
if (rootElem !== null) {
|
|
||||||
rootElem.setAttribute(
|
|
||||||
'aria-activedescendant',
|
|
||||||
'typeahead-item-' + index,
|
|
||||||
);
|
|
||||||
setHighlightedIndex(index);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
const rootElem = editor.getRootElement();
|
|
||||||
if (rootElem !== null) {
|
|
||||||
rootElem.removeAttribute('aria-activedescendant');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (options === null) {
|
|
||||||
setHighlightedIndex(null);
|
|
||||||
} else if (selectedIndex === null) {
|
|
||||||
updateSelectedIndex(0);
|
|
||||||
}
|
|
||||||
}, [options, selectedIndex, updateSelectedIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return mergeRegister(
|
|
||||||
editor.registerCommand(
|
|
||||||
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
|
|
||||||
({ option }) => {
|
|
||||||
if (option.ref && option.ref.current) {
|
|
||||||
scrollIntoViewIfNeeded(option.ref.current);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
COMMAND_PRIORITY_LOW,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, [editor, updateSelectedIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return mergeRegister(
|
|
||||||
editor.registerCommand<KeyboardEvent>(
|
|
||||||
KEY_ARROW_DOWN_COMMAND,
|
|
||||||
(payload) => {
|
|
||||||
const event = payload;
|
|
||||||
if (options !== null && options.length && selectedIndex !== null) {
|
|
||||||
const newSelectedIndex =
|
|
||||||
selectedIndex !== options.length - 1 ? selectedIndex + 1 : 0;
|
|
||||||
updateSelectedIndex(newSelectedIndex);
|
|
||||||
const option = options[newSelectedIndex];
|
|
||||||
if (option.ref && option.ref.current) {
|
|
||||||
editor.dispatchCommand(
|
|
||||||
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
|
|
||||||
{
|
|
||||||
index: newSelectedIndex,
|
|
||||||
option,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
COMMAND_PRIORITY_LOW,
|
|
||||||
),
|
|
||||||
editor.registerCommand<KeyboardEvent>(
|
|
||||||
KEY_ARROW_UP_COMMAND,
|
|
||||||
(payload) => {
|
|
||||||
const event = payload;
|
|
||||||
if (options !== null && options.length && selectedIndex !== null) {
|
|
||||||
const newSelectedIndex =
|
|
||||||
selectedIndex !== 0 ? selectedIndex - 1 : options.length - 1;
|
|
||||||
updateSelectedIndex(newSelectedIndex);
|
|
||||||
const option = options[newSelectedIndex];
|
|
||||||
if (option.ref && option.ref.current) {
|
|
||||||
scrollIntoViewIfNeeded(option.ref.current);
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
COMMAND_PRIORITY_LOW,
|
|
||||||
),
|
|
||||||
editor.registerCommand<KeyboardEvent>(
|
|
||||||
KEY_ESCAPE_COMMAND,
|
|
||||||
(payload) => {
|
|
||||||
const event = payload;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
close();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
COMMAND_PRIORITY_LOW,
|
|
||||||
),
|
|
||||||
editor.registerCommand<KeyboardEvent>(
|
|
||||||
KEY_TAB_COMMAND,
|
|
||||||
(payload) => {
|
|
||||||
const event = payload;
|
|
||||||
if (
|
|
||||||
options === null ||
|
|
||||||
selectedIndex === null ||
|
|
||||||
!options[selectedIndex]
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
selectOptionAndCleanUp(options[selectedIndex]);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
COMMAND_PRIORITY_LOW,
|
|
||||||
),
|
|
||||||
editor.registerCommand(
|
|
||||||
KEY_ENTER_COMMAND,
|
|
||||||
(event: KeyboardEvent | null) => {
|
|
||||||
if (
|
|
||||||
options === null ||
|
|
||||||
selectedIndex === null ||
|
|
||||||
!options[selectedIndex]
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (event !== null) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
}
|
|
||||||
selectOptionAndCleanUp(options[selectedIndex]);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
COMMAND_PRIORITY_LOW,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
selectOptionAndCleanUp,
|
|
||||||
close,
|
|
||||||
editor,
|
|
||||||
options,
|
|
||||||
selectedIndex,
|
|
||||||
updateSelectedIndex,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const listItemProps = useMemo(
|
|
||||||
() => ({
|
|
||||||
options,
|
|
||||||
selectOptionAndCleanUp,
|
|
||||||
selectedIndex,
|
|
||||||
setHighlightedIndex,
|
|
||||||
}),
|
|
||||||
[selectOptionAndCleanUp, selectedIndex, options],
|
|
||||||
);
|
|
||||||
|
|
||||||
return menuRenderFn(
|
|
||||||
anchorElementRef,
|
|
||||||
listItemProps,
|
|
||||||
resolution.match.matchingString,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBasicTypeaheadTriggerMatch(
|
|
||||||
trigger: string,
|
|
||||||
{ minLength = 1, maxLength = 75 }: {minLength?: number, maxLength?: number},
|
|
||||||
): TriggerFn {
|
|
||||||
return useCallback(
|
|
||||||
(text: string) => {
|
|
||||||
const validChars = '[^' + trigger + PUNCTUATION + '\\s]';
|
|
||||||
const TypeaheadTriggerRegex = new RegExp(
|
|
||||||
'(^|\\s|\\()(' +
|
|
||||||
'[' +
|
|
||||||
trigger +
|
|
||||||
']' +
|
|
||||||
'((?:' +
|
|
||||||
validChars +
|
|
||||||
'){0,' +
|
|
||||||
maxLength +
|
|
||||||
'})' +
|
|
||||||
')$',
|
|
||||||
);
|
|
||||||
const match = TypeaheadTriggerRegex.exec(text);
|
|
||||||
if (match !== null) {
|
|
||||||
const maybeLeadingWhitespace = match[1];
|
|
||||||
const matchingString = match[3];
|
|
||||||
if (matchingString.length >= minLength) {
|
|
||||||
return {
|
|
||||||
leadOffset: match.index + maybeLeadingWhitespace.length,
|
|
||||||
matchingString,
|
|
||||||
replaceableString: match[2],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[maxLength, minLength, trigger],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useMenuAnchorRef(
|
function useMenuAnchorRef(
|
||||||
resolution: Resolution | null,
|
resolution: Resolution | null,
|
||||||
setResolution: (r: Resolution | null) => void,
|
setResolution: (r: Resolution | null) => void,
|
||||||
className?: string,
|
|
||||||
): MutableRefObject<HTMLElement> {
|
): MutableRefObject<HTMLElement> {
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
const anchorElementRef = useRef<HTMLElement>(document.createElement('div'));
|
const anchorElementRef = useRef<HTMLElement>(document.createElement('div'));
|
||||||
|
@ -588,9 +219,6 @@ function useMenuAnchorRef(
|
||||||
containerDiv.style.width = `${width}px`;
|
containerDiv.style.width = `${width}px`;
|
||||||
|
|
||||||
if (!containerDiv.isConnected) {
|
if (!containerDiv.isConnected) {
|
||||||
if (className) {
|
|
||||||
containerDiv.className = className;
|
|
||||||
}
|
|
||||||
containerDiv.setAttribute('aria-label', 'Typeahead menu');
|
containerDiv.setAttribute('aria-label', 'Typeahead menu');
|
||||||
containerDiv.setAttribute('id', 'typeahead-menu');
|
containerDiv.setAttribute('id', 'typeahead-menu');
|
||||||
containerDiv.setAttribute('role', 'listbox');
|
containerDiv.setAttribute('role', 'listbox');
|
||||||
|
@ -601,7 +229,7 @@ function useMenuAnchorRef(
|
||||||
anchorElementRef.current = containerDiv;
|
anchorElementRef.current = containerDiv;
|
||||||
rootElement.setAttribute('aria-controls', 'typeahead-menu');
|
rootElement.setAttribute('aria-controls', 'typeahead-menu');
|
||||||
}
|
}
|
||||||
}, [editor, resolution, className]);
|
}, [editor, resolution]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rootElement = editor.getRootElement();
|
const rootElement = editor.getRootElement();
|
||||||
|
@ -641,20 +269,11 @@ function useMenuAnchorRef(
|
||||||
return anchorElementRef;
|
return anchorElementRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TypeaheadMenuPluginProps<TOption extends TypeaheadOption> = {
|
export type TypeaheadMenuPluginProps = {
|
||||||
onQueryChange: (matchingString: string | null) => void
|
menuRenderFn: MenuRenderFn
|
||||||
onSelectOption: (
|
|
||||||
option: TOption,
|
|
||||||
textNodeContainingQuery: TextNode | null,
|
|
||||||
closeMenu: () => void,
|
|
||||||
matchingString: string,
|
|
||||||
) => void
|
|
||||||
options: Array<TOption>
|
|
||||||
menuRenderFn: MenuRenderFn<TOption>
|
|
||||||
triggerFn: TriggerFn
|
triggerFn: TriggerFn
|
||||||
onOpen?: (resolution: Resolution) => void
|
onOpen?: (resolution: Resolution) => void
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
anchorClassName?: string
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TriggerFn = (
|
export type TriggerFn = (
|
||||||
|
@ -662,22 +281,17 @@ export type TriggerFn = (
|
||||||
editor: LexicalEditor,
|
editor: LexicalEditor,
|
||||||
) => QueryMatch | null;
|
) => QueryMatch | null;
|
||||||
|
|
||||||
export function TypeaheadMenuPlugin<TOption extends TypeaheadOption>({
|
export function TypeaheadMenuPlugin({
|
||||||
options,
|
|
||||||
onQueryChange,
|
|
||||||
onSelectOption,
|
|
||||||
onOpen,
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
menuRenderFn,
|
menuRenderFn,
|
||||||
triggerFn,
|
triggerFn,
|
||||||
anchorClassName,
|
}: TypeaheadMenuPluginProps): JSX.Element | null {
|
||||||
}: TypeaheadMenuPluginProps<TOption>): JSX.Element | null {
|
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
const [resolution, setResolution] = useState<Resolution | null>(null);
|
const [resolution, setResolution] = useState<Resolution | null>(null);
|
||||||
const anchorElementRef = useMenuAnchorRef(
|
const anchorElementRef = useMenuAnchorRef(
|
||||||
resolution,
|
resolution,
|
||||||
setResolution,
|
setResolution,
|
||||||
anchorClassName,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeTypeahead = useCallback(() => {
|
const closeTypeahead = useCallback(() => {
|
||||||
|
@ -701,21 +315,14 @@ export function TypeaheadMenuPlugin<TOption extends TypeaheadOption>({
|
||||||
const updateListener = () => {
|
const updateListener = () => {
|
||||||
editor.getEditorState().read(() => {
|
editor.getEditorState().read(() => {
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
const selection = $getSelection();
|
|
||||||
const text = getQueryTextForSearch(editor);
|
const text = getQueryTextForSearch(editor);
|
||||||
|
|
||||||
if (
|
if (!text) {
|
||||||
!$isRangeSelection(selection) ||
|
|
||||||
!selection.isCollapsed() ||
|
|
||||||
text === null ||
|
|
||||||
range === null
|
|
||||||
) {
|
|
||||||
closeTypeahead();
|
closeTypeahead();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = triggerFn(text, editor);
|
const match = triggerFn(text, editor);
|
||||||
onQueryChange(match ? match.matchingString : null);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
match !== null &&
|
match !== null &&
|
||||||
|
@ -744,7 +351,6 @@ export function TypeaheadMenuPlugin<TOption extends TypeaheadOption>({
|
||||||
}, [
|
}, [
|
||||||
editor,
|
editor,
|
||||||
triggerFn,
|
triggerFn,
|
||||||
onQueryChange,
|
|
||||||
resolution,
|
resolution,
|
||||||
closeTypeahead,
|
closeTypeahead,
|
||||||
openTypeahead,
|
openTypeahead,
|
||||||
|
@ -752,115 +358,8 @@ export function TypeaheadMenuPlugin<TOption extends TypeaheadOption>({
|
||||||
|
|
||||||
return resolution === null || editor === null ? null : (
|
return resolution === null || editor === null ? null : (
|
||||||
<LexicalPopoverMenu
|
<LexicalPopoverMenu
|
||||||
close={closeTypeahead}
|
|
||||||
resolution={resolution}
|
|
||||||
editor={editor}
|
|
||||||
anchorElementRef={anchorElementRef}
|
anchorElementRef={anchorElementRef}
|
||||||
options={options}
|
|
||||||
menuRenderFn={menuRenderFn}
|
menuRenderFn={menuRenderFn}
|
||||||
onSelectOption={onSelectOption}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type NodeMenuPluginProps<TOption extends TypeaheadOption> = {
|
|
||||||
onSelectOption: (
|
|
||||||
option: TOption,
|
|
||||||
textNodeContainingQuery: TextNode | null,
|
|
||||||
closeMenu: () => void,
|
|
||||||
matchingString: string,
|
|
||||||
) => void
|
|
||||||
options: Array<TOption>
|
|
||||||
nodeKey: NodeKey | null
|
|
||||||
onClose?: () => void
|
|
||||||
onOpen?: (resolution: Resolution) => void
|
|
||||||
menuRenderFn: MenuRenderFn<TOption>
|
|
||||||
anchorClassName?: string
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LexicalNodeMenuPlugin<TOption extends TypeaheadOption>({
|
|
||||||
options,
|
|
||||||
nodeKey,
|
|
||||||
onClose,
|
|
||||||
onOpen,
|
|
||||||
onSelectOption,
|
|
||||||
menuRenderFn,
|
|
||||||
anchorClassName,
|
|
||||||
}: NodeMenuPluginProps<TOption>): JSX.Element | null {
|
|
||||||
const [editor] = useLexicalComposerContext();
|
|
||||||
const [resolution, setResolution] = useState<Resolution | null>(null);
|
|
||||||
const anchorElementRef = useMenuAnchorRef(
|
|
||||||
resolution,
|
|
||||||
setResolution,
|
|
||||||
anchorClassName,
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeNodeMenu = useCallback(() => {
|
|
||||||
setResolution(null);
|
|
||||||
if (onClose && resolution !== null) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, [onClose, resolution]);
|
|
||||||
|
|
||||||
const openNodeMenu = useCallback(
|
|
||||||
(res: Resolution) => {
|
|
||||||
setResolution(res);
|
|
||||||
if (onOpen && resolution === null) {
|
|
||||||
onOpen(res);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onOpen, resolution],
|
|
||||||
);
|
|
||||||
|
|
||||||
const positionOrCloseMenu = useCallback(() => {
|
|
||||||
if (nodeKey) {
|
|
||||||
editor.update(() => {
|
|
||||||
const node = $getNodeByKey(nodeKey);
|
|
||||||
const domElement = editor.getElementByKey(nodeKey);
|
|
||||||
if (node && domElement) {
|
|
||||||
const text = node.getTextContent();
|
|
||||||
if (!resolution || resolution.match.matchingString !== text) {
|
|
||||||
startTransition(() =>
|
|
||||||
openNodeMenu({
|
|
||||||
getRect: () => domElement.getBoundingClientRect(),
|
|
||||||
match: {
|
|
||||||
leadOffset: text.length,
|
|
||||||
matchingString: text,
|
|
||||||
replaceableString: text,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (!nodeKey && resolution) {
|
|
||||||
closeNodeMenu();
|
|
||||||
}
|
|
||||||
}, [closeNodeMenu, editor, nodeKey, openNodeMenu, resolution]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
positionOrCloseMenu();
|
|
||||||
}, [positionOrCloseMenu, nodeKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (nodeKey) {
|
|
||||||
return editor.registerUpdateListener(({ dirtyElements }) => {
|
|
||||||
if (dirtyElements.get(nodeKey)) {
|
|
||||||
positionOrCloseMenu();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [editor, positionOrCloseMenu, nodeKey]);
|
|
||||||
|
|
||||||
return resolution === null || editor === null ? null : (
|
|
||||||
<LexicalPopoverMenu
|
|
||||||
close={closeNodeMenu}
|
|
||||||
resolution={resolution}
|
|
||||||
editor={editor}
|
|
||||||
anchorElementRef={anchorElementRef}
|
|
||||||
options={options}
|
|
||||||
menuRenderFn={menuRenderFn}
|
|
||||||
onSelectOption={onSelectOption}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue