Last active
August 24, 2025 16:00
-
-
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 hidden or 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 | |
| } |
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
@loilo Here,
\ncreates 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
\nfor 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 thecontenteditableelement.Here’s my current implementation:
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.