Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active January 27, 2024 14:13
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save loilo/f873a88631e660c59a1d5ab757ca9b1e to your computer and use it in GitHub Desktop.
Save loilo/f873a88631e660c59a1d5ab757ca9b1e to your computer and use it in GitHub Desktop.
JavaScript Contenteditable Caret Position Utilities
// 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
}
@ErfanEbrahimnia
Copy link

Hey @loilo,
is it ok if I use this code in one of my open source projects?

btw. here is an TS adjusted version I quickly created:

// Some utilities for detecting the caret position inside a contenteditable element

/**
 * Get the number of characters in an element
 */
export function getTextLength(element: HTMLElement) {
  let range = element.ownerDocument.createRange();
  range.selectNodeContents(element);

  return range.toString().length;
}

/**
 * Get the character offset the caret is currently at
 */
export function getCaretOffset(element: HTMLElement) {
  let sel = element.ownerDocument.defaultView?.getSelection();

  if (!sel || sel.rangeCount === 0) return 0;

  let range = element.ownerDocument.defaultView?.getSelection()?.getRangeAt(0);

  if (!range) return 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
 */
export function isCaretAtStart(element: HTMLElement) {
  if (element.ownerDocument.activeElement !== element) return false;

  const caretRange = element.ownerDocument.defaultView
    ?.getSelection()
    ?.getRangeAt(0)
    .toString();

  if (!caretRange || caretRange.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
 */
export function isCaretAtEnd(element: HTMLElement) {
  if (element.ownerDocument.activeElement !== element) return false;

  const caretRange = element.ownerDocument.defaultView
    ?.getSelection()
    ?.getRangeAt(0)
    .toString();

  if (!caretRange || caretRange.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
 */
export 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;
}

/**
 * Check if the caret is on the last line of an element
 * Returns `false` when the caret is part of a selection
 */
export 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;
}

@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 thoguh, I agree that this was a terrible design decision for reading the code later. 😅

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