@chayns-components/emoji-input
Version:
Input field that supports HTML elements and emojis
555 lines (533 loc) • 17.9 kB
JavaScript
import { clamp } from './number';
import { convertHTMLToText, getElementTextLength } from './text';
let childIndex = -1;
let endOffset = -1;
let startOffset = -1;
export const saveSelection = (element, {
shouldIgnoreEmptyTextNodes
} = {}) => {
const selection = window.getSelection();
if (!selection) {
return;
}
const {
anchorNode
} = selection;
if (!anchorNode) {
return;
}
const range = selection.getRangeAt(0);
let childNodesArray = Array.from(element.childNodes);
if (shouldIgnoreEmptyTextNodes) {
childNodesArray = childNodesArray.filter(({
nodeType,
nodeValue
}) => nodeType !== Node.TEXT_NODE || nodeValue !== '' && nodeValue !== '\u200B');
}
childIndex = childNodesArray.indexOf(anchorNode);
endOffset = range.endOffset;
startOffset = range.startOffset;
};
export const restoreSelection = element => {
// Search for \u200C in child nodes. If found, set the childIndex, startOffset, and endOffset to the
// position of the \u200C character. Also remove the \u200C character from the child node. If not found,
// the childIndex, startOffset, and endOffset will be like before.
const childNodesArray = Array.from(element.childNodes);
let hasFoundNoJoiner = false;
childNodesArray.forEach(node => {
if (!hasFoundNoJoiner && node.nodeType === Node.TEXT_NODE && typeof node.nodeValue === 'string') {
const noJoinerIndex = node.nodeValue.indexOf('\u200C');
if (noJoinerIndex !== -1) {
hasFoundNoJoiner = true;
childIndex = childNodesArray.indexOf(node);
startOffset = noJoinerIndex;
endOffset = noJoinerIndex;
}
}
});
// Remove all no joiner characters from the child nodes if no joiner was found
if (hasFoundNoJoiner) {
childNodesArray.forEach(node => {
if (node.nodeType === Node.TEXT_NODE && typeof node.nodeValue === 'string') {
// eslint-disable-next-line no-param-reassign
node.nodeValue = node.nodeValue.replace(/\u200C/g, '');
}
});
}
let childNode = element.childNodes[childIndex];
const selection = window.getSelection();
if (!childNode || !element || !selection) {
return;
}
// noinspection SuspiciousTypeOfGuard
if (typeof childNode.nodeValue !== 'string') {
const elementTextLength = getElementTextLength(childNode);
if (childNode.nextSibling) {
childNode = childNode.nextSibling;
if (childNode.nodeType === Node.TEXT_NODE && childNode.nodeValue) {
endOffset -= elementTextLength;
startOffset -= elementTextLength;
if (childNode.nodeValue.charCodeAt(endOffset) === 8203) {
endOffset += 1;
startOffset += 1;
}
} else {
const textNode = document.createTextNode('\u200B');
childNode.parentNode?.insertBefore(textNode, childNode.nextSibling);
childNode = textNode;
endOffset = textNode.length;
startOffset = textNode.length;
}
} else {
const textNode = document.createTextNode('\u200B');
childNode.parentNode?.insertBefore(textNode, childNode.nextSibling);
childNode = textNode;
endOffset = textNode.length;
startOffset = textNode.length;
}
} else if (childNode.nodeValue && endOffset > childNode.nodeValue.length) {
if (childNode.nextSibling?.nodeValue) {
let elementTextLength = childNode.nodeValue.length;
childNode = childNode.nextSibling;
// noinspection SuspiciousTypeOfGuard
if (typeof childNode.nodeValue !== 'string') {
elementTextLength += getElementTextLength(childNode);
if (childNode.nextSibling?.nodeValue) {
childNode = childNode.nextSibling;
if (childNode.nodeType === Node.TEXT_NODE && childNode.nodeValue) {
endOffset -= elementTextLength;
startOffset -= elementTextLength;
if (childNode.nodeValue.charCodeAt(endOffset) === 8203) {
endOffset += 1;
startOffset += 1;
}
} else {
const textNode = document.createTextNode('\u200B');
childNode.parentNode?.insertBefore(textNode, childNode.nextSibling);
childNode = textNode;
endOffset = textNode.length;
startOffset = textNode.length;
}
} else {
const textNode = document.createTextNode('\u200B');
childNode.parentNode?.insertBefore(textNode, childNode.nextSibling);
childNode = textNode;
endOffset = textNode.length;
startOffset = textNode.length;
}
}
} else {
endOffset = childNode.nodeValue.length;
startOffset = childNode.nodeValue.length;
}
}
const range = document.createRange();
// noinspection SuspiciousTypeOfGuard
if (typeof childNode.nodeValue === 'string') {
startOffset = clamp(startOffset, 0, childNode.nodeValue.length);
endOffset = clamp(endOffset, 0, childNode.nodeValue.length);
}
try {
range.setStart(childNode, startOffset);
range.setEnd(childNode, endOffset);
} catch (error) {
// Do nothing
}
selection.removeAllRanges();
selection.addRange(range);
range.collapse(true);
};
export const insertInvisibleCursorMarker = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
const textNode = range.startContainer;
const offset = range.startOffset;
// noinspection JSDeprecatedSymbols
document.execCommand('delete', false);
const span = document.createElement('span');
span.style.display = 'inline-block';
span.style.width = '0';
span.style.height = '0';
span.className = 'invisible-cursor-marker';
const parent = textNode.parentNode;
if (parent) {
if (textNode.nodeType === Node.TEXT_NODE) {
const textContent = textNode.textContent || '';
const beforeText = textContent.slice(0, offset);
const afterText = textContent.slice(offset);
textNode.textContent = beforeText;
parent.insertBefore(span, textNode.nextSibling);
const afterTextNode = document.createTextNode(afterText);
parent.insertBefore(afterTextNode, span.nextSibling);
setTimeout(() => {
// Set cursor to cursor element
const newRange = document.createRange();
newRange.setStartAfter(span);
newRange.setEndAfter(span);
selection.removeAllRanges();
selection.addRange(newRange);
// Remove cursor element
span.remove();
}, 10);
}
}
};
export const insertPseudoMarker = () => {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const marker = document.createElement('span');
marker.id = 'cursor-marker';
marker.style.display = 'inline-block';
marker.style.width = '0';
marker.style.height = '0';
range.insertNode(marker);
}
};
export const insertCursorAtMarker = ref => {
const marker = ref.current?.querySelector('#cursor-marker');
if (marker) {
const selection = window.getSelection();
const range = document.createRange();
range.setStartAfter(marker);
range.setEndAfter(marker);
selection?.removeAllRanges();
selection?.addRange(range);
marker.remove();
}
};
export const moveSelectionOffset = distance => {
endOffset += distance;
startOffset += distance;
};
export const setChildIndex = index => {
childIndex = index;
};
/**
* This function returns the code of the character that will be removed by the KeyDown event in the
* next step, if the "Backspace" or "Delete" key was pressed and there is no selection of multiple
* characters.
*
* @param event - Keyboard event from "onKeyDown"
*/
export const getCharCodeThatWillBeDeleted = event => {
const range = window.getSelection()?.getRangeAt(0);
/**
* At this point the function is aborted if there is no selection range, several characters have
* been selected and therefore no single letter is removed or neither the "Backspace" nor the
* "Delete" key has been pressed.
*/
if (!range || range.endOffset !== range.startOffset || event.key !== 'Backspace' && event.key !== 'Delete' && event.key !== 'Unidentified') {
return null;
}
if (event.key === 'Backspace' || event.key === 'Unidentified') {
const {
nodeValue,
previousSibling
} = range.startContainer;
if (range.startOffset > 0) {
return nodeValue?.charCodeAt(range.startOffset - 1);
}
return previousSibling?.nodeValue?.charCodeAt(previousSibling.nodeValue.length - 1);
}
const {
nextSibling,
nodeValue
} = range.endContainer;
if (range.endOffset < (nodeValue?.length ?? 0)) {
return nodeValue?.charCodeAt(range.endOffset);
}
return nextSibling?.nodeValue?.charCodeAt(0);
};
export const findAndSelectText = ({
editorElement,
searchText,
options = {
shouldReplaceNearCursor: true
}
}) => {
if (!editorElement.textContent?.includes(searchText)) {
return null;
}
const range = document.createRange();
const cursorPos = getCurrentCursorPosition(editorElement);
const matches = [];
let absCounter = 0;
const searchNodesForText = node => {
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
let index = node.textContent.indexOf(searchText);
while (index !== -1) {
matches.push({
node,
offset: index,
absIndex: absCounter + index
});
index = node.textContent.indexOf(searchText, index + 1);
}
absCounter += node.textContent.length;
} else if (node.nodeName !== 'LC_MENTION') {
Array.from(node.childNodes).forEach(searchNodesForText);
}
};
searchNodesForText(editorElement);
if (matches.length === 0) {
return null;
}
let match = matches[0];
if (options?.shouldReplaceNearCursor && cursorPos !== null) {
match = matches.reduce((prev, curr) => Math.abs(curr.absIndex - cursorPos) < Math.abs(prev.absIndex - cursorPos) ? curr : prev);
}
if (!match) {
return null;
}
range.setStart(match.node, match.offset);
range.setEnd(match.node, match.offset + searchText.length);
return range;
};
export const getCurrentCursorPosition = editorElement => {
if (!editorElement) {
return null;
}
const sel = window.getSelection?.();
if (!sel || sel.rangeCount === 0) {
return null;
}
const range = sel.getRangeAt(0);
if (!editorElement.contains(range.commonAncestorContainer)) {
return null;
}
const pre = document.createRange();
pre.selectNodeContents(editorElement);
try {
pre.setEnd(range.startContainer, range.startOffset);
const container = document.createElement('div');
container.appendChild(pre.cloneContents());
const bbCodeUntilCursor = convertHTMLToText(container.innerHTML, {
preserveSpaces: true,
shouldSerializeNoEmojiToBBCode: false
});
return bbCodeUntilCursor.length;
} catch {
return convertHTMLToText(editorElement.innerHTML, {
shouldSerializeNoEmojiToBBCode: false
}).length;
}
};
const getCursorTargetByAbsIndex = ({
editorElement,
position
}) => {
const childNodes = Array.from(editorElement.childNodes);
if (childNodes.length === 0) {
const textNode = document.createTextNode('');
editorElement.appendChild(textNode);
return {
node: textNode,
offset: 0
};
}
const clampedPosition = clamp(position, 0, convertHTMLToText(editorElement.innerHTML, {
shouldSerializeNoEmojiToBBCode: false
}).length);
let absCounter = 0;
const searchNodes = node => {
if (node.nodeType === Node.TEXT_NODE) {
const textNode = node;
const nodeText = textNode.nodeValue ?? '';
const nextAbsCounter = absCounter + nodeText.length;
if (clampedPosition <= nextAbsCounter) {
return {
node: textNode,
offset: clamp(clampedPosition - absCounter, 0, nodeText.length)
};
}
absCounter = nextAbsCounter;
return null;
}
if (node.nodeName === 'LC_MENTION') {
return null;
}
for (const child of Array.from(node.childNodes)) {
const found = searchNodes(child);
if (found) {
return found;
}
}
return null;
};
for (const rootNode of childNodes) {
const found = searchNodes(rootNode);
if (found) {
return found;
}
}
const lastTextNode = editorElement.lastChild;
if (lastTextNode?.nodeType === Node.TEXT_NODE) {
const textNode = lastTextNode;
return {
node: textNode,
offset: textNode.length
};
}
const appendedTextNode = document.createTextNode('\u200B');
editorElement.appendChild(appendedTextNode);
return {
node: appendedTextNode,
offset: appendedTextNode.length
};
};
export const setCursorPositionByAbsIndex = ({
editorElement,
position
}) => {
const selection = window.getSelection?.();
if (!selection) {
return;
}
const target = getCursorTargetByAbsIndex({
editorElement,
position
});
if (!target) {
return;
}
const range = document.createRange();
try {
range.setStart(target.node, target.offset);
range.setEnd(target.node, target.offset);
} catch {
return;
}
selection.removeAllRanges();
selection.addRange(range);
range.collapse(true);
};
/**
* Unwraps any no-emoji-convert span that contains the cursor.
* This removes the span element but keeps its text content, preventing the cursor from getting stuck.
*/
export const unwrapIgnoreEmojiSpanAtCursor = editorElement => {
if (!editorElement) {
return;
}
const selection = window.getSelection();
if (!selection || !selection.rangeCount) {
return;
}
const range = selection.getRangeAt(0);
let {
commonAncestorContainer
} = range;
const cursorOffset = range.startOffset;
if (!editorElement.contains(commonAncestorContainer)) {
return;
}
// Walk up the DOM tree to find if we're inside a no-emoji-convert span
while (commonAncestorContainer && commonAncestorContainer !== editorElement) {
if (commonAncestorContainer.nodeType === Node.ELEMENT_NODE && commonAncestorContainer.classList.contains('no-emoji-convert')) {
// We're inside a no-emoji-convert span, unwrap it
const span = commonAncestorContainer;
const {
parentNode
} = span;
if (!parentNode) {
return;
}
// Find which text node contains the cursor
let cursorNode = null;
const cursorNodeOffset = cursorOffset;
// Get all child nodes of the span
const childNodes = Array.from(span.childNodes);
// Insert all children before the span and find the cursor node
childNodes.forEach(child => {
const clonedChild = child.cloneNode(true);
parentNode.insertBefore(clonedChild, span);
// If this is the node that contains the cursor, track it
if (child === range.startContainer) {
cursorNode = clonedChild;
}
});
// Remove the span
parentNode.removeChild(span);
// Restore cursor position in the unwrapped content
if (cursorNode) {
range.setStart(cursorNode, cursorNodeOffset);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
break;
}
// @ts-expect-error - is needed
commonAncestorContainer = commonAncestorContainer.parentNode;
}
};
/**
* Moves the cursor outside of any no-emoji-convert span if it's currently inside one.
* This prevents the cursor from getting "stuck" inside the protection span when typing.
*/
export const moveCursorOutOfIgnoreEmojiSpan = editorElement => {
if (!editorElement) {
return;
}
const selection = window.getSelection();
if (!selection || !selection.rangeCount) {
return;
}
const range = selection.getRangeAt(0);
let {
commonAncestorContainer
} = range;
if (!editorElement.contains(commonAncestorContainer)) {
return;
}
// Walk up the DOM tree to find if we're inside a no-emoji-convert span
while (commonAncestorContainer && commonAncestorContainer !== editorElement) {
if (commonAncestorContainer.nodeType === Node.ELEMENT_NODE && commonAncestorContainer.classList.contains('no-emoji-convert')) {
// We're inside a no-emoji-convert span, move cursor to after it
const span = commonAncestorContainer;
const {
nextSibling
} = span;
const {
parentNode
} = span;
if (!parentNode) {
return;
}
// Always ensure there's a text node AFTER the span
let targetNode;
if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE) {
// Next sibling is already a text node, use it
targetNode = nextSibling;
} else if (nextSibling && nextSibling.nodeType === Node.ELEMENT_NODE) {
// Next sibling is an element, find first text node in it
const walker = document.createTreeWalker(nextSibling, NodeFilter.SHOW_TEXT);
const firstTextNode = walker.nextNode();
if (firstTextNode) {
targetNode = firstTextNode;
} else {
// No text node found, create one
targetNode = document.createTextNode('\u200B');
parentNode.insertBefore(targetNode, nextSibling);
}
} else {
// No next sibling or it's not a text/element node, create a new text node
targetNode = document.createTextNode('\u200B');
parentNode.insertBefore(targetNode, nextSibling);
}
// Place cursor at the start of the target node
range.setStart(targetNode, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
break;
}
// @ts-expect-error - is needed
commonAncestorContainer = commonAncestorContainer.parentNode;
}
};
//# sourceMappingURL=selection.js.map