在 React 中处理带有受控 contenteditable 的游标
Dealing with cursor with controlled contenteditable in React
我正在尝试在 React 中设置受控 contentEditable
。每次我在 div 中写东西时,组件都会重新渲染,并且 cursor/caret 会跳回到开头。我试图通过将光标保存在 onInput
回调中来解决这个问题:
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
这个不行,光标还卡在开头。如果我在 contentEditable
div 中输入一个字符,我得到输出:
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 }
为什么 selectionRange
的值在 useLayoutEffect
回调中发生变化,而它在重新渲染开始时是正确的?
好吧,我不熟悉范围操作,但在我看来问题出在状态变化上。
您可以使用 useRef
或 useState
来解决这个问题,让我暂时使用带有 useState
的对象。
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)
}
您可以轻松地用 useRef
替换此版本,重点是确保在通过 setState
之前立即分配值,这需要时间将您的状态更新到最新值。
当 contentEditable
div 为 re-rendered 时,它会消失。 Range
object 包含对此 div 的 children 的引用(startNode
、endNode
属性),并且当 div 消失 Range
object 跟踪此 ,并将自身重置为 parent,偏移量为零。
下面的代码演示了如果现在 contentEditable
div 只有一个 child 如何处理这个问题。它修复了光标卡在开头的问题。我们做的是保存文本中的偏移量,恢复时我们新建一个Range
object,新渲染的文本节点为startNode
,我们保存的偏移量为[=19] =].
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
我正在尝试在 React 中设置受控 contentEditable
。每次我在 div 中写东西时,组件都会重新渲染,并且 cursor/caret 会跳回到开头。我试图通过将光标保存在 onInput
回调中来解决这个问题:
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
这个不行,光标还卡在开头。如果我在 contentEditable
div 中输入一个字符,我得到输出:
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 }
为什么 selectionRange
的值在 useLayoutEffect
回调中发生变化,而它在重新渲染开始时是正确的?
好吧,我不熟悉范围操作,但在我看来问题出在状态变化上。
您可以使用 useRef
或 useState
来解决这个问题,让我暂时使用带有 useState
的对象。
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)
}
您可以轻松地用 useRef
替换此版本,重点是确保在通过 setState
之前立即分配值,这需要时间将您的状态更新到最新值。
当 contentEditable
div 为 re-rendered 时,它会消失。 Range
object 包含对此 div 的 children 的引用(startNode
、endNode
属性),并且当 div 消失 Range
object 跟踪此 ,并将自身重置为 parent,偏移量为零。
下面的代码演示了如果现在 contentEditable
div 只有一个 child 如何处理这个问题。它修复了光标卡在开头的问题。我们做的是保存文本中的偏移量,恢复时我们新建一个Range
object,新渲染的文本节点为startNode
,我们保存的偏移量为[=19] =].
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