@selfcommunity/react-ui
Version:
React UI Components to integrate a Community created with SelfCommunity Platform.
535 lines (534 loc) • 22.8 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIsomorphicLayoutEffect } from '@selfcommunity/react-core';
import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, KEY_TAB_COMMAND } from 'lexical';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { mergeRegister } from '@lexical/utils';
import { createMentionNode, MentionNode } from '../nodes/MentionNode';
import { http, Endpoints } from '@selfcommunity/api-services';
import classNames from 'classnames';
import { Avatar, Portal } from '@mui/material';
import { styled } from '@mui/material/styles';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import { PREFIX } from '../constants';
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 = ['@', '\\uff20'].join('');
// Chars we expect to see in a mention (non-space, non-punctuation).
const VALID_CHARS = '[^' + TRIGGERS + PUNC + '\\s]';
// Non-standard series of chars. Each series must be preceded and followed by
// a valid char.
const VALID_JOINS = '(?:' +
'\\.[ |$]|' + // E.g. "r. " in "Mr. Smith"
' |' + // E.g. " " in "Josh Duck"
'[' +
PUNC +
']|' + // E.g. "-' in "Salier-Hellendag"
')';
const LENGTH_LIMIT = 75;
const AtSignMentionsRegex = new RegExp('(^|\\s|\\()(' + '[' + TRIGGERS + ']' + '((?:' + VALID_CHARS + VALID_JOINS + '){0,' + LENGTH_LIMIT + '})' + ')$');
// 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 + '})' + ')$');
// At most, 5 suggestions are shown in the popup.
const SUGGESTION_LIST_LENGTH_LIMIT = 5;
function isTriggerVisibleInNearestScrollContainer(targetElement, containerElement) {
const tRect = targetElement.getBoundingClientRect();
const cRect = containerElement.getBoundingClientRect();
return tRect.top > cRect.top && tRect.top < cRect.bottom;
}
export function getScrollParent(element, includeHidden) {
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 = 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;
}
export function useDynamicPositioning(resolution, targetElement, onReposition, onVisibilityChange) {
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, true);
};
}
}, [targetElement, editor, onVisibilityChange, onReposition, resolution]);
}
export function useMenuAnchorRef(resolution, setResolution, className) {
const [anchorElementRef, setAnchorElementRef] = useState(null);
const [editor] = useLexicalComposerContext();
//const anchorElementRef = useRef<HTMLElement>(null);
const positionMenu = useCallback(() => {
const rootElement = editor.getRootElement();
const containerDiv = anchorElementRef;
const menuEle = containerDiv.firstChild;
if (rootElement !== null && resolution !== null) {
const { left, top, width, height } = resolution.range.getBoundingClientRect();
containerDiv.style.top = `${top + window.scrollY}px`;
containerDiv.style.left = `${left + window.scrollX}px`;
containerDiv.style.height = `${height}px`;
containerDiv.style.width = `${width}px`;
if (menuEle !== null) {
const menuRect = menuEle.getBoundingClientRect();
const menuHeight = menuRect.height;
const menuWidth = menuRect.width;
const rootElementRect = rootElement.getBoundingClientRect();
if (left + menuWidth > rootElementRect.right) {
containerDiv.style.left = `${rootElementRect.right - menuWidth + window.scrollX}px`;
}
const margin = 10;
if ((top + menuHeight > window.innerHeight || top + menuHeight > rootElementRect.bottom) && top - rootElementRect.top > menuHeight) {
containerDiv.style.top = `${top - menuHeight + window.scrollY - (height + margin)}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);
}
setAnchorElementRef(containerDiv);
rootElement.setAttribute('aria-controls', 'typeahead-menu');
}
}, [anchorElementRef, editor, resolution, className]);
useEffect(() => {
if (resolution && !anchorElementRef) {
setAnchorElementRef(document.createElement('div'));
}
}, [resolution]);
useEffect(() => {
const rootElement = editor.getRootElement();
if (resolution !== null && anchorElementRef) {
positionMenu();
return () => {
if (rootElement !== null) {
rootElement.removeAttribute('aria-controls');
}
const containerDiv = anchorElementRef;
if (containerDiv !== null && containerDiv.isConnected) {
containerDiv.remove();
}
};
}
}, [anchorElementRef, editor, positionMenu, resolution]);
const onVisibilityChange = useCallback((isInView) => {
if (resolution !== null) {
if (!isInView) {
setResolution(null);
}
}
}, [resolution, setResolution]);
useDynamicPositioning(resolution, anchorElementRef, positionMenu, onVisibilityChange);
return anchorElementRef;
}
const mentionsCache = new Map();
function useMentionLookupService(mentionString) {
const [results, setResults] = useState(null);
useEffect(() => {
const cachedResults = mentionsCache.get(mentionString);
if (cachedResults === null) {
return;
}
else if (cachedResults !== undefined) {
setResults(cachedResults);
return;
}
mentionsCache.set(mentionString, null);
http
.request({
url: Endpoints.UserSearch.url(),
method: Endpoints.UserSearch.method,
params: { user: mentionString, limit: 5 }
})
.then((res) => {
mentionsCache.set(mentionString, res.data.results);
setResults(res.data.results);
});
}, [mentionString]);
return results;
}
function MentionsTypeaheadItem({ index, isHovered, isSelected, onClick, onMouseEnter, onMouseLeave, result }) {
const liRef = useRef(null);
return (_jsxs("li", Object.assign({ tabIndex: -1, className: classNames('item', { ['selected']: isSelected, ['hovered']: isHovered }), ref: liRef, role: "option", "aria-selected": isSelected, id: 'typeahead-item-' + index, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, onClick: onClick }, { children: [_jsx(Avatar, { alt: result.username, src: result.avatar }), " ", result.username] }), result.id));
}
function MentionsTypeahead({ close, editor, resolution, className = '' }) {
const divRef = useRef(null);
const match = resolution.match;
const results = useMentionLookupService(match.matchingString);
const [selectedIndex, setSelectedIndex] = useState(null);
const [hoveredIndex, setHoveredIndex] = useState(null);
const applyCurrentSelected = useCallback((index) => {
index = index || selectedIndex;
if (results === null || index === null) {
return;
}
const selectedEntry = results[index];
close();
createMentionNodeFromSearchResult(editor, selectedEntry, match);
}, [close, match, editor, results, selectedIndex]);
const updateSelectedIndex = useCallback((index) => {
const rootElem = editor.getRootElement();
if (rootElem !== null) {
rootElem.setAttribute('aria-activedescendant', 'typeahead-item-' + index);
setSelectedIndex(index);
}
}, [editor]);
useEffect(() => {
return () => {
const rootElem = editor.getRootElement();
if (rootElem !== null) {
rootElem.removeAttribute('aria-activedescendant');
}
};
}, [editor]);
useIsomorphicLayoutEffect(() => {
if (results === null) {
setSelectedIndex(null);
}
else if (selectedIndex === null) {
updateSelectedIndex(0);
}
}, [results, selectedIndex, updateSelectedIndex]);
useEffect(() => {
return mergeRegister(editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (payload) => {
const event = payload;
if (results !== null && selectedIndex !== null) {
if (selectedIndex < SUGGESTION_LIST_LENGTH_LIMIT - 1 && selectedIndex !== results.length - 1) {
updateSelectedIndex(selectedIndex + 1);
event.preventDefault();
event.stopImmediatePropagation();
}
}
return true;
}, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_ARROW_UP_COMMAND, (payload) => {
const event = payload;
if (results !== null && selectedIndex !== null) {
if (selectedIndex !== 0) {
updateSelectedIndex(selectedIndex - 1);
event.preventDefault();
event.stopImmediatePropagation();
}
}
return true;
}, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_ESCAPE_COMMAND, (payload) => {
const event = payload;
if (results === null || selectedIndex === null) {
return false;
}
event.preventDefault();
event.stopImmediatePropagation();
close();
return true;
}, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_TAB_COMMAND, (payload) => {
const event = payload;
if (results === null || selectedIndex === null) {
return false;
}
event.preventDefault();
event.stopImmediatePropagation();
applyCurrentSelected();
return true;
}, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
if (results === null || selectedIndex === null) {
return false;
}
if (event !== null) {
event.preventDefault();
event.stopImmediatePropagation();
}
applyCurrentSelected();
return true;
}, COMMAND_PRIORITY_LOW));
}, [applyCurrentSelected, close, editor, results, selectedIndex, updateSelectedIndex]);
if (results === null) {
return null;
}
return (_jsx("div", Object.assign({ className: className, "aria-label": "Suggested mentions", ref: divRef, role: "listbox" }, { children: _jsx("ul", { children: results.slice(0, SUGGESTION_LIST_LENGTH_LIMIT).map((result, i) => (_jsx(MentionsTypeaheadItem, { index: i, isHovered: i === hoveredIndex, isSelected: i === selectedIndex, onClick: () => {
applyCurrentSelected(i);
}, onMouseEnter: () => {
setHoveredIndex(i);
}, onMouseLeave: () => {
setHoveredIndex(null);
}, result: result }, result.id))) }) })));
}
function checkForCapitalizedNameMentions(text, minMatchLength) {
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;
}
function checkForAtSignMentions(text, minMatchLength) {
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;
}
function getPossibleMentionMatch(text) {
const match = checkForAtSignMentions(text, 1);
return match === null ? checkForCapitalizedNameMentions(text, 3) : match;
}
function getTextUpToAnchor(selection) {
const anchor = selection.anchor;
if (anchor.type !== 'text') {
return null;
}
const anchorNode = anchor.getNode();
// We should not be attempting to extract mentions out of nodes
// that are already being used for other core things. This is
// especially true for immutable nodes, which can't be mutated at all.
if (!anchorNode.isSimpleText()) {
return null;
}
const anchorOffset = anchor.offset;
return anchorNode.getTextContent().slice(0, anchorOffset);
}
function tryToPositionRange(match, range) {
const domSelection = window.getSelection();
if (domSelection === null || !domSelection.isCollapsed) {
return false;
}
const anchorNode = domSelection.anchorNode;
const startOffset = match.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 getMentionsTextToSearch(editor) {
let text = null;
editor.getEditorState().read(() => {
const selection = $getSelection();
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.
*
* E.g. User types "Hello Sarah Smit" and we match "Smit" to "Sarah Smith".
* Replacing just the match would give us "Hello Sarah Sarah Smith".
* Instead we find the string "Sarah Smit" and replace all of it.
*/
function getMentionOffset(documentText, entryText, offset) {
let triggerOffset = offset;
for (let ii = triggerOffset; ii <= entryText.length; ii++) {
if (documentText.substr(-ii) === entryText.substr(0, ii)) {
triggerOffset = ii;
}
}
return triggerOffset;
}
/**
* From a Typeahead Search Result, replace plain text from search offset and
* render a newly created MentionNode.
*/
function createMentionNodeFromSearchResult(editor, user, match) {
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return;
}
const anchor = selection.anchor;
if (anchor.type !== 'text') {
return;
}
const anchorNode = anchor.getNode();
// We should not be attempting to extract mentions out of nodes
// that are already being used for other core things. This is
// especially true for immutable nodes, which can't be mutated at all.
if (!anchorNode.isSimpleText()) {
return;
}
const selectionOffset = anchor.offset;
const textContent = anchorNode.getTextContent().slice(0, selectionOffset);
const characterOffset = match.replaceableString.length;
// Given a known offset for the mention match, look backward in the
// text to see if there's a longer match to replace.
const mentionOffset = getMentionOffset(textContent, user.username, characterOffset);
const startOffset = selectionOffset - mentionOffset;
if (startOffset < 0) {
return;
}
let nodeToReplace;
if (startOffset === 0) {
[nodeToReplace] = anchorNode.splitText(selectionOffset);
}
else {
[, nodeToReplace] = anchorNode.splitText(startOffset, selectionOffset);
}
const mentionNode = createMentionNode(user);
nodeToReplace.replace(mentionNode);
mentionNode.select();
});
}
function isSelectionOnEntityBoundary(editor, offset) {
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();
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
return $isTextNode(prevSibling) && prevSibling.isTextEntity();
}
return false;
});
}
const classes = {
root: `${PREFIX}-mention-plugin-root`
};
const Root = styled(MentionsTypeahead, {
name: PREFIX,
slot: 'MentionPluginRoot'
})(() => ({}));
function useMentions(editor, anchorClassName = null) {
const [resolution, setResolution] = useState(null);
const anchorElementRef = useMenuAnchorRef(resolution, setResolution, anchorClassName);
useEffect(() => {
if (!editor.hasNodes([MentionNode])) {
throw new Error('MentionsPlugin: MentionNode not registered on editor');
}
}, [editor]);
useEffect(() => {
let activeRange = document.createRange();
let previousText = null;
const updateListener = ({ prevEditorState }) => {
if (prevEditorState.isEmpty()) {
return;
}
const range = activeRange;
const text = getMentionsTextToSearch(editor);
if (text === previousText || range === null) {
return;
}
previousText = text;
if (text === null) {
return;
}
const match = getPossibleMentionMatch(text);
if (match !== null && !isSelectionOnEntityBoundary(editor, match.leadOffset)) {
const isRangePositioned = tryToPositionRange(match, range);
if (isRangePositioned !== null) {
setResolution({
match,
range
});
return;
}
}
setResolution(null);
};
const removeUpdateListener = editor.registerUpdateListener(updateListener);
return () => {
activeRange = null;
removeUpdateListener();
};
}, [editor]);
const closeTypeahead = useCallback(() => {
setResolution(null);
}, [resolution]);
if (resolution === null || editor === null) {
return null;
}
if (!anchorElementRef) {
return null;
}
return (_jsx(ClickAwayListener, Object.assign({ onClickAway: closeTypeahead }, { children: _jsx(Portal, Object.assign({ container: anchorElementRef }, { children: _jsx(Root, { close: closeTypeahead, resolution: resolution, editor: editor, className: classes.root }) })) })));
}
export default function MentionsPlugin() {
const [editor] = useLexicalComposerContext();
return useMentions(editor);
}