If you're looking for a robust "caret position listener" that is functional for text areas, look no further than this solution inspired by the codepen found here. The technique involves using a "shadow" element that mirrors the styles of the textarea and gracefully handles scenarios where the textarea is resized or removed from the DOM. Be sure to review the code for insights into handling small issues like 'phantomNewline'.
function keepTrackOfCaretPosition(textArea, action) {
const shadow = createShadowElement(textArea);
shadow.style.visibility = 'hidden'; // Conceal the shadow element
function adjustShadowSizeAndPosition() {
let boundingRect = textArea.getBoundingClientRect();
let scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
let scrollTop = window.pageYOffset || document.documentElement.scrollTop;
shadow.style.left = `${boundingRect.left + scrollLeft}px`;
shadow.style.top = `${boundingRect.top + scrollTop}px`;
shadow.style.width = `${textArea.offsetWidth}px`;
shadow.style.height = `${textArea.offsetHeight}px`;
shadow.scrollTop = textArea.scrollTop;
}
function updateCaret() {
adjustShadowSizeAndPosition();
const caretPos = getCurrentCaretPosition(textArea, shadow);
action(caretPos);
}
textArea.addEventListener('input', updateCaret);
textArea.addEventListener('click', updateCaret);
textArea.addEventListener('scroll', updateCaret);
textArea.addEventListener('keydown', function(e) {
if(["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown"].includes(e.key)) {
updateCaret();
}
});
// Resize shadow if textarea is resized:
const resizeObserver = new ResizeObserver(() => adjustShadowSizeAndPosition());
resizeObserver.observe(textArea);
// Remove shadow if textarea is removed:
const mutationObserver = new MutationObserver(mutations => {
for(const mutation of mutations) {
if(Array.from(mutation.removedNodes).includes(textArea)) {
shadow.remove();
mutationObserver.disconnect();
resizeObserver.disconnect();
}
}
});
mutationObserver.observe(textArea.parentNode, { childList: true });
function createShadowElement(textArea) {
const shadow = document.createElement('div');
const style = getComputedStyle(textArea);
const propertiesToCopy = ['overflow-x', 'overflow-y', 'display', 'font-family', 'font-size', 'font-weight', 'word-wrap', 'white-space', 'padding-left', 'padding-right', 'padding-top', 'padding-bottom', 'border-left-width', 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-style', 'text-align'];
propertiesToCopy.forEach(key => shadow.style[key] = style[key]);
Object.assign(shadow.style, {
position: 'absolute',
left: `${textArea.offsetLeft}px`,
top: `${textArea.offsetTop}px`,
});
document.body.appendChild(shadow);
return shadow;
}
function getCurrentCaretPosition(textArea, shadow) {
const { selectionStart, selectionEnd } = textArea;
const value = textArea.value;
let phantomNewline = false;
// Add a 'phantom' character for a final newline due to HTML vs. textarea disparity
if(selectionStart === selectionEnd && value[selectionStart - 1] === '\n') {
phantomNewline = true;
}
shadow.textContent = phantomNewline ? value.substring(0, selectionStart) + ' ' + value.substring(selectionStart) : value;
if(!shadow.firstChild) {
const style = getComputedStyle(textArea);
return {
x: textArea.offsetLeft + parseFloat(style.paddingLeft),
y: textArea.offsetTop + parseFloat(style.paddingTop),
};
}
const range = document.createRange();
range.setStart(shadow.firstChild, phantomNewline ? selectionStart + 1 : selectionStart);
range.setEnd(shadow.firstChild, phantomNewline ? selectionEnd + 1 : selectionEnd);
const rect = range.getBoundingClientRect();
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
return {
x: rect.left + scrollLeft,
y: rect.top + scrollTop,
};
}
}
Ready to implement? Here's how you can use it:
keepTrackOfCaretPosition(textareaEl, position => {
console.log(`Caret location: x=${position.x}, y=${position.y}`);
});