env-autosuggest
Version:
A React component that provides variable autosuggestion functionality with a rich text editor-like interface. This component allows users to type variables within curly braces and get intelligent suggestions based on predefined variables.
485 lines (451 loc) • 23 kB
JSX
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { filterSuggestions, getTextAfterLastOpenCurlyBrace, isEncodedWithCurlyBraces, removeAllPreceedingCurlyBracesFromTextNode, removeOuterCurlyBraces, saveCaretPosition, restoreCaretPosition, setDynamicVariables } from '../../utility/commonUtility.js';
import { convertTextToHTML, createNewTextNode, createNewVariableNode, getTextBeforeAndTextAfterNode } from '../../utility/createNewNode.js';
import { getCaretPosition } from '../../utility/getCaretPosition.js';
import SuggestionBox from '../suggestionBox/suggestionBox.jsx';
import Tooltip from '../tooltip/tooltip.jsx';
import { createPortal } from 'react-dom';
import './autoSuggest.css';
export default function AutoSuggest({ suggestions, contentEditableDivRef, initial, handleValueChange, disable, placeholder }) {
const showVariableValueTimeoutRef = useRef(null);
const latestSuggestionsRef = useRef(suggestions || {});
const [caretPosition, setCaretPosition] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
const [tooltipPosition, setTooltipPosition] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
const [tooltipVariableDetails, setTooltipVariableDetails] = useState(null);
const [showTooltip, setShowTooltip] = useState(false);
const [showSuggestions, setShowSuggestions] = useState(false);
const [filteredSuggestions, setFilteredSuggestions] = useState(suggestions || {});
const [searchWord, setSearchWord] = useState(null);
const [suggestionIndex, setSuggestionIndex] = useState(0);
const [showPlaceholder, setShowPlaceholder] = useState(true);
useEffect(() => {
const editableDiv = contentEditableDivRef?.current;
if (!editableDiv) return;
editableDiv.addEventListener('blur', handleEditableDivBlur);
return () => {
editableDiv.removeEventListener('blur', handleEditableDivBlur);
};
}, [contentEditableDivRef]);
useEffect(() => {
latestSuggestionsRef.current = suggestions;
}, [suggestions]);
useEffect(() => {
const editableDiv = contentEditableDivRef?.current;
if (!editableDiv) return;
editableDiv.innerHTML = initial;
removeEmptySpans();
addEventListenersToVariableSpan();
checkShowPlaceholder();
}, [initial]);
function handleEditableDivBlur() {
setShowSuggestions(false);
setShowTooltip(false);
setSuggestionIndex(0);
}
const handleVariableSpanHoverEvent = useCallback((event) => {
const node = event.target;
const variableKey = removeOuterCurlyBraces(node.innerText || node.textContent);
const rect = node.getBoundingClientRect();
setTooltipVariableDetails(latestSuggestionsRef?.current?.[variableKey]);
setTooltipPosition(rect);
showVariableValueTimeoutRef.current = setTimeout(() => {
setShowTooltip(true);
setShowSuggestions(false);
setSuggestionIndex(0);
}, 500);
}, []);
const handleVariableSpanDownEvent = useCallback(() => {
clearTimeout(showVariableValueTimeoutRef.current);
setShowTooltip(false);
}, []);
const addEventListenersToVariableSpan = useCallback(() => {
removeAllEventListeners();
const editableDiv = contentEditableDivRef.current;
if (!editableDiv) return;
const variableBlocks = editableDiv.querySelectorAll('span[variable-block="true"]')
if (variableBlocks.length === 0) return;
Array.from(variableBlocks)?.forEach((variableBlock) => {
variableBlock.addEventListener('mouseenter', handleVariableSpanHoverEvent);
variableBlock.addEventListener('mouseleave', handleVariableSpanDownEvent);
})
}, [handleVariableSpanHoverEvent, handleVariableSpanDownEvent]);
const removeAllEventListeners = useCallback(() => {
const editableDiv = contentEditableDivRef.current;
if (!editableDiv) return;
const allSpan = editableDiv.querySelectorAll('span[text-block="true"]');
Array.from(allSpan)?.forEach((span) => {
span.removeEventListener('mouseenter', handleVariableSpanHoverEvent);
});
}, [handleVariableSpanHoverEvent, handleVariableSpanDownEvent]);
function insertSuggestion(suggestionText) {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const currentTextNode = selection.anchorNode;
const spanNode = currentTextNode.parentNode;
const editableDivNode = spanNode.parentNode;
const { textBefore, textAfter } = removeAllPreceedingCurlyBracesFromTextNode(currentTextNode.wholeText, searchWord);
const textElementBefore = createNewTextNode();
const variableElement = createNewVariableNode();
const textElementAfter = createNewTextNode();
variableElement.innerText = `{{${suggestionText}}}`;
textElementBefore.innerText = textBefore;
textElementAfter.innerText = textAfter;
if (textBefore) editableDivNode?.insertBefore(textElementBefore, spanNode)
editableDivNode?.insertBefore(variableElement, spanNode)
if (textAfter) editableDivNode?.insertBefore(textElementAfter, spanNode)
editableDivNode?.removeChild(spanNode);
range.setStartAfter(variableElement, 0);
range.setEndAfter(variableElement, variableElement.textContent.length);
selection.removeAllRanges();
setShowSuggestions(false);
setSuggestionIndex(0);
range.collapse(false);
selection.addRange(range);
setTimeout(() => contentEditableDivRef.current.focus());
addEventListenersToVariableSpan();
removeEmptySpans();
checkShowPlaceholder();
handleValueChange && handleValueChange();
}
function createFirstNode(content) {
const selection = window.getSelection();
const range = document.createRange();
const textElement = createNewTextNode();
textElement.innerText = content;
contentEditableDivRef.current.innerText = '';
contentEditableDivRef.current.appendChild(textElement);
range.setStart(textElement, textElement.textContent.length);
selection.removeAllRanges();
range.collapse(false);
selection.addRange(range);
checkShowPlaceholder();
handleValueChange && handleValueChange();
addEventListenersToVariableSpan();
}
const handleContentChange = (event) => {
const prevCaretPosition = saveCaretPosition(contentEditableDivRef.current);
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const currentNode = selection.anchorNode;
const parentNode = currentNode.parentNode;
const editableDivNode = parentNode.parentNode;
const content = event.target.innerText;
if (content.length === 0) {
setShowSuggestions(false);
setShowTooltip(false);
return;
}
if (content.length === 1 && content != '\n') return createFirstNode(content);
if (currentNode.parentNode.getAttribute('text-block')) getSearchWord();
if (parentNode.getAttribute('variable-block')) {
if (isEncodedWithCurlyBraces(currentNode?.textContent?.slice(0, -1))) {
const textElement = createNewTextNode();
const variableElement = createNewVariableNode();
textElement.innerText = currentNode.textContent[currentNode.textContent.length - 1];
variableElement.innerText = currentNode.textContent.slice(0, -1);
editableDivNode?.insertBefore(variableElement, parentNode);
editableDivNode?.insertBefore(textElement, parentNode);
editableDivNode?.removeChild(parentNode)
range.setStart(textElement, textElement.textContent.length);
selection.removeAllRanges();
range.collapse(false);
selection.addRange(range);
}
else if (isEncodedWithCurlyBraces(currentNode?.textContent?.slice(1))) {
const textElement = createNewTextNode();
const variableElement = createNewVariableNode();
textElement.innerText = currentNode.textContent[0];
variableElement.innerText = currentNode.textContent.slice(1);
editableDivNode?.insertBefore(textElement, parentNode);
editableDivNode?.insertBefore(variableElement, parentNode);
editableDivNode?.removeChild(parentNode)
range.setStart(textElement, textElement.textContent.length);
selection.removeAllRanges();
range.collapse(true);
selection.addRange(range);
}
}
let currentText = '';
Array.from(editableDivNode?.childNodes)?.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.getAttribute('text-block')) return;
if (!isEncodedWithCurlyBraces(node?.textContent)) {
node.setAttribute('text-block', true);
node.removeAttribute('variable-block');
currentText = node?.textContent;
}
}
removeAllEventListeners();
});
if (currentText != '') {
setDiffVariableBlock(currentText);
}
mergeTextBlockSpans();
setDynamicVariables(contentEditableDivRef);
restoreCaretPosition(contentEditableDivRef.current, prevCaretPosition);
addEventListenersToVariableSpan();
removeEmptySpans();
checkShowPlaceholder();
handleValueChange && handleValueChange();
};
const setDiffVariableBlock = () => {
const selection = window.getSelection();
const currentNode = selection.anchorNode.parentNode;
const newHTML = convertTextToHTML(currentNode.innerText);
const tempContainer = document.createElement('div');
tempContainer.innerHTML = newHTML;
const parentNode = currentNode.parentNode;
Array.from(tempContainer.childNodes).forEach((newNode) => {
parentNode.insertBefore(newNode, currentNode);
});
parentNode.removeChild(currentNode);
};
const mergeTextBlockSpans = () => {
const editableDivNodeRef = contentEditableDivRef.current;
const spans = Array.from(editableDivNodeRef.querySelectorAll('span'));
let newSpans = [];
let i = 0;
while (i < spans.length) {
let currentSpan = spans[i];
if (currentSpan.attributes[0]?.name === 'text-block' && currentSpan.getAttribute('text-block')) {
let mergedContent = currentSpan.textContent;
i++;
while (i < spans.length && spans[i].attributes[0]?.name === 'text-block' && spans[i].getAttribute('text-block')) {
mergedContent += spans[i].textContent;
i++;
}
let newMergedSpan = document.createElement('span');
newMergedSpan.setAttribute('text-block', true);
newMergedSpan.textContent = mergedContent;
newSpans.push(newMergedSpan);
} else {
newSpans.push(currentSpan);
i++;
}
}
editableDivNodeRef.innerHTML = '';
newSpans.forEach((span) => {
editableDivNodeRef.appendChild(span);
});
};
const removeEmptySpans = () => {
const allSpan = contentEditableDivRef.current.querySelectorAll('span');
Array.from(allSpan)?.forEach((span) => {
if (span.querySelector('br')) {
const brTag = span.querySelector('br');
if (brTag?.parentNode === span && span.parentNode === contentEditableDivRef.current) {
contentEditableDivRef?.current?.removeChild(span);
}
}
if (span.innerText.length === 0 && span.parentNode === contentEditableDivRef.current) {
contentEditableDivRef?.current?.removeChild(span);
}
})
}
function getSearchWord() {
const searchWord = getTextAfterLastOpenCurlyBrace();
if (searchWord) {
setShowTooltip(false);
setSearchWord(searchWord);
const caretPosition = getCaretPosition();
setCaretPosition(caretPosition);
setShowSuggestions(true);
}
else {
setSearchWord(null);
setShowSuggestions(false);
}
const filteredSuggestions = filterSuggestions(searchWord, suggestions) || {};
setSuggestionIndex(0);
if (Object.keys(filteredSuggestions || {})?.length === 0) setShowSuggestions(false);
setFilteredSuggestions(filteredSuggestions || {});
}
function arrowUpPress(event) {
event.preventDefault();
if (suggestionIndex === 0) return setSuggestionIndex(Object.keys(filteredSuggestions).length - 1);
setSuggestionIndex((prev) => prev - 1);
}
function arrowDownPress(event) {
event.preventDefault();
if (suggestionIndex === Object.keys(filteredSuggestions).length - 1) return setSuggestionIndex(0);
setSuggestionIndex((prev) => prev + 1);
}
function enterPress(event) {
event.preventDefault();
if (suggestionIndex > -1 && showSuggestions) {
insertSuggestion(Object.keys(filteredSuggestions)[suggestionIndex])
}
}
const handleKeyDown = (event) => {
if (event.key === 'ArrowUp') arrowUpPress(event);
if (event.key === 'ArrowDown') arrowDownPress(event);
if (event.key === 'Enter') enterPress(event);
}
const handleKeyUp = (event) => {
const selection = window.getSelection();
const currentNode = selection.anchorNode;
const parentNode = currentNode?.parentNode;
if (event.key === '{' && currentNode?.parentNode.getAttribute('text-block')) {
const caretPosition = getCaretPosition();
setCaretPosition(caretPosition);
setShowTooltip(false);
setShowSuggestions(true);
setFilteredSuggestions(suggestions);
}
if (!event.key.match(/^[\x20-\x7E]$/) && parentNode?.getAttribute('variable-block')) {
event.preventDefault();
}
}
const modifySelectedText = () => {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
const startNode = range.startContainer;
const endNode = range.endContainer;
const startOffset = range.startOffset;
const endOffset = range.endOffset;
let newCaretPositionNode;
let newCaretPositionOffset;
if (startNode === endNode) {
const fullText = startNode.textContent;
const modifiedText = fullText.substring(0, startOffset) + fullText.substring(endOffset);
startNode.textContent = modifiedText;
newCaretPositionNode = startNode;
newCaretPositionOffset = startOffset;
}
else {
let currentNode = startNode.parentNode.nextSibling;
while (currentNode && currentNode !== endNode.parentNode) {
let nextNode = currentNode.nextSibling;
currentNode.parentNode.removeChild(currentNode);
currentNode = nextNode;
}
const startText = startNode.textContent;
const modifiedStartText = startText.substring(0, startOffset);
startNode.textContent = modifiedStartText;
const endText = endNode.textContent;
const modifiedEndText = endText.substring(endOffset);
endNode.textContent = modifiedEndText;
newCaretPositionNode = startNode;
newCaretPositionOffset = startOffset;
}
const newRange = document.createRange();
selection.removeAllRanges();
newRange.setStart(newCaretPositionNode, newCaretPositionOffset);
newRange.setEnd(newCaretPositionNode, newCaretPositionOffset);
selection.addRange(newRange);
};
const handlePaste = (event) => {
event.preventDefault();
const selection = window.getSelection();
const range = selection.getRangeAt(0);
if (range.startOffset != range.endOffset) {
modifySelectedText();
if (contentEditableDivRef.current.innerText === '') {
let text = (event.clipboardData || window.clipboardData).getData('text');
if (!text || text.length === 0) return;
let html = convertTextToHTML(text);
contentEditableDivRef.current.innerHTML = html;
addEventListenersToVariableSpan();
handleValueChange && handleValueChange();
return;
}
}
Array.from(contentEditableDivRef.current?.childNodes)?.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.getAttribute('text-block')) return;
if (!isEncodedWithCurlyBraces(node?.textContent)) {
node.setAttribute('text-block', true);
node.removeAttribute('variable-block');
}
}
removeAllEventListeners();
});
const savedCaretPos = saveCaretPosition(contentEditableDivRef.current);
const currentNode = selection?.anchorNode?.parentNode;
if (!currentNode) return;
if (!contentEditableDivRef.current) return;
let text = (event.clipboardData || window.clipboardData).getData('text');
if (!text || text.length === 0) return;
if (!selection.anchorNode?.parentNode?.getAttribute) return;
let html = convertTextToHTML(text);
if (!contentEditableDivRef.current.innerHTML || contentEditableDivRef.current.innerHTML.trim() === '') {
contentEditableDivRef.current.innerHTML = html;
checkShowPlaceholder();
addEventListenersToVariableSpan();
handleValueChange && handleValueChange();
return;
}
const createDiv = document.createElement('div');
createDiv.innerHTML = html;
const isVariableBlock = createDiv.querySelectorAll(`span[variable-block='true']`);
const spans = createDiv.querySelectorAll('span');
const { textElementAfter, textElementBefore } = getTextBeforeAndTextAfterNode();
if (!selection.anchorNode.parentNode.getAttribute('variable-block')) {
if (Array.from(isVariableBlock).length === 0) {
document.execCommand('insertText', false, text);
} else {
if (contentEditableDivRef.current.contains(currentNode)) {
contentEditableDivRef.current.insertBefore(textElementBefore, currentNode);
Array.from(spans).forEach((span) => {
contentEditableDivRef.current.insertBefore(span, currentNode);
});
contentEditableDivRef.current.insertBefore(textElementAfter, currentNode);
contentEditableDivRef.current.removeChild(currentNode);
}
}
} else if (selection.anchorNode.parentNode.getAttribute('variable-block') && selection.anchorOffset > 1 && selection.anchorOffset < selection.anchorNode.parentNode.innerText.length - 1) {
document.execCommand('insertText', false, text);
} else if (selection.anchorNode.parentNode.getAttribute('variable-block') && (selection.anchorOffset === 1 || selection.anchorOffset >= selection.anchorNode.parentNode.innerText.length - 1)) {
if (selection.anchorOffset === 0) {
Array.from(spans).forEach((span) => {
contentEditableDivRef.current.insertBefore(span, selection.anchorNode.parentNode.nextSibling);
});
} else if (selection.anchorOffset > selection.anchorNode.parentNode.innerText.length - 1) {
Array.from(spans).forEach((span) => {
contentEditableDivRef.current.insertBefore(span, selection.anchorNode.parentNode.nextSibling || null);
});
} else {
contentEditableDivRef.current.insertBefore(textElementBefore, currentNode);
Array.from(spans).forEach((span) => {
contentEditableDivRef.current.insertBefore(span, currentNode);
});
contentEditableDivRef.current.insertBefore(textElementAfter, currentNode);
contentEditableDivRef.current.removeChild(currentNode);
}
}
removeEmptySpans();
addEventListenersToVariableSpan();
restoreCaretPosition(contentEditableDivRef.current, savedCaretPos);
checkShowPlaceholder();
handleValueChange && handleValueChange();
};
function checkShowPlaceholder() {
if (contentEditableDivRef?.current?.innerText?.length === 0) return setShowPlaceholder(true);
if (contentEditableDivRef?.current?.innerText?.length != 0 && showPlaceholder === true) return setShowPlaceholder(false);
}
return (
<React.Fragment>
<div className="parent-div">
<div className={`main__div ${disable ? 'disable-div' : ''}`}>
{showPlaceholder && !disable ? <div className='placeholder-editable-div'>
<span className='placeholder-text'>{placeholder}</span>
</div> : null}
<div className='auto-suggest'>
<div
ref={contentEditableDivRef} className={`__custom-autosuggest-block__`}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
contentEditable={disable === true ? false : true}
onInput={handleContentChange}
onPaste={handlePaste}
>
</div>
</div>
</div>
</div>
{showSuggestions && createPortal(<SuggestionBox setSuggestionIndex={setSuggestionIndex} suggestionIndex={suggestionIndex} filteredSuggestions={filteredSuggestions} caretPosition={caretPosition} insertSuggestion={insertSuggestion} />, document.getElementById('root'))}
{showTooltip && createPortal(<Tooltip suggestions={suggestions} tooltipPosition={tooltipPosition} tooltipVariableDetails={tooltipVariableDetails} />, document.getElementById('root'))}
</React.Fragment>
)
}