React: How to maintain caret position when editing contentEditable div?

ContentEditable is a tricky especially with react as you have to account for a lot of different kind of behaviors. May I suggest you take a look at DraftJS by Facebook.

They took contentEditable and prevented all default behavior and built a nice framework to make a tag editable, they use it for a rich text editor but you can use the same framework without all the bells and whistle to just take control of the content editable.

https://draftjs.org/docs/getting-started


I was able to get this working following solution in https://stackoverflow.com/a/13950376/1730260

Key changes:

  1. Add new component EditCaretPositioning.js with 2 functions: (1) saveSelection to save caret position, and (2) restoreSelection to restore caret position.
  2. Save the caret position in the state of Input component
  3. Call saveSelection() after every Change event
  4. restoreSelection() as a callback after setting the state
  5. Added id to <div> so can reference in restoreSelection() function

EditCaretPositioning.js

const EditCaretPositioning = {}

export default EditCaretPositioning;


if (window.getSelection && document.createRange) {
    //saves caret position(s)
    EditCaretPositioning.saveSelection = function(containerEl) {
        var range = window.getSelection().getRangeAt(0);
        var preSelectionRange = range.cloneRange();
        preSelectionRange.selectNodeContents(containerEl);
        preSelectionRange.setEnd(range.startContainer, range.startOffset);
        var start = preSelectionRange.toString().length;

        return {
            start: start,
            end: start + range.toString().length
        }
    };
    //restores caret position(s)
    EditCaretPositioning.restoreSelection = function(containerEl, savedSel) {
        var charIndex = 0, range = document.createRange();
        range.setStart(containerEl, 0);
        range.collapse(true);
        var nodeStack = [containerEl], node, foundStart = false, stop = false;

        while (!stop && (node = nodeStack.pop())) {
            if (node.nodeType === 3) {
                var nextCharIndex = charIndex + node.length;
                if (!foundStart && savedSel.start >= charIndex && savedSel.start <= nextCharIndex) {
                    range.setStart(node, savedSel.start - charIndex);
                    foundStart = true;
                }
                if (foundStart && savedSel.end >= charIndex && savedSel.end <= nextCharIndex) {
                    range.setEnd(node, savedSel.end - charIndex);
                    stop = true;
                }
                charIndex = nextCharIndex;
            } else {
                var i = node.childNodes.length;
                while (i--) {
                    nodeStack.push(node.childNodes[i]);
                }
            }
        }

        var sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(range);
    }



} else if (document.selection && document.body.createTextRange) {
  //saves caret position(s)
    EditCaretPositioning.saveSelection = function(containerEl) {
        var selectedTextRange = document.selection.createRange();
        var preSelectionTextRange = document.body.createTextRange();
        preSelectionTextRange.moveToElementText(containerEl);
        preSelectionTextRange.setEndPoint("EndToStart", selectedTextRange);
        var start = preSelectionTextRange.text.length;

        return {
            start: start,
            end: start + selectedTextRange.text.length
        }
    };
    //restores caret position(s)
    EditCaretPositioning.restoreSelection = function(containerEl, savedSel) {
        var textRange = document.body.createTextRange();
        textRange.moveToElementText(containerEl);
        textRange.collapse(true);
        textRange.moveEnd("character", savedSel.end);
        textRange.moveStart("character", savedSel.start);
        textRange.select();
    };

}

Updated contentEditable div component:

import CaretPositioning from 'EditCaretPositioning'

class Input extends Component {

  constructor(props) {
    super(props);
    this.state = {
      //newValue input by user
      newValue : undefined,
      //stores positions(s) of caret to handle reload after onChange end
      caretPosition : {
        start : 0,
        end : 0
      }
    }
  }

  //handler during key press / input
  onChangeHandler = event => {
    let targetValue =  event.currentTarget.textContent;
    //save caret position(s), so can restore when component reloads
    let savedCaretPosition = CaretPositioning.saveSelection(event.currentTarget);
    this.setState({
      "newValue": targetValue,
      "caretPosition" : savedCaretPosition
    }, () => {
      //restore caret position(s)
      CaretPositioning.restoreSelection(document.getElementById("editable"), this.state.caretPosition);
    })
  }

  //handler when user opens input form
  onBlurHandler = event => {
    //some code that sends the "newValue" to be saved, and resets state
  }

  render() {
    //determine which value to show in the div
    let showValue;
    //if there is a new value being input by user, show this value
    if (this.state.newValue !== undefined) {
      showValue = this.state.newValue;
    } else {
      //if prop has no value e.g. null or undefined, use "" placeholder
      if (this.props.value) {
        showValue = this.props.value;
      } else {
        showValue = "";
      }
    }

    return (
    <table>
    <tbody>
      <td>
          <div
            id="editable"
            contentEditable="true"
            suppressContentEditableWarning="true"
            onInput={this.onChangeHandler.bind(this)}
            onBlur={this.onBlurHandler}
          >{showValue}
          </div>
      </td>
     </tbody>
     </table>
    )
  }
}

export default Input;