React contenteditable cursor jumps to beginning

Viewed 6466

I am using the module react-simple-contenteditable to enable editing of a fill in the blank worksheet. The reason I must use a content editable element instead of an input element is because I want the text of the problem to wrap. For example, if a problem has one blank, it divides the text into three sections the part before the blank, the blank, and the part after. If I were to represent the outer two as separate divs (or input fields), then the text would not wrap like a paragraph. Instead, I must have a single contenteditable div that contains an input field for the blank and free text on either side.

The text is wrapping like I want it, but when I type text in the contenteditable field, the cursor jumps to the beginning. I don't understand why because I tried the example on the module's github site and it works perfectly, and although my implementation is a bit more complicated, it works essentially the same.

Here is my render function that uses <ContentEditable /> :

render() {
    const textPieces =
      <div className='new-form-text-pieces'>
        {
          this.props.problem.textPieces.map( (textPiece, idx) => {
            if (textPiece.blank) {
              return (
                  <div data-blank={true} className='blank' key={ textPiece.id } style={{display: 'inline'}}>
                    <input
                      placeholder="Answer blank"
                      className='new-form-answer-input'
                      value={ this.props.problem.textPieces[idx].text }
                      onChange={ (event) => this.props.handleTextPiecesInput(this.props.problemIdx, idx, event.target.value) }
                    />
                    <button className='modify-blank remove-blank' onClick={ (event) => this.props.removeBlank(this.props.problemIdx, idx) }>-</button>

                  </div>
              );
            } else {
              let text = this.props.problem.textPieces[idx].text;
              const placeholder = idx === 0 ? 'Problem text' : '...continue text';
              // text = text === '' ? placeholder : text;
              if (text === '') {
                text = <span style={{color:'gray'}}>{placeholder}</span>;
              } else {

              }
              return (
                this.props.isTextSplit ?
                  <TextPiece
                    key={ textPiece.id }
                    problemIdx={this.props.problemIdx}
                    textPieceIdx={idx}
                    dropBlank={this.props.dropBlank}
                    moveBlank={this.props.moveBlank}
                  >
                    <div style={{display: 'inline-block', }}>{text}</div>
                  </TextPiece>
                : text

              );
            }
          })
        }
      </div>;



    return (
       this.props.isTextSplit ? textPieces :
        <ContentEditable
          html={ReactDOMServer.renderToStaticMarkup(textPieces)}
          className="my-class"
          tagName="div"
          onChange={ (event, value) => this.props.handleProblemChange(event, this.props.problemIdx, value) }
          contentEditable='plaintext-only'
        />
    );

  }

Here is the onChange function:

handleProblemChange(event, problemIdx) {
    const problems = cloneDeep(this.state.problems);
    event.target.children[0].childNodes.forEach( (textPieceNode, idx) => {
      if (textPieceNode.constructor === Text) {
        problems[problemIdx].textPieces[idx].text = textPieceNode.wholeText;
      } else {
        problems[problemIdx].textPieces[idx].text = textPieceNode.childNodes[0].value;
      }
    });
    this.setState({ problems });
  }

And here is the state it refers to, just to make thing clear:

this.state = {
  problems: [
    {
      id: shortid.generate(),
      textPieces: [
        {
          text : "Three days was simply not a(n)",
          blank : false,
          id: shortid.generate(),
        },
        {
          text : "acceptable",
          blank : true,
          id: shortid.generate(),
        },
        {
          text : "amount of time to complete such a lot of work.",
          blank : false,
          id: shortid.generate(),
        }
      ]
    }

Thanks so much

2 Answers

Long story short, there is no easy way to do this. I have tried this myself and spent days trying. Basically you have to save the cursor position and reposition it yourself after the update. All of this can be achieved with window.getSelection()

https://developer.mozilla.org/en-US/docs/Web/API/Window/getSelection

But it can get really tricky depending on how much your content has changed.

I ended up using draftJS instead. Which is an abstraction over contenteditable div by facebook themselves.

https://draftjs.org/docs/overview.html#content

A bit longer to pick up but you will be able to do a lot more

I had a similar problem using VueJS.

Here is the component containing the contenteditable div :

<Text @update-content="updateContent" :current-item-content="item.html_content"/>

Here is the prop definition in Text.vue component :

const props = defineProps({
    currentItemContent: {
        type: String,
        default: ''
    }
})

Here is the contenteditable div in Text.vue component :

<div
    id="text-editor"
    ref="text_editor"
    class="mt-3 h-full w-full break-words"
    contenteditable="true"
    @input="updateContent"
    v-html="currentItemContent"
>

Here is the method triggered on @update-content event

const item = computed(() => { ... })
(...)
function updateContent(content) {
    item.value.html_content = content
}

The problem here is injecting the item.html_content value as a props triggers a re-render of the contenteditable div. Because it's mutating it's value in the updateContent method and as the computed (item) is beeing updated, so does the prop value, v-html detects the updated value and triggers a re-render.

To avoid this, i removed the v-html binding :

<div
    id="text-editor"
    ref="text_editor"
    class="mt-3 h-full w-full break-words"
    contenteditable="true"
    @input="updateContent"
>

And initialized the value of the contenteditable div in a onMounted hook :

const text_editor = ref(null)
(...)
onMounted(() => {
    if (props.currentItemContent !== '') {
        text_editor.value.innerHTML = props.currentItemContent 
    }
})

I don't know if there is a better solution for this but it's working fine for me. Hope this helps someone

Related