Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active August 24, 2025 16:00
Show Gist options
  • Select an option

  • Save loilo/f873a88631e660c59a1d5ab757ca9b1e to your computer and use it in GitHub Desktop.

Select an option

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, 2022

Hey @ErfanEbrahimnia, sure, go for it!
(I have noted licensing information for my gists here, just FYI.)

@ErfanEbrahimnia
Copy link

Thank you ❤️

@AbdulhadiJarad
Copy link

Bro i am impresive how legend you are

@benoitlahoz
Copy link

Thank you so much!

I was wondering why sometime you get the selection with element.ownerDocument.defaultView?.getSelection() and sometime with window.getSelection...

@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. :)

@al-dot-dev
Copy link

Hello, isCaretOnLastLine / isCaretOnFirstLine don’t handle \n line breaks correctly. Any idea how to fix it?

@loilo
Copy link
Author

loilo commented Aug 24, 2025

@al-x-dev Do you have a concrete example that does not work? I can't really tell what you're refering to with "handle \n line breaks correctly"…

@al-dot-dev
Copy link

<script lang="ts" setup>
function isCaretOnFirstLine(element: HTMLElement) {
  if (element.ownerDocument.activeElement !== element) return false

  // Get the client rect of the current selection
  let selection = element.ownerDocument.defaultView?.getSelection()
  if (!selection || 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)) {
    if (!startContainer.firstChild) continue

    startContainer = startContainer.firstChild
  }

  startOfElementRange.setStart(startContainer, startOffset)
  startOfElementRange.setEnd(startContainer, startOffset)
  let endOfElementRect = startOfElementRange.getBoundingClientRect()

  return originalCaretRect.top === endOfElementRect.top
}

function isCaretOnLastLine(element: HTMLElement) {
  if (element.ownerDocument.activeElement !== element) return false

  // Get the client rect of the current selection
  let window = element.ownerDocument.defaultView

  if (!window) return false

  let selection = window.getSelection()

  if (!selection || 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)) {
    if (!endContainer.lastChild) continue

    endContainer = endContainer.lastChild
    endOffset = (endContainer as Text).length ?? 0
  }

  endOfElementRange.setEnd(endContainer, endOffset)
  endOfElementRange.setStart(endContainer, endOffset)
  let endOfElementRect = endOfElementRange.getBoundingClientRect()

  return originalCaretRect.bottom === endOfElementRect.bottom
}

const handleUp = (event: KeyboardEvent) => {
  const isOnFirstLine = isCaretOnFirstLine(event.target as HTMLElement)
  console.log('Is on first line', isOnFirstLine)
}

const handleDown = (event: KeyboardEvent) => {
  const isOnLastLine = isCaretOnLastLine(event.target as HTMLElement)
  console.log('Is on last line', isOnLastLine)
}
</script>

<template>
  <div
    class="bg-slate-50 whitespace-break-spaces"
    contenteditable
    v-html="'\n\n\n\n\n\n\n\n'"
    @keydown.up="handleUp"
    @keydown.down="handleDown"
  />
</template>

@loilo Here, \n creates a line break, so the block actually consists of 8 lines.
But with this setup, no matter which line the caret is on, both isCaretOnFirstLine and isCaretOnLastLine always return true.

I’m trying to implement text block navigation similar to Notion.
When inspecting their editor, it looks like they also use \n for line breaks, but somehow they can reliably detect when the caret is on the first or last line inside a block.

I’ve tried several approaches. The only one that consistently works so far is to temporarily insert an invisible character at the caret position, use range.getClientRects() to measure its position, and then compare that with the bounding rect of the contenteditable element.

Here’s my current implementation:

getCurrentLineInfo(editableElement: HTMLElement) {
  const selection = window.getSelection()
  if (!selection || selection.rangeCount === 0) return null

  const range = selection.getRangeAt(0).cloneRange()

  const tempNode = document.createTextNode('\0')
  range.insertNode(tempNode)

  const rects = range.getClientRects()
  tempNode.parentNode?.removeChild(tempNode)

  if (rects.length === 0) return null

  const caretRect = rects[0]
  const elementRect = editableElement.getBoundingClientRect()

  const tolerance = caretRect.height / 2
  const isFirstLine = caretRect.top <= elementRect.top + tolerance
  const isLastLine = caretRect.bottom >= elementRect.bottom - tolerance

  return {
    isFirstLine,
    isLastLine,
    caretHeight: caretRect.height,
  }
}

This works, but it doesn’t feel like a very clean solution.
I wonder if there’s a better way to achieve the same result (detecting if the caret is on the first or last line) without inserting a temporary node.

@al-dot-dev
Copy link

Alternative

function getCaretPositionRelativeToElement(element: HTMLElement): DOMRect | null {
  const selection = window.getSelection()
  if (!selection || selection.rangeCount === 0) {
    return null
  }

  const range = selection.getRangeAt(0).cloneRange()

  if (!range.collapsed) {
    return getRelativeRect(range.getBoundingClientRect(), element)
  }

  let caretRect = range.getBoundingClientRect()

  if (caretRect.width === 0 && caretRect.height === 0) {
    range.setEnd(range.endContainer, range.endOffset + 1)
    caretRect = range.getBoundingClientRect()
    range.collapse(true) 
  }

  return getRelativeRect(caretRect, element)
}

function getRelativeRect(absoluteRect: DOMRect, relativeElement: HTMLElement): DOMRect {
  const elementRect = relativeElement.getBoundingClientRect()
  return new DOMRect(
    absoluteRect.left - elementRect.left,
    absoluteRect.top - elementRect.top,
    absoluteRect.width,
    absoluteRect.height,
  )
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment