UNPKG

@chayns-components/emoji-input

Version:
555 lines (533 loc) • 17.9 kB
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