Accounting for `<br>`s in contenteditable caret position
Asked Answered
H

1

15

To get and set the caret position in an contenteditable element, I've tried the code from this answer, but the start & end position resets as you move into different text nodes.

<div contenteditable>012345<br><br><br>9012345</div>

So, I modified the code from this answer (by @TimDown) but it's still not quite counting the line breaks properly... In this demo, when I click after the 4 and press the right arrow three times, I'll see the start/end report as 5, 6, then 8. Or, use the mouse to select from the 4 in the first row and continuing selecting to the right (see gif)

selection in contenteditable

Here is the code (demo; even though it looks like it, jQuery is not being used)

function getCaret(el) {
  let start, end;
  const range = document.getSelection().getRangeAt(0),
    preSelectionRange = range.cloneRange(),
    postSelectionRange = range.cloneRange();
  preSelectionRange.selectNodeContents(el);
  preSelectionRange.setEnd(range.startContainer, range.startOffset);
  postSelectionRange.selectNodeContents(el);
  postSelectionRange.setEnd(range.endContainer, range.endOffset);
  start = preSelectionRange.toString().length;
  end = start + range.toString().length;
  // count <br>'s and adjust start & end
  if (start > 0) {
    var node,
      i = el.children.length;
    while (i--) {
      node = el.children[i];
      if (node.nodeType === 1 && node.nodeName === 'BR') {
        start += preSelectionRange.intersectsNode(el.children[i]) ? 1 : 0;
        end += postSelectionRange.intersectsNode(el.children[i]) ? 1 : 0;
      }
    }
  }
  return {start, end};
}

The setCaret function modification appears to be working properly (in this basic contenteditable example).

function setCaret(el, start, end) {
  var node, i, nextCharIndex, sel,
    charIndex = 0,
    nodeStack = [el],
    foundStart = false,
    stop = false,
    range = document.createRange();
  range.setStart(el, 0);
  range.collapse(true);
  while (!stop && (node = nodeStack.pop())) {
    // BR's aren't counted, so we need to increase the index when one
    // is encountered 
    if (node.nodeType === 1 && node.nodeName === 'BR') {
      charIndex++;
    } else if (node.nodeType === 3) {
      nextCharIndex = charIndex + node.length;
      if (!foundStart && start >= charIndex && start <= nextCharIndex) {
        range.setStart(node, start - charIndex);
        foundStart = true;
      }
      if (foundStart && end >= charIndex && end <= nextCharIndex) {
        range.setEnd(node, end - charIndex);
        stop = true;
      }
      charIndex = nextCharIndex;
    } else {
      i = node.childNodes.length;
      while (i--) {
        nodeStack.push(node.childNodes[i]);
      }
    }
  }
  sel = document.getSelection();
  sel.removeAllRanges();
  sel.addRange(range);
}

I could use some advice/help with the following issues:

  • How do I properly count the <br>s?
  • How do you count a <br> at the beginning (in this HTML example)?

    <div contenteditable><br>12345<br><br><br>9012345</div>
    
  • Include <br>'s wrapped in a <div> (in this HTML example) - I'll eventually get to this, but I didn't want to continue down this path and find out there is an easier method.

    <div contenteditable><div><br></div>12345<div><br></div><div><br></div><div><br></div>9012345</div>
    
  • I tried to replace the above code with rangy, but it doesn't appear to have a built-in method to get or set a range.

Hebbel answered 26/9, 2017 at 19:56 Comment(4)
It seems like the core of the issue is that you're trying to use a simple integer offset for cursor position. HTML is a complex nested structure. If you want an exact position, you need more data than that. You need need an offset from a specific parent element. Have you considered changing how you represent selection location to use a parent-element+offset pair for each location?Deceitful
Would you please elaborate. Both functions walk through the DOM tree to determine the offset, I'm not sure what you're describing... I'm still trying to figure out how to determine the carets position within a text node.Hebbel
@Deceitful Would you mind if I run with your comment and fill it out into an answer? I think the heart of this is as you say: OP is referring to two text nodes with <br> elements between as a single text node, which I would call a category error.Oaken
@Oaken Go for it, no problem.Deceitful
P
5

I modified your demo to serialize the position as a container/offset pair instead of just a position. The container is serialized as a simple array of indexes into the childNodes collection of each node starting from a reference node (which in this case is the contenteditable element, of course).

It's not completely clear to me what you intend to use this for, but since it mirrors the selection model it should hopefully give you much less pain.

const $el = $('ce'),
  $startContainer = $('start-container'),
  $startOffset = $('start-offset'),
  $endContainer = $('end-container'),
  $endOffset = $('end-offset');
  
function pathFromNode(node, reference) {
  function traverse(node, acc) {
    if (node === reference) {
      return acc;
    } else {
      const parent = node.parentNode;
      const index = [...parent.childNodes].indexOf(node);
      return traverse(parent, [index, ...acc]);
    }
  }
  return traverse(node, []);
}

function nodeFromPath(path, reference) {
  if (path.length === 0) {
    return reference;
  } else {
    const [index, ...rest] = path;
    const next = reference.childNodes[index];
    return nodeFromPath(rest, next);
  }
}

function getCaret(el) {
  const range = document.getSelection().getRangeAt(0);
  return {
    start: {
      container: pathFromNode(range.startContainer, el),
      offset: range.startOffset
    },
    end: {
      container: pathFromNode(range.endContainer, el),
      offset: range.endOffset
    }
  };
}

function setCaret(el, start, end) {
  const range = document.createRange();
  range.setStart(nodeFromPath(start.container, el), start.offset);
  range.setEnd(nodeFromPath(end.container, el), end.offset);
  sel = document.getSelection();
  sel.removeAllRanges();
  sel.addRange(range);
}

function update() {
  const pos = getCaret($el);
  $startContainer.value = JSON.stringify(pos.start.container);
  $startOffset.value = pos.start.offset;
  $endContainer.value = JSON.stringify(pos.end.container);
  $endOffset.value = pos.end.offset;
}

$el.addEventListener('keyup', update);
$el.addEventListener('click', update);

$('set').addEventListener('click', () => {
  const start = {
    container: JSON.parse($startContainer.value),
    offset: $startOffset.value
  };
  const end = {
    container: JSON.parse($endContainer.value),
    offset: $endOffset.value
  };
  setCaret($el, start, end);
});

function $(sel) {
  return document.getElementById(sel);
}
input {
  width: 40px;
}

[contenteditable] {
  white-space: pre;
}
(updates on click &amp; keyup)<br/>
<label>Start: <input id="start-container" type="text"/><input id="start-offset" type="number"/></label><br/>
<label>End: <input id="end-container" type="text"/><input id="end-offset" type="number"/></label><br/>
<button id="set">Set</button>
<p></p>
<!-- inline BR's behave differently from <br> on their own separate line
<div id="ce" contenteditable>012345<br><br><br>9012345</div>
-->

<!-- get/set caret needs to work with these examples as well
* <br> at beginning
  <div id="ce" contenteditable><br>12345<br><br><br>9012345</div>
* <br>'s wrapped in a <div>
-->
  <div id="ce" contenteditable><div><br></div>12345<div><br></div><div><br></div><div><br></div>9012345</div>
Poesy answered 5/10, 2017 at 20:53 Comment(3)
Thanks! It appears to work really well when <br> are wrapped with <div>s, but not with inline <br>s; but I think I can live with that.Hebbel
And in case you were wondering, I need this code to get my virtual keyboard project to work properly with contenteditable elements.Hebbel
The setCaret Method doesn't works when call it from a br or blank <p> (without text). The other cases works like a charm!! Can you fix it, you can save my life ;)Vincenz

© 2022 - 2024 — McMap. All rights reserved.