I'm trying to set up a controlled contentEditable
in React. Every time i write something in the div the ponent 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 { monAncestorContainer: #text, startContainer: #text, startOffset: 1, endContainer: #text
, endOffset: 1, collapsed: true }
on rerender:
Range { monAncestorContainer: #text, startContainer: #text, startOffset: 1, endContainer: #text
, endOffset: 1, collapsed: true }
in layout effect
Range { monAncestorContainer: 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?
I'm trying to set up a controlled contentEditable
in React. Every time i write something in the div the ponent 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 { monAncestorContainer: #text, startContainer: #text, startOffset: 1, endContainer: #text
, endOffset: 1, collapsed: true }
on rerender:
Range { monAncestorContainer: #text, startContainer: #text, startOffset: 1, endContainer: #text
, endOffset: 1, collapsed: true }
in layout effect
Range { monAncestorContainer: 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?
2 Answers
Reset to default 6 +50When 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
Ok, I'm not familiar with the range operation, but seems to me the problem lies in the state change.
You can use useRef
or useState
to fix this, let me use an object with useState
for now.
function App() {
const [HTML, setHTML] = useState()
const [selectionRange, setSelectionRange] = useState({ range: null })
useLayoutEffect(() => {
const selection = document.getSelection()
if (selectionRange !== undefined) {
selection.removeAllRanges()
if (selectionRange.range)
selection.addRange(selectionRange.range)
}
})
function inputHandler(ev) {
selectionRange.range = document.getSelection().getRangeAt(0).cloneRange())
setSelectionRange({ ...selectionRange })
setHTML(ev.target.innerHTML)
}
You can easily replace this version with a useRef
, the point is to make sure the value is assigned right away before going through the setState
which takes time to get your state updated to the latest value.