在 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 回调中发生变化,而它在重新渲染开始时是正确的?

好吧,我不熟悉范围操作,但在我看来问题出在状态变化上。

您可以使用 useRefuseState 来解决这个问题,让我暂时使用带有 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 的引用(startNodeendNode 属性),并且当 div 消失 Range object 跟踪此 ,并将自身重置为 parent,偏移量为零。

下面的代码演示了如果现在 contentEditable div 只有一个 child 如何处理这个问题。它修复了光标卡在开头的问题。我们做的是保存文本中的偏移量,恢复时我们新建一个Rangeobject,新渲染的文本节点为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