Last active
December 17, 2024 07:33
-
-
Save loilo/f873a88631e660c59a1d5ab757ca9b1e to your computer and use it in GitHub Desktop.
Some utilities for detecting the caret position inside a contenteditable element
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Get the number of characters in an element | |
* | |
* @param {Element} element | |
* @return {number} | |
*/ | |
function getTextLength(element) { | |
let range = element.ownerDocument.createRange() | |
range.selectNodeContents(element) | |
return range.toString().length | |
} | |
/** | |
* Get the character offset the caret is currently at | |
* | |
* @param {Element} element | |
* @return {number} | |
*/ | |
function getCaretOffset(element) { | |
let sel = element.ownerDocument.defaultView.getSelection() | |
if (sel.rangeCount === 0) return 0 | |
let range = element.ownerDocument.defaultView.getSelection().getRangeAt(0) | |
let preCaretRange = range.cloneRange() | |
preCaretRange.selectNodeContents(element) | |
preCaretRange.setEnd(range.endContainer, range.endOffset) | |
return preCaretRange.toString().length | |
} | |
/** | |
* Check if the caret is at the start of an element | |
* Returns `false` when the caret is part of a selection | |
* | |
* @param {Element} element | |
* @return {boolean} | |
*/ | |
function isCaretAtStart(element) { | |
if (element.ownerDocument.activeElement !== element) return false | |
if ( | |
element.ownerDocument.defaultView.getSelection().getRangeAt(0).toString() | |
.length > 0 | |
) | |
return false | |
return getCaretOffset(element) === 0 | |
} | |
/** | |
* Check if the caret is at the end of an element | |
* Returns `false` when the caret is part of a selection | |
* | |
* @param {Element} element | |
* @return {boolean} | |
*/ | |
function isCaretAtEnd(element) { | |
if (element.ownerDocument.activeElement !== element) return false | |
if ( | |
element.ownerDocument.defaultView.getSelection().getRangeAt(0).toString() | |
.length > 0 | |
) | |
return false | |
return getCaretOffset(element) === getTextLength(element) | |
} | |
/** | |
* Check if the caret is on the first line of an element | |
* Returns `false` when the caret is part of a selection | |
* | |
* @param {Element} element | |
* @return {boolean} | |
*/ | |
function isCaretOnFirstLine(element) { | |
if (element.ownerDocument.activeElement !== element) return false | |
// Get the client rect of the current selection | |
let window = element.ownerDocument.defaultView | |
let selection = window.getSelection() | |
if (selection.rangeCount === 0) return false | |
let originalCaretRange = selection.getRangeAt(0) | |
// Bail if there is text selected | |
if (originalCaretRange.toString().length > 0) return false | |
let originalCaretRect = originalCaretRange.getBoundingClientRect() | |
// Create a range at the end of the last text node | |
let startOfElementRange = element.ownerDocument.createRange() | |
startOfElementRange.selectNodeContents(element) | |
// The endContainer might not be an actual text node, | |
// try to find the last text node inside | |
let startContainer = startOfElementRange.endContainer | |
let startOffset = 0 | |
while (startContainer.hasChildNodes() && !(startContainer instanceof Text)) { | |
startContainer = startContainer.firstChild | |
} | |
startOfElementRange.setStart(startContainer, startOffset) | |
startOfElementRange.setEnd(startContainer, startOffset) | |
let endOfElementRect = startOfElementRange.getBoundingClientRect() | |
return originalCaretRect.top === endOfElementRect.top | |
} | |
/** | |
* Check if the caret is on the last line of an element | |
* Returns `false` when the caret is part of a selection | |
* | |
* @param {Element} element | |
* @return {boolean} | |
*/ | |
function isCaretOnLastLine(element) { | |
if (element.ownerDocument.activeElement !== element) return false | |
// Get the client rect of the current selection | |
let window = element.ownerDocument.defaultView | |
let selection = window.getSelection() | |
if (selection.rangeCount === 0) return false | |
let originalCaretRange = selection.getRangeAt(0) | |
// Bail if there is a selection | |
if (originalCaretRange.toString().length > 0) return false | |
let originalCaretRect = originalCaretRange.getBoundingClientRect() | |
// Create a range at the end of the last text node | |
let endOfElementRange = document.createRange() | |
endOfElementRange.selectNodeContents(element) | |
// The endContainer might not be an actual text node, | |
// try to find the last text node inside | |
let endContainer = endOfElementRange.endContainer | |
let endOffset = 0 | |
while (endContainer.hasChildNodes() && !(endContainer instanceof Text)) { | |
endContainer = endContainer.lastChild | |
endOffset = endContainer.length ?? 0 | |
} | |
endOfElementRange.setEnd(endContainer, endOffset) | |
endOfElementRange.setStart(endContainer, endOffset) | |
let endOfElementRect = endOfElementRange.getBoundingClientRect() | |
return originalCaretRect.bottom === endOfElementRect.bottom | |
} |
getCaretOffset only returns the previous offset not the current one. Is there a way to fix this?
@ClemsonCoder That doesn't happen for me. In which situations are you trying to access the offset? I'm assuming you're somehow trying to track the position "live", while typing, using the keydown
event.
In that case, you'd have to slightly defer reading the offset (e.g. using setTimeout
) because the caret position has not changed yet when the event is fired (for what it's worth, you could still preventDefault()
the event to block the caret from moving):
// Won't get the latest offset
myEditableElement.addEventListener('keydown', () => {
console.log(getCaretOffset(myEditableElement))
})
// Will get the latest offset
myEditableElement.addEventListener('keydown', () => {
setTimeout(() => {
console.log(getCaretOffset(myEditableElement))
}, 0)
})
If that's not what you were trying to do, you'd have to provide some more context/code to look at. :)
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@benoitlahoz I always use
element.ownerDocument.defaultView
, I just sometimes dolet window = element.ownerDocument.defaultView
before. In retrospect though, I agree that this was a terrible design decision for reading the code later. 😅