UNPKG

@selfcommunity/react-ui

Version:

React UI Components to integrate a Community created with SelfCommunity Platform.

543 lines (542 loc) • 23.8 kB
"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;