@selfcommunity/react-ui
Version:
React UI Components to integrate a Community created with SelfCommunity Platform.
543 lines (542 loc) • 23.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.useMenuAnchorRef = exports.useDynamicPositioning = exports.getScrollParent = void 0;
const tslib_1 = require("tslib");
const jsx_runtime_1 = require("react/jsx-runtime");
const react_1 = require("react");
const react_core_1 = require("@selfcommunity/react-core");
const lexical_1 = require("lexical");
const LexicalComposerContext_1 = require("@lexical/react/LexicalComposerContext");
const utils_1 = require("@lexical/utils");
const MentionNode_1 = require("../nodes/MentionNode");
const api_services_1 = require("@selfcommunity/api-services");
const classnames_1 = tslib_1.__importDefault(require("classnames"));
const material_1 = require("@mui/material");
const styles_1 = require("@mui/material/styles");
const ClickAwayListener_1 = tslib_1.__importDefault(require("@mui/material/ClickAwayListener"));
const constants_1 = require("../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;
}
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;
}
exports.getScrollParent = getScrollParent;
function useDynamicPositioning(resolution, targetElement, onReposition, onVisibilityChange) {
const [editor] = (0, LexicalComposerContext_1.useLexicalComposerContext)();
(0, react_1.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]);
}
exports.useDynamicPositioning = useDynamicPositioning;
function useMenuAnchorRef(resolution, setResolution, className) {
const [anchorElementRef, setAnchorElementRef] = (0, react_1.useState)(null);
const [editor] = (0, LexicalComposerContext_1.useLexicalComposerContext)();
//const anchorElementRef = useRef<HTMLElement>(null);
const positionMenu = (0, react_1.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]);
(0, react_1.useEffect)(() => {
if (resolution && !anchorElementRef) {
setAnchorElementRef(document.createElement('div'));
}
}, [resolution]);
(0, react_1.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 = (0, react_1.useCallback)((isInView) => {
if (resolution !== null) {
if (!isInView) {
setResolution(null);
}
}
}, [resolution, setResolution]);
useDynamicPositioning(resolution, anchorElementRef, positionMenu, onVisibilityChange);
return anchorElementRef;
}
exports.useMenuAnchorRef = useMenuAnchorRef;
const mentionsCache = new Map();
function useMentionLookupService(mentionString) {
const [results, setResults] = (0, react_1.useState)(null);
(0, react_1.useEffect)(() => {
const cachedResults = mentionsCache.get(mentionString);
if (cachedResults === null) {
return;
}
else if (cachedResults !== undefined) {
setResults(cachedResults);
return;
}
mentionsCache.set(mentionString, null);
api_services_1.http
.request({
url: api_services_1.Endpoints.UserSearch.url(),
method: api_services_1.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 = (0, react_1.useRef)(null);
return ((0, jsx_runtime_1.jsxs)("li", Object.assign({ tabIndex: -1, className: (0, classnames_1.default)('item', { ['selected']: isSelected, ['hovered']: isHovered }), ref: liRef, role: "option", "aria-selected": isSelected, id: 'typeahead-item-' + index, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, onClick: onClick }, { children: [(0, jsx_runtime_1.jsx)(material_1.Avatar, { alt: result.username, src: result.avatar }), " ", result.username] }), result.id));
}
function MentionsTypeahead({ close, editor, resolution, className = '' }) {
const divRef = (0, react_1.useRef)(null);
const match = resolution.match;
const results = useMentionLookupService(match.matchingString);
const [selectedIndex, setSelectedIndex] = (0, react_1.useState)(null);
const [hoveredIndex, setHoveredIndex] = (0, react_1.useState)(null);
const applyCurrentSelected = (0, react_1.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 = (0, react_1.useCallback)((index) => {
const rootElem = editor.getRootElement();
if (rootElem !== null) {
rootElem.setAttribute('aria-activedescendant', 'typeahead-item-' + index);
setSelectedIndex(index);
}
}, [editor]);
(0, react_1.useEffect)(() => {
return () => {
const rootElem = editor.getRootElement();
if (rootElem !== null) {
rootElem.removeAttribute('aria-activedescendant');
}
};
}, [editor]);
(0, react_core_1.useIsomorphicLayoutEffect)(() => {
if (results === null) {
setSelectedIndex(null);
}
else if (selectedIndex === null) {
updateSelectedIndex(0);
}
}, [results, selectedIndex, updateSelectedIndex]);
(0, react_1.useEffect)(() => {
return (0, utils_1.mergeRegister)(editor.registerCommand(lexical_1.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;
}, lexical_1.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical_1.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;
}, lexical_1.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical_1.KEY_ESCAPE_COMMAND, (payload) => {
const event = payload;
if (results === null || selectedIndex === null) {
return false;
}
event.preventDefault();
event.stopImmediatePropagation();
close();
return true;
}, lexical_1.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical_1.KEY_TAB_COMMAND, (payload) => {
const event = payload;
if (results === null || selectedIndex === null) {
return false;
}
event.preventDefault();
event.stopImmediatePropagation();
applyCurrentSelected();
return true;
}, lexical_1.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical_1.KEY_ENTER_COMMAND, (event) => {
if (results === null || selectedIndex === null) {
return false;
}
if (event !== null) {
event.preventDefault();
event.stopImmediatePropagation();
}
applyCurrentSelected();
return true;
}, lexical_1.COMMAND_PRIORITY_LOW));
}, [applyCurrentSelected, close, editor, results, selectedIndex, updateSelectedIndex]);
if (results === null) {
return null;
}
return ((0, jsx_runtime_1.jsx)("div", Object.assign({ className: className, "aria-label": "Suggested mentions", ref: divRef, role: "listbox" }, { children: (0, jsx_runtime_1.jsx)("ul", { children: results.slice(0, SUGGESTION_LIST_LENGTH_LIMIT).map((result, i) => ((0, jsx_runtime_1.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 = (0, lexical_1.$getSelection)();
if (!(0, lexical_1.$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 = (0, lexical_1.$getSelection)();
if (!(0, lexical_1.$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 = (0, MentionNode_1.createMentionNode)(user);
nodeToReplace.replace(mentionNode);
mentionNode.select();
});
}
function isSelectionOnEntityBoundary(editor, offset) {
if (offset !== 0) {
return false;
}
return editor.getEditorState().read(() => {
const selection = (0, lexical_1.$getSelection)();
if ((0, lexical_1.$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 (0, lexical_1.$isTextNode)(prevSibling) && prevSibling.isTextEntity();
}
return false;
});
}
const classes = {
root: `${constants_1.PREFIX}-mention-plugin-root`
};
const Root = (0, styles_1.styled)(MentionsTypeahead, {
name: constants_1.PREFIX,
slot: 'MentionPluginRoot'
})(() => ({}));
function useMentions(editor, anchorClassName = null) {
const [resolution, setResolution] = (0, react_1.useState)(null);
const anchorElementRef = useMenuAnchorRef(resolution, setResolution, anchorClassName);
(0, react_1.useEffect)(() => {
if (!editor.hasNodes([MentionNode_1.MentionNode])) {
throw new Error('MentionsPlugin: MentionNode not registered on editor');
}
}, [editor]);
(0, react_1.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 = (0, react_1.useCallback)(() => {
setResolution(null);
}, [resolution]);
if (resolution === null || editor === null) {
return null;
}
if (!anchorElementRef) {
return null;
}
return ((0, jsx_runtime_1.jsx)(ClickAwayListener_1.default, Object.assign({ onClickAway: closeTypeahead }, { children: (0, jsx_runtime_1.jsx)(material_1.Portal, Object.assign({ container: anchorElementRef }, { children: (0, jsx_runtime_1.jsx)(Root, { close: closeTypeahead, resolution: resolution, editor: editor, className: classes.root }) })) })));
}
function MentionsPlugin() {
const [editor] = (0, LexicalComposerContext_1.useLexicalComposerContext)();
return useMentions(editor);
}
exports.default = MentionsPlugin;