Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active December 17, 2024 07:33
Show Gist options
  • Save loilo/f873a88631e660c59a1d5ab757ca9b1e to your computer and use it in GitHub Desktop.
Save loilo/f873a88631e660c59a1d5ab757ca9b1e to your computer and use it in GitHub Desktop.
Some utilities for detecting the caret position inside a contenteditable element
/**
* 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
}
@loilo
Copy link
Author

loilo commented Dec 15, 2023

@benoitlahoz I always use element.ownerDocument.defaultView, I just sometimes do let window = element.ownerDocument.defaultView before. In retrospect though, I agree that this was a terrible design decision for reading the code later. 😅

@ClemsonCoder
Copy link

getCaretOffset only returns the previous offset not the current one. Is there a way to fix this?

@loilo
Copy link
Author

loilo commented Dec 17, 2024

@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