parent
d9f5784a14
commit
9ce079ce7e
|
@ -8,7 +8,6 @@
|
||||||
|
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
import {
|
import {
|
||||||
LexicalTypeaheadMenuPlugin,
|
|
||||||
QueryMatch,
|
QueryMatch,
|
||||||
TypeaheadOption,
|
TypeaheadOption,
|
||||||
useBasicTypeaheadTriggerMatch,
|
useBasicTypeaheadTriggerMatch,
|
||||||
|
@ -19,6 +18,8 @@ import ReactDOM from 'react-dom';
|
||||||
|
|
||||||
import { $createMentionNode, MentionNode } from '../nodes/mention-node';
|
import { $createMentionNode, MentionNode } from '../nodes/mention-node';
|
||||||
|
|
||||||
|
import { TypeaheadMenuPlugin } from './typeahead-menu-plugin';
|
||||||
|
|
||||||
import type { 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');
|
||||||
|
@ -318,7 +319,7 @@ export function MentionPlugin(): JSX.Element | null {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LexicalTypeaheadMenuPlugin<MentionTypeaheadOption>
|
<TypeaheadMenuPlugin<MentionTypeaheadOption>
|
||||||
onQueryChange={setQueryString}
|
onQueryChange={setQueryString}
|
||||||
onSelectOption={onSelectOption}
|
onSelectOption={onSelectOption}
|
||||||
triggerFn={checkForMentionMatch}
|
triggerFn={checkForMentionMatch}
|
||||||
|
|
|
@ -0,0 +1,890 @@
|
||||||
|
/**
|
||||||
|
* 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 { mergeRegister } from '@lexical/utils';
|
||||||
|
import {
|
||||||
|
$getNodeByKey,
|
||||||
|
$getSelection,
|
||||||
|
$isRangeSelection,
|
||||||
|
$isTextNode,
|
||||||
|
COMMAND_PRIORITY_LOW,
|
||||||
|
createCommand,
|
||||||
|
KEY_ARROW_DOWN_COMMAND,
|
||||||
|
KEY_ARROW_UP_COMMAND,
|
||||||
|
KEY_ENTER_COMMAND,
|
||||||
|
KEY_ESCAPE_COMMAND,
|
||||||
|
KEY_TAB_COMMAND,
|
||||||
|
LexicalCommand,
|
||||||
|
LexicalEditor,
|
||||||
|
NodeKey,
|
||||||
|
RangeSelection,
|
||||||
|
TextNode,
|
||||||
|
} from 'lexical';
|
||||||
|
import React, {
|
||||||
|
MutableRefObject,
|
||||||
|
ReactPortal,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
export type QueryMatch = {
|
||||||
|
leadOffset: number
|
||||||
|
matchingString: string
|
||||||
|
replaceableString: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Resolution = {
|
||||||
|
match: QueryMatch
|
||||||
|
getRect: () => DOMRect
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PUNCTUATION =
|
||||||
|
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';
|
||||||
|
|
||||||
|
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>,
|
||||||
|
itemProps: {
|
||||||
|
selectedIndex: number | null
|
||||||
|
selectOptionAndCleanUp: (option: TOption) => void
|
||||||
|
setHighlightedIndex: (index: number) => void
|
||||||
|
options: Array<TOption>
|
||||||
|
},
|
||||||
|
matchingString: string,
|
||||||
|
) => 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 getTextUpToAnchor(selection: RangeSelection): string | 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 anchorOffset = anchor.offset;
|
||||||
|
return anchorNode.getTextContent().slice(0, anchorOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryToPositionRange(leadOffset: number, range: Range): boolean {
|
||||||
|
const domSelection = window.getSelection();
|
||||||
|
if (domSelection === null || !domSelection.isCollapsed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const anchorNode = domSelection.anchorNode;
|
||||||
|
const startOffset = leadOffset;
|
||||||
|
const endOffset = domSelection.anchorOffset;
|
||||||
|
|
||||||
|
if (anchorNode == null || endOffset == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
range.setStart(anchorNode, startOffset);
|
||||||
|
range.setEnd(anchorNode, endOffset);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueryTextForSearch(editor: LexicalEditor): string | null {
|
||||||
|
let text = null;
|
||||||
|
editor.getEditorState().read(() => {
|
||||||
|
console.log(editor.getEditorState().toJSON());
|
||||||
|
const selection = $getSelection();
|
||||||
|
console.log(selection);
|
||||||
|
if (!$isRangeSelection(selection)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
text = getTextUpToAnchor(selection);
|
||||||
|
});
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
editor: LexicalEditor,
|
||||||
|
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(
|
||||||
|
editor: LexicalEditor,
|
||||||
|
offset: number,
|
||||||
|
): boolean {
|
||||||
|
if (offset !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return editor.getEditorState().read(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
const anchor = selection.anchor;
|
||||||
|
const anchorNode = anchor.getNode();
|
||||||
|
const prevSibling = anchorNode.getPreviousSibling();
|
||||||
|
return $isTextNode(prevSibling) && prevSibling.isTextEntity();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTransition(callback: () => void) {
|
||||||
|
if (React.startTransition) {
|
||||||
|
React.startTransition(callback);
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Got from https://stackoverflow.com/a/42543908/2013580
|
||||||
|
export function getScrollParent(
|
||||||
|
element: HTMLElement,
|
||||||
|
includeHidden: boolean,
|
||||||
|
): HTMLElement | HTMLBodyElement {
|
||||||
|
let style = getComputedStyle(element);
|
||||||
|
const excludeStaticParent = style.position === 'absolute';
|
||||||
|
const overflowRegex = includeHidden
|
||||||
|
? /(auto|scroll|hidden)/
|
||||||
|
: /(auto|scroll)/;
|
||||||
|
if (style.position === 'fixed') {
|
||||||
|
return document.body;
|
||||||
|
}
|
||||||
|
for (
|
||||||
|
let parent: HTMLElement | null = element;
|
||||||
|
(parent = parent.parentElement);
|
||||||
|
|
||||||
|
) {
|
||||||
|
style = getComputedStyle(parent);
|
||||||
|
if (excludeStaticParent && style.position === 'static') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
overflowRegex.test(style.overflow + style.overflowY + style.overflowX)
|
||||||
|
) {
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return document.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTriggerVisibleInNearestScrollContainer(
|
||||||
|
targetElement: HTMLElement,
|
||||||
|
containerElement: HTMLElement,
|
||||||
|
): boolean {
|
||||||
|
const tRect = targetElement.getBoundingClientRect();
|
||||||
|
const cRect = containerElement.getBoundingClientRect();
|
||||||
|
return tRect.top > cRect.top && tRect.top < cRect.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reposition the menu on scroll, window resize, and element resize.
|
||||||
|
export function useDynamicPositioning(
|
||||||
|
resolution: Resolution | null,
|
||||||
|
targetElement: HTMLElement | null,
|
||||||
|
onReposition: () => void,
|
||||||
|
onVisibilityChange?: (isInView: boolean) => void,
|
||||||
|
) {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetElement != null && resolution != null) {
|
||||||
|
const rootElement = editor.getRootElement();
|
||||||
|
const rootScrollParent =
|
||||||
|
rootElement != null
|
||||||
|
? getScrollParent(rootElement, false)
|
||||||
|
: document.body;
|
||||||
|
let ticking = false;
|
||||||
|
let previousIsInView = isTriggerVisibleInNearestScrollContainer(
|
||||||
|
targetElement,
|
||||||
|
rootScrollParent,
|
||||||
|
);
|
||||||
|
const handleScroll = function () {
|
||||||
|
if (!ticking) {
|
||||||
|
window.requestAnimationFrame(function () {
|
||||||
|
onReposition();
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
ticking = true;
|
||||||
|
}
|
||||||
|
const isInView = isTriggerVisibleInNearestScrollContainer(
|
||||||
|
targetElement,
|
||||||
|
rootScrollParent,
|
||||||
|
);
|
||||||
|
if (isInView !== previousIsInView) {
|
||||||
|
previousIsInView = isInView;
|
||||||
|
if (onVisibilityChange != null) {
|
||||||
|
onVisibilityChange(isInView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const resizeObserver = new ResizeObserver(onReposition);
|
||||||
|
window.addEventListener('resize', onReposition);
|
||||||
|
document.addEventListener('scroll', handleScroll, {
|
||||||
|
capture: true,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
resizeObserver.observe(targetElement);
|
||||||
|
return () => {
|
||||||
|
resizeObserver.unobserve(targetElement);
|
||||||
|
window.removeEventListener('resize', onReposition);
|
||||||
|
document.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [targetElement, editor, onVisibilityChange, onReposition, resolution]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{
|
||||||
|
index: number
|
||||||
|
option: TypeaheadOption
|
||||||
|
}> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND');
|
||||||
|
|
||||||
|
function LexicalPopoverMenu<TOption extends TypeaheadOption>({
|
||||||
|
close,
|
||||||
|
editor,
|
||||||
|
anchorElementRef,
|
||||||
|
resolution,
|
||||||
|
options,
|
||||||
|
menuRenderFn,
|
||||||
|
onSelectOption,
|
||||||
|
}: {
|
||||||
|
close: () => void
|
||||||
|
editor: LexicalEditor
|
||||||
|
anchorElementRef: MutableRefObject<HTMLElement>
|
||||||
|
resolution: Resolution
|
||||||
|
options: Array<TOption>
|
||||||
|
menuRenderFn: MenuRenderFn<TOption>
|
||||||
|
onSelectOption: (
|
||||||
|
option: TOption,
|
||||||
|
textNodeContainingQuery: TextNode | null,
|
||||||
|
closeMenu: () => void,
|
||||||
|
matchingString: string,
|
||||||
|
) => void
|
||||||
|
}): JSX.Element | null {
|
||||||
|
const [selectedIndex, setHighlightedIndex] = useState<null | number>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
}, [resolution.match.matchingString]);
|
||||||
|
|
||||||
|
const selectOptionAndCleanUp = useCallback(
|
||||||
|
(selectedEntry: TOption) => {
|
||||||
|
editor.update(() => {
|
||||||
|
const textNodeContainingQuery = splitNodeContainingQuery(
|
||||||
|
editor,
|
||||||
|
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 != null) {
|
||||||
|
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 != null && 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 != null && 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] == null
|
||||||
|
) {
|
||||||
|
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] == null
|
||||||
|
) {
|
||||||
|
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(
|
||||||
|
resolution: Resolution | null,
|
||||||
|
setResolution: (r: Resolution | null) => void,
|
||||||
|
className?: string,
|
||||||
|
): MutableRefObject<HTMLElement> {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const anchorElementRef = useRef<HTMLElement>(document.createElement('div'));
|
||||||
|
const positionMenu = useCallback(() => {
|
||||||
|
const rootElement = editor.getRootElement();
|
||||||
|
const containerDiv = anchorElementRef.current;
|
||||||
|
|
||||||
|
if (rootElement !== null && resolution !== null) {
|
||||||
|
const { left, top, width, height } = resolution.getRect();
|
||||||
|
containerDiv.style.top = `${top + window.pageYOffset}px`;
|
||||||
|
containerDiv.style.left = `${left + window.pageXOffset}px`;
|
||||||
|
containerDiv.style.height = `${height}px`;
|
||||||
|
containerDiv.style.width = `${width}px`;
|
||||||
|
|
||||||
|
if (!containerDiv.isConnected) {
|
||||||
|
if (className != null) {
|
||||||
|
containerDiv.className = className;
|
||||||
|
}
|
||||||
|
containerDiv.setAttribute('aria-label', 'Typeahead menu');
|
||||||
|
containerDiv.setAttribute('id', 'typeahead-menu');
|
||||||
|
containerDiv.setAttribute('role', 'listbox');
|
||||||
|
containerDiv.style.display = 'block';
|
||||||
|
containerDiv.style.position = 'absolute';
|
||||||
|
document.body.append(containerDiv);
|
||||||
|
}
|
||||||
|
anchorElementRef.current = containerDiv;
|
||||||
|
rootElement.setAttribute('aria-controls', 'typeahead-menu');
|
||||||
|
}
|
||||||
|
}, [editor, resolution, className]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const rootElement = editor.getRootElement();
|
||||||
|
if (resolution !== null) {
|
||||||
|
positionMenu();
|
||||||
|
return () => {
|
||||||
|
if (rootElement !== null) {
|
||||||
|
rootElement.removeAttribute('aria-controls');
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerDiv = anchorElementRef.current;
|
||||||
|
if (containerDiv !== null && containerDiv.isConnected) {
|
||||||
|
containerDiv.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [editor, positionMenu, resolution]);
|
||||||
|
|
||||||
|
const onVisibilityChange = useCallback(
|
||||||
|
(isInView: boolean) => {
|
||||||
|
if (resolution !== null) {
|
||||||
|
if (!isInView) {
|
||||||
|
setResolution(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[resolution, setResolution],
|
||||||
|
);
|
||||||
|
|
||||||
|
useDynamicPositioning(
|
||||||
|
resolution,
|
||||||
|
anchorElementRef.current,
|
||||||
|
positionMenu,
|
||||||
|
onVisibilityChange,
|
||||||
|
);
|
||||||
|
|
||||||
|
return anchorElementRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TypeaheadMenuPluginProps<TOption extends TypeaheadOption> = {
|
||||||
|
onQueryChange: (matchingString: string | null) => void
|
||||||
|
onSelectOption: (
|
||||||
|
option: TOption,
|
||||||
|
textNodeContainingQuery: TextNode | null,
|
||||||
|
closeMenu: () => void,
|
||||||
|
matchingString: string,
|
||||||
|
) => void
|
||||||
|
options: Array<TOption>
|
||||||
|
menuRenderFn: MenuRenderFn<TOption>
|
||||||
|
triggerFn: TriggerFn
|
||||||
|
onOpen?: (resolution: Resolution) => void
|
||||||
|
onClose?: () => void
|
||||||
|
anchorClassName?: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TriggerFn = (
|
||||||
|
text: string,
|
||||||
|
editor: LexicalEditor,
|
||||||
|
) => QueryMatch | null;
|
||||||
|
|
||||||
|
export function TypeaheadMenuPlugin<TOption extends TypeaheadOption>({
|
||||||
|
options,
|
||||||
|
onQueryChange,
|
||||||
|
onSelectOption,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
menuRenderFn,
|
||||||
|
triggerFn,
|
||||||
|
anchorClassName,
|
||||||
|
}: TypeaheadMenuPluginProps<TOption>): JSX.Element | null {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const [resolution, setResolution] = useState<Resolution | null>(null);
|
||||||
|
const anchorElementRef = useMenuAnchorRef(
|
||||||
|
resolution,
|
||||||
|
setResolution,
|
||||||
|
anchorClassName,
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeTypeahead = useCallback(() => {
|
||||||
|
setResolution(null);
|
||||||
|
if (onClose != null && resolution !== null) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onClose, resolution]);
|
||||||
|
|
||||||
|
const openTypeahead = useCallback(
|
||||||
|
(res: Resolution) => {
|
||||||
|
setResolution(res);
|
||||||
|
if (onOpen != null && resolution === null) {
|
||||||
|
onOpen(res);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onOpen, resolution],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateListener = () => {
|
||||||
|
editor.getEditorState().read(() => {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = $getSelection();
|
||||||
|
const text = getQueryTextForSearch(editor);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!$isRangeSelection(selection) ||
|
||||||
|
!selection.isCollapsed() ||
|
||||||
|
text === null ||
|
||||||
|
range === null
|
||||||
|
) {
|
||||||
|
closeTypeahead();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = triggerFn(text, editor);
|
||||||
|
onQueryChange(match ? match.matchingString : null);
|
||||||
|
|
||||||
|
if (
|
||||||
|
match !== null &&
|
||||||
|
!isSelectionOnEntityBoundary(editor, match.leadOffset)
|
||||||
|
) {
|
||||||
|
const isRangePositioned = tryToPositionRange(match.leadOffset, range);
|
||||||
|
if (isRangePositioned !== null) {
|
||||||
|
startTransition(() =>
|
||||||
|
openTypeahead({
|
||||||
|
getRect: () => range.getBoundingClientRect(),
|
||||||
|
match,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeTypeahead();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeUpdateListener = editor.registerUpdateListener(updateListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeUpdateListener();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
editor,
|
||||||
|
triggerFn,
|
||||||
|
onQueryChange,
|
||||||
|
resolution,
|
||||||
|
closeTypeahead,
|
||||||
|
openTypeahead,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return resolution === null || editor === null ? null : (
|
||||||
|
<LexicalPopoverMenu
|
||||||
|
close={closeTypeahead}
|
||||||
|
resolution={resolution}
|
||||||
|
editor={editor}
|
||||||
|
anchorElementRef={anchorElementRef}
|
||||||
|
options={options}
|
||||||
|
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 != null && resolution !== null) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onClose, resolution]);
|
||||||
|
|
||||||
|
const openNodeMenu = useCallback(
|
||||||
|
(res: Resolution) => {
|
||||||
|
setResolution(res);
|
||||||
|
if (onOpen != null && resolution === null) {
|
||||||
|
onOpen(res);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onOpen, resolution],
|
||||||
|
);
|
||||||
|
|
||||||
|
const positionOrCloseMenu = useCallback(() => {
|
||||||
|
if (nodeKey) {
|
||||||
|
editor.update(() => {
|
||||||
|
const node = $getNodeByKey(nodeKey);
|
||||||
|
const domElement = editor.getElementByKey(nodeKey);
|
||||||
|
if (node != null && domElement != null) {
|
||||||
|
const text = node.getTextContent();
|
||||||
|
if (resolution == null || resolution.match.matchingString !== text) {
|
||||||
|
startTransition(() =>
|
||||||
|
openNodeMenu({
|
||||||
|
getRect: () => domElement.getBoundingClientRect(),
|
||||||
|
match: {
|
||||||
|
leadOffset: text.length,
|
||||||
|
matchingString: text,
|
||||||
|
replaceableString: text,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (nodeKey == null && resolution != null) {
|
||||||
|
closeNodeMenu();
|
||||||
|
}
|
||||||
|
}, [closeNodeMenu, editor, nodeKey, openNodeMenu, resolution]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
positionOrCloseMenu();
|
||||||
|
}, [positionOrCloseMenu, nodeKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (nodeKey != null) {
|
||||||
|
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 New Issue