Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2023-03-13 20:16:37 +01:00
parent d9f5784a14
commit 9ce079ce7e
2 changed files with 893 additions and 2 deletions

View file

@ -8,7 +8,6 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
LexicalTypeaheadMenuPlugin,
QueryMatch,
TypeaheadOption,
useBasicTypeaheadTriggerMatch,
@ -19,6 +18,8 @@ import ReactDOM from 'react-dom';
import { $createMentionNode, MentionNode } from '../nodes/mention-node';
import { TypeaheadMenuPlugin } from './typeahead-menu-plugin';
import type { TextNode } from 'lexical';
const REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i');
@ -318,7 +319,7 @@ export function MentionPlugin(): JSX.Element | null {
);
return (
<LexicalTypeaheadMenuPlugin<MentionTypeaheadOption>
<TypeaheadMenuPlugin<MentionTypeaheadOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForMentionMatch}

View file

@ -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}
/>
);
}