UNPKG

@selfcommunity/react-ui

Version:

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

535 lines (534 loc) • 22.8 kB
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); }