/** * Caret boundary detection utilities. * * This module provides functions for detecting if the caret is at the * start or end of an input element. */ import { Dom as $, isCollapsedWhitespaces } from '../../dom'; import { getCaretNodeAndOffset } from './selection'; /** * Checks content at left or right of the passed node for emptiness. * * @param contenteditable - The contenteditable element containing the nodes. * @param fromNode - The starting node to check from. * @param offsetInsideNode - The offset inside the starting node. * @param direction - The direction to check ('left' or 'right'). * @returns true if adjacent content is empty, false otherwise. */ export const checkContenteditableSliceForEmptiness = ( contenteditable: HTMLElement, fromNode: Node, offsetInsideNode: number, direction: 'left' | 'right' ): boolean => { const range = document.createRange(); /** * In case of "left": * Set range from the start of the contenteditable to the passed offset */ if (direction === 'left') { range.selectNodeContents(contenteditable); range.setEnd(fromNode, offsetInsideNode); /** * In case of "right": * Set range from the passed offset to the end of the contenteditable */ } else { range.selectNodeContents(contenteditable); range.setStart(fromNode, offsetInsideNode); } /** * Clone the range's content and check its text content */ const clonedContent = range.cloneContents(); const tempDiv = document.createElement('div'); tempDiv.appendChild(clonedContent); const textContent = tempDiv.textContent || ''; /** * Check if we have any tags in the slice * We should not ignore them to allow navigation inside (e.g. empty bold tag) * * When checking the right side, trailing
tags are browser artifacts * in contenteditable elements (sentinels for cursor positioning after Shift+Enter). * If the only significant tags are
and there's no text content, * the slice is effectively empty — skip the early return. */ const significantTags = tempDiv.querySelectorAll( 'img, br, hr, input, area, base, col, embed, link, meta, param, source, track, wbr' ); if (significantTags.length > 0) { const isOnlyTrailingBrs = direction === 'right' && tempDiv.querySelectorAll('img, hr, input, area, base, col, embed, link, meta, param, source, track, wbr').length === 0 && textContent.trim() === ''; if (!isOnlyTrailingBrs) { return false; } } /** * Check if there is a non-breaking space, * since textContent can replace it with a space */ const hasNbsp = textContent.includes('\u00A0') || tempDiv.innerHTML.includes(' ') || range.toString().includes('\u00A0'); /** * Check if we have NBSP in the text node itself (if fromNode is text node) * This avoids issues with range.toString() normalization */ const isNBSPInTextNode = fromNode.nodeType === Node.TEXT_NODE && (direction === 'left' ? (fromNode.textContent || '').slice(0, offsetInsideNode) : (fromNode.textContent || '').slice(offsetInsideNode) ).includes('\u00A0'); if (hasNbsp || isNBSPInTextNode) { return false; } /** * Check for visual width * This helps to detect   that might be converted to regular space in textContent but still renders with width */ tempDiv.style.position = 'absolute'; tempDiv.style.visibility = 'hidden'; tempDiv.style.height = 'auto'; tempDiv.style.width = 'auto'; tempDiv.style.whiteSpace = window.getComputedStyle(contenteditable).whiteSpace; document.body.appendChild(tempDiv); const width = tempDiv.getBoundingClientRect().width; document.body.removeChild(tempDiv); if (width > 0) { return false; } /** * In HTML there are two types of whitespaces: * - visible ( ) * - invisible (trailing spaces, tabs, etc.) * * If text contains only invisible whitespaces, it is considered to be empty */ if (!isCollapsedWhitespaces(textContent)) { return false; } const style = window.getComputedStyle(contenteditable); const isPre = style.whiteSpace.startsWith('pre'); if (isPre && textContent.length > 0) { return false; } return true; }; /** * Checks if caret is at the start of the passed input. * * Cases: * Native input: * - if offset is 0, caret is at the start * Contenteditable: * - caret at the first text node and offset is 0 — caret is at the start * - caret not at the first text node — we need to check left siblings for emptiness * - caret offset > 0, but all left part is visible (nbsp) — caret is not at the start * - caret offset > 0, but all left part is invisible (whitespaces) — caret is at the start * * @param input - input where caret should be checked * @returns true if caret is at the start of the input */ export const isCaretAtStartOfInput = (input: HTMLElement): boolean => { const firstNode = $.getDeepestNode(input); if (firstNode === null || $.isEmpty(input)) { return true; } /** * In case of native input, we simply check if offset is 0 */ if ($.isNativeInput(firstNode)) { return (firstNode as HTMLInputElement).selectionEnd === 0; } if ($.isEmpty(input)) { return true; } const [caretNode, caretOffset] = getCaretNodeAndOffset(); /** * If there is no selection, caret is not at the start */ if (caretNode === null) { return false; } /** * If caret is inside a nested tag (e.g. ), we should let browser handle the navigation * to exit the tag first, before moving to the previous block. */ const selection = window.getSelection(); const focusNode = selection?.focusNode ?? null; if ( focusNode !== null && focusNode !== input && !(focusNode.nodeType === Node.TEXT_NODE && focusNode.parentNode === input) ) { return false; } /** * If there is nothing visible to the left of the caret, it is considered to be at the start */ return checkContenteditableSliceForEmptiness(input, caretNode, caretOffset, 'left'); }; /** * Checks if caret is at the end of the passed input. * * Cases: * Native input: * - if offset is equal to value length, caret is at the end * Contenteditable: * - caret at the last text node and offset is equal to text length — caret is at the end * - caret not at the last text node — we need to check right siblings for emptiness * - caret offset < text length, but all right part is visible (nbsp) — caret is at the end * - caret offset < text length, but all right part is invisible (whitespaces) — caret is at the end * * @param input - input where caret should be checked * @returns true if caret is at the end of the input */ export const isCaretAtEndOfInput = (input: HTMLElement): boolean => { const lastNode = $.getDeepestNode(input, true); if (lastNode === null) { return true; } /** * In case of native input, we simply check if offset is equal to value length */ if ($.isNativeInput(lastNode)) { return (lastNode as HTMLInputElement).selectionEnd === (lastNode as HTMLInputElement).value.length; } const [caretNode, caretOffset] = getCaretNodeAndOffset(); /** * If there is no selection, caret is not at the end */ if (caretNode === null) { return false; } /** * If there is nothing visible to the right of the caret, it is considered to be at the end */ return checkContenteditableSliceForEmptiness(input, caretNode, caretOffset, 'right'); };