Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Accounting for `<br>`s in contenteditable caret position

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.

like image 415
Mottie Avatar asked Sep 26 '17 19:09

Mottie


1 Answers

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>
like image 169
glennsl Avatar answered Oct 20 '22 09:10

glennsl