Contenteditable DIV - how can I determine if the cursor is at the start or end of the content

I have a contenteditable div which contains typical wysiwyg editor html (bold, anchors, lists).

I need to determine if the current cursor is, onKeyDown, at the start and at the end of the div. The reason for this is, based on the cursor position, and the key pressed, I might want to merge this div with the previous div on a backspace, or create a new following div on enter.

I've been fiddling around with ranges, but when you're working with html inside the element, things get pretty complicated.

I'm hoping I must be overlooking some simple solution.

Is there a relatively simple way to determine this - I'm open to using a library like Rangy.

Thanks!

Edit: I'm thinking something along these lines:

$('.mycontenteditable').bind('keydown', handle_keydown)

handle_keydown = function(e) {

  range = window.getSelection().getRangeAt(0)
  start_range = document.createRange()
  start_range.selectNodeContents(this.firstChild)
  start_range.collapse(true) // collapse to start
  is_start = start_range.compareBoundaryPoints(Range.START_TO_START,range)
  end_range = document.createRange()
  end_range.selectNodeContents(this.lastChild)
  end_range.collapse(false)
  is_end = end_range.compareBoundaryPoints(Range.END_TO_END,range)
}

Am I going to run into any odd issues with something like this?

Answers:

Answer

I would use a similar approach to yours except using the toString() method of Range objects rather than cloneContents() to avoid unnecessary cloning. Also, in IE < 9 (which doesn't support ranges), you can use a similar approach with the text property of TextRange.

Note that this will have issues when there are leading and/or trailing line breaks in the content because the toString() method of a range works just like the textContent property of a node and only considers text nodes, therefore not taking into account line breaks implied by <br> or block elements. Also CSS is not taken into account: for example, text inside elements that are hidden via display: none is included.

Here's an example:

Live demo: http://jsfiddle.net/YA3Pu/1/

Code:

function getSelectionTextInfo(el) {
    var atStart = false, atEnd = false;
    var selRange, testRange;
    if (window.getSelection) {
        var sel = window.getSelection();
        if (sel.rangeCount) {
            selRange = sel.getRangeAt(0);
            testRange = selRange.cloneRange();

            testRange.selectNodeContents(el);
            testRange.setEnd(selRange.startContainer, selRange.startOffset);
            atStart = (testRange.toString() == "");

            testRange.selectNodeContents(el);
            testRange.setStart(selRange.endContainer, selRange.endOffset);
            atEnd = (testRange.toString() == "");
        }
    } else if (document.selection && document.selection.type != "Control") {
        selRange = document.selection.createRange();
        testRange = selRange.duplicate();

        testRange.moveToElementText(el);
        testRange.setEndPoint("EndToStart", selRange);
        atStart = (testRange.text == "");

        testRange.moveToElementText(el);
        testRange.setEndPoint("StartToEnd", selRange);
        atEnd = (testRange.text == "");
    }

    return { atStart: atStart, atEnd: atEnd };
}
Answer

This is how I ended up solving this. My proposed solution above worked sometimes but there were way to many edge cases, so I ended up considering how much text was before or after the cursor, and if that was 0 characters, then I was at the start or end:

handle_keydown = function(e) {
  // Get the current cusor position
  range = window.getSelection().getRangeAt(0)
  // Create a new range to deal with text before the cursor
  pre_range = document.createRange();
  // Have this range select the entire contents of the editable div
  pre_range.selectNodeContents(this);
  // Set the end point of this range to the start point of the cursor
  pre_range.setEnd(range.startContainer, range.startOffset);
  // Fetch the contents of this range (text before the cursor)
  this_text = pre_range.cloneContents();
  // If the text's length is 0, we're at the start of the div.
  at_start = this_text.textContent.length === 0;
  // Rinse and repeat for text after the cursor to determine if we're at the end.
  post_range = document.createRange();
  post_range.selectNodeContents(this);
  post_range.setStart(range.endContainer, range.endOffset);
  next_text = post_range.cloneContents();
  at_end = next_text.textContent.length === 0;
}

Still not entirely sure there are any other edge cases, as I'm not entirely sure how to unit test this, since it requires mouse interaction - there's probably a library to deal with this somewhere.

Answer

I figured out this pretty consistent and short method:

function isAtTextEnd() {
    var sel = window.getSelection(),
      offset = sel.focusOffset;
  sel.modify ("move","forward","character");
  if (offset == sel.focusOffset) return true;
  else {
    sel.modify ("move","backward","character");
    return false;
  }
}

The key: try to force move it one character forward - if it actually moved: not at the end (move it one character back), if it didn't - it's at the end (no need to move back, it didn't move).
Implementing for start of text is the opposite, and is "left as an exercise for the reader"...

Cavities:

  • MDN marks modify as "Non-standard", though the compatibility table shows a pretty wide support (tested to work on the latest Chrome and Firefox, according to the table - not supported in Edge).
    I tried using the more supported extend() for it - however, it seems that, weirdly, the extend does work even when at the end of text.

  • If you check if after a user initiates a move of the caret (e.g. in a keyboard or mouse event handler) - you should handle cases where the check forces the caret to move in an unexpected way.

Answer

I've had the same issue today with no clean solution, so I developed the following approach. It uses just Selection - no Range or vendor-specific features. It also takes newlines at the start and end of the content into account.

It works in current Chrome, Firefox, Safari and Opera. Microsoft Edge again is the outlier since text selection itself is partially broken in contenteditable divs when there are newlines at the beginning or end of the content. Unfortunately I haven't found a workaround for that issue yet.

It's also worth noting that the logic is different not just between browsers but also between white-space modes (normal vs. pre*) because the browser will generate different nodes for each while typing.

document.addEventListener("selectionchange", function() {
  updateCaretInfo(document.getElementById('input-normal'))
  updateCaretInfo(document.getElementById('input-pre'))  
});



function updateCaretInfo(input) {

  function isAcceptableNode(node, side) {
    if (node === input) { return true }

    const childProperty = side === 'start' ? 'firstChild' : 'lastChild'
    while (node && node.parentNode && node.parentNode[childProperty] === node) {
      if (node.parentNode === input) {
        return true
      }

      node = node.parentNode
    }

    return false
  }

  function isAcceptableOffset(offset, node, side) {
    if (side === 'start') {
      return offset === 0
    }

    if (node.nodeType === Node.TEXT_NODE) {
      return offset >= node.textContent.replace(/\n$/, '').length
    }
    else {
      return offset >= node.childNodes.length - 1
    }
  }

  function isAcceptableSelection(selection, side) {
    return selection &&
      selection.isCollapsed &&
      isAcceptableNode(selection.anchorNode, side) &&
      isAcceptableOffset(selection.anchorOffset, selection.anchorNode, side)
  }


  const selection = document.getSelection()
  const isAtStart = isAcceptableSelection(selection, 'start')
  const isAtEnd = isAcceptableSelection(selection, 'end')

  document.getElementById('start-' + input.id).innerText = isAtStart ? 'YES' : 'no'
  document.getElementById('end-' + input.id).innerText = isAtEnd ? 'YES' : 'no'
}
body {
  padding: 10px;
}

[id^="input-"] {
  border:        1px solid black;
  display:       inline-block;
  margin-bottom: 10px;
  padding:       5px;
}
<div contenteditable id="input-normal">Move the caret inside here!</div>
(<code>white-space: normal</code>)

<p>
  Caret at start: <span id="start-input-normal">no</span><br>
  Caret at end: <span id="end-input-normal">no</span>
</p>

<hr>

<div contenteditable id="input-pre" style="white-space: pre-wrap">Move the caret inside here!</div>
(<code>white-space: pre-wrap</code>)

<p>
  Caret at start: <span id="start-input-pre">no</span><br>
  Caret at end: <span id="end-input-pre">no</span>
</p>

Answer

Simple solution for checking if cursor/caret is at the end of an input:

this.$('input').addEventListener('keydown', (e) => {
  if (e.target.selectionEnd == e.target.value.length) {
    // DO SOMETHING
  }
})

Tags

Recent Questions

Top Questions

Home Tags Terms of Service Privacy Policy DMCA Contact Us

©2020 All rights reserved.