Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

Currently

  1. I have a react component that stores a newValue when the user clicks on a contentEditable <div>, and updates newValue as the user types. Note: there are 2 main reasons why I have set up this behavior this way: (1) I do not want to send the data to be saved on every key stroke, and (2) I plan to use a variation of this div where each input is checked to verify whether the input is a number.
  2. The newValue is sent to be saved when the <div> loses focus, and then the state of the prop is reset.

Problem

The onChangeHandler is moving the position of the caret within the editable div to the left hand side. This results in the key strokes 123456 appearing as 654321

Code:

class Input extends Component {

  constructor(props) {
    super(props);
    this.state = {
      //newValue input by user
      newValue : undefined
    }
  }

  //handler during key press / input
  onChangeHandler = event => {
    let targetValue =  event.currentTarget.textContent;
    this.setState({"newValue": targetValue})
  }

  //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
            contentEditable="true"
            suppressContentEditableWarning="true"
            onInput={this.onChangeHandler.bind(this)}
            onBlur={this.onBlurHandler}
          >{showValue}
          </div>
      </td>
     </tbody>
     </table>
    )
  }
}

export default Input;

Notes

  1. I previously was doing this with a <textarea> which did not have this issue, but switched to <div> for more control over the auto-adjusting div height behavior (ref CSS: Remove scroll bar and replace with variable height for textarea in a table <td>)
  2. I have been able to find numerous related answers, but none that are specific to react e.g. Maintain cursor position in contenteditable div. I assume because react is reloading the component after each stroke, this issue is occurring.
  3. I previously had no ChangeHandler onInput, and this was working fine, but I was unable to log each key press and validate whether the character was a number.
like image 717
Wronski Avatar asked Apr 27 '19 14:04

Wronski


1 Answers

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;
like image 104
Wronski Avatar answered Sep 19 '22 16:09

Wronski