-
-
Save loilo/f873a88631e660c59a1d5ab757ca9b1e to your computer and use it in GitHub Desktop.
| /** | |
| * 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 | |
| } |
Hello, isCaretOnLastLine / isCaretOnFirstLine don’t handle \n line breaks correctly. Any idea how to fix it?
@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"…
<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.
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,
)
}
@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
keydownevent.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 stillpreventDefault()the event to block the caret from moving):If that's not what you were trying to do, you'd have to provide some more context/code to look at. :)