@chayns-components/emoji-input
Version:
Input field that supports HTML elements and emojis
586 lines (563 loc) • 21.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.unwrapIgnoreEmojiSpanAtCursor = exports.setCursorPositionByAbsIndex = exports.setChildIndex = exports.saveSelection = exports.restoreSelection = exports.moveSelectionOffset = exports.moveCursorOutOfIgnoreEmojiSpan = exports.insertPseudoMarker = exports.insertInvisibleCursorMarker = exports.insertCursorAtMarker = exports.getCurrentCursorPosition = exports.getCharCodeThatWillBeDeleted = exports.findAndSelectText = void 0;
var _number = require("./number");
var _text = require("./text");
let childIndex = -1;
let endOffset = -1;
let startOffset = -1;
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;
};
exports.saveSelection = saveSelection;
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 = (0, _text.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 {
var _childNode$parentNode;
const textNode = document.createTextNode('\u200B');
(_childNode$parentNode = childNode.parentNode) === null || _childNode$parentNode === void 0 || _childNode$parentNode.insertBefore(textNode, childNode.nextSibling);
childNode = textNode;
endOffset = textNode.length;
startOffset = textNode.length;
}
} else {
var _childNode$parentNode2;
const textNode = document.createTextNode('\u200B');
(_childNode$parentNode2 = childNode.parentNode) === null || _childNode$parentNode2 === void 0 || _childNode$parentNode2.insertBefore(textNode, childNode.nextSibling);
childNode = textNode;
endOffset = textNode.length;
startOffset = textNode.length;
}
} else if (childNode.nodeValue && endOffset > childNode.nodeValue.length) {
var _childNode$nextSiblin;
if ((_childNode$nextSiblin = childNode.nextSibling) !== null && _childNode$nextSiblin !== void 0 && _childNode$nextSiblin.nodeValue) {
let elementTextLength = childNode.nodeValue.length;
childNode = childNode.nextSibling;
// noinspection SuspiciousTypeOfGuard
if (typeof childNode.nodeValue !== 'string') {
var _childNode$nextSiblin2;
elementTextLength += (0, _text.getElementTextLength)(childNode);
if ((_childNode$nextSiblin2 = childNode.nextSibling) !== null && _childNode$nextSiblin2 !== void 0 && _childNode$nextSiblin2.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 {
var _childNode$parentNode3;
const textNode = document.createTextNode('\u200B');
(_childNode$parentNode3 = childNode.parentNode) === null || _childNode$parentNode3 === void 0 || _childNode$parentNode3.insertBefore(textNode, childNode.nextSibling);
childNode = textNode;
endOffset = textNode.length;
startOffset = textNode.length;
}
} else {
var _childNode$parentNode4;
const textNode = document.createTextNode('\u200B');
(_childNode$parentNode4 = childNode.parentNode) === null || _childNode$parentNode4 === void 0 || _childNode$parentNode4.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 = (0, _number.clamp)(startOffset, 0, childNode.nodeValue.length);
endOffset = (0, _number.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);
};
exports.restoreSelection = restoreSelection;
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);
}
}
};
exports.insertInvisibleCursorMarker = insertInvisibleCursorMarker;
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);
}
};
exports.insertPseudoMarker = insertPseudoMarker;
const insertCursorAtMarker = ref => {
var _ref$current;
const marker = (_ref$current = ref.current) === null || _ref$current === void 0 ? void 0 : _ref$current.querySelector('#cursor-marker');
if (marker) {
const selection = window.getSelection();
const range = document.createRange();
range.setStartAfter(marker);
range.setEndAfter(marker);
selection === null || selection === void 0 || selection.removeAllRanges();
selection === null || selection === void 0 || selection.addRange(range);
marker.remove();
}
};
exports.insertCursorAtMarker = insertCursorAtMarker;
const moveSelectionOffset = distance => {
endOffset += distance;
startOffset += distance;
};
exports.moveSelectionOffset = moveSelectionOffset;
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"
*/
exports.setChildIndex = setChildIndex;
const getCharCodeThatWillBeDeleted = event => {
var _window$getSelection, _nextSibling$nodeValu;
const range = (_window$getSelection = window.getSelection()) === null || _window$getSelection === void 0 ? void 0 : _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') {
var _previousSibling$node;
const {
nodeValue,
previousSibling
} = range.startContainer;
if (range.startOffset > 0) {
return nodeValue === null || nodeValue === void 0 ? void 0 : nodeValue.charCodeAt(range.startOffset - 1);
}
return previousSibling === null || previousSibling === void 0 || (_previousSibling$node = previousSibling.nodeValue) === null || _previousSibling$node === void 0 ? void 0 : _previousSibling$node.charCodeAt(previousSibling.nodeValue.length - 1);
}
const {
nextSibling,
nodeValue
} = range.endContainer;
if (range.endOffset < ((nodeValue === null || nodeValue === void 0 ? void 0 : nodeValue.length) ?? 0)) {
return nodeValue === null || nodeValue === void 0 ? void 0 : nodeValue.charCodeAt(range.endOffset);
}
return nextSibling === null || nextSibling === void 0 || (_nextSibling$nodeValu = nextSibling.nodeValue) === null || _nextSibling$nodeValu === void 0 ? void 0 : _nextSibling$nodeValu.charCodeAt(0);
};
exports.getCharCodeThatWillBeDeleted = getCharCodeThatWillBeDeleted;
const findAndSelectText = ({
editorElement,
searchText,
options = {
shouldReplaceNearCursor: true
}
}) => {
var _editorElement$textCo;
if (!((_editorElement$textCo = editorElement.textContent) !== null && _editorElement$textCo !== void 0 && _editorElement$textCo.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 !== null && options !== void 0 && 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;
};
exports.findAndSelectText = findAndSelectText;
const getCurrentCursorPosition = editorElement => {
var _window$getSelection2, _window;
if (!editorElement) {
return null;
}
const sel = (_window$getSelection2 = (_window = window).getSelection) === null || _window$getSelection2 === void 0 ? void 0 : _window$getSelection2.call(_window);
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 = (0, _text.convertHTMLToText)(container.innerHTML, {
preserveSpaces: true,
shouldSerializeNoEmojiToBBCode: false
});
return bbCodeUntilCursor.length;
} catch {
return (0, _text.convertHTMLToText)(editorElement.innerHTML, {
shouldSerializeNoEmojiToBBCode: false
}).length;
}
};
exports.getCurrentCursorPosition = getCurrentCursorPosition;
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 = (0, _number.clamp)(position, 0, (0, _text.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: (0, _number.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 === null || lastTextNode === void 0 ? void 0 : 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
};
};
const setCursorPositionByAbsIndex = ({
editorElement,
position
}) => {
var _window$getSelection3, _window2;
const selection = (_window$getSelection3 = (_window2 = window).getSelection) === null || _window$getSelection3 === void 0 ? void 0 : _window$getSelection3.call(_window2);
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.
*/
exports.setCursorPositionByAbsIndex = setCursorPositionByAbsIndex;
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.
*/
exports.unwrapIgnoreEmojiSpanAtCursor = unwrapIgnoreEmojiSpanAtCursor;
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;
}
};
exports.moveCursorOutOfIgnoreEmojiSpan = moveCursorOutOfIgnoreEmojiSpan;
//# sourceMappingURL=selection.js.map