"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;
