I’m trying to set up a controlled contentEditable
in React. Every time i write something in the div the component re-renders, and the cursor/caret jumps back to the beginning. I’m trying to deal with this by saving the cursor in an onInput
callback:
import { useState, useEffect, useRef, useLayoutEffect } from 'react' function App() { const [HTML, setHTML] = useState() const [selectionRange, setSelectionRange] = useState() console.log('on rerender:', selectionRange) useLayoutEffect(() => { console.log('in layout effect', selectionRange) const selection = document.getSelection() if (selectionRange !== undefined) { selection.removeAllRanges() selection.addRange(selectionRange) } }) function inputHandler(ev) { console.log('on input', document.getSelection().getRangeAt(0)) setSelectionRange(document.getSelection().getRangeAt(0).cloneRange()) setHTML(ev.target.innerHTML) } return ( <> <div contentEditable suppressContentEditableWarning onInput={inputHandler} dangerouslySetInnerHTML={{ __html: HTML }} > </div> <div>html:{HTML}</div> </> ) } export default App
This doesn’t work, the cursor is still stuck at the beginning. If I input one character in the contentEditable
div, i get the output:
on input Range { commonAncestorContainer: #text, startContainer: #text, startOffset: 1, endContainer: #text , endOffset: 1, collapsed: true } on rerender: Range { commonAncestorContainer: #text, startContainer: #text, startOffset: 1, endContainer: #text , endOffset: 1, collapsed: true } in layout effect Range { commonAncestorContainer: div, startContainer: div, startOffset: 0, endContainer: div, endOffset: 0, collapsed: true }
Why does the value of selectionRange
change in the useLayoutEffect
callback, when it was correct at the start of the re-render?
Advertisement
Answer
When the contentEditable
div is re-rendered it disappears. The Range
object contains references to the children of this div (startNode
, endNode
properties), and when the div disappears the Range
object tracks this , and resets itself to it’s parent, with zero offset.
The code below demonstrates how to deal with this if you now that the contentEditable
div will only have one child. It fixes the problem where the cursor gets stuck at the beginning. What we do is to save the offset in the text, and when restoring we create a new Range
object, with the newly rendered text node as startNode
and our saved offset as startOffset
.
import { useState, useEffect, useRef, useLayoutEffect } from 'react' function App() { const [HTML, setHTML] = useState() const [offset, setOffset] = useState() const textRef = useRef() useLayoutEffect(() => { if (offset !== undefined) { const newRange = document.createRange() newRange.setStart(textRef.current.childNodes[0], offset) const selection = document.getSelection() selection.removeAllRanges() selection.addRange(newRange) } }) function inputHandler(ev) { const range = document.getSelection().getRangeAt(0) setOffset(range.startOffset) setHTML(ev.target.innerHTML) } return ( <> <div contentEditable suppressContentEditableWarning onInput={inputHandler} dangerouslySetInnerHTML={{ __html: HTML }} ref={textRef} > </div> <div>html:{HTML}</div> </> ) } export default App