最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

javascript - Dealing with cursor with controlled contenteditable in React - Stack Overflow

programmeradmin2浏览0评论

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?

Share Improve this question asked Sep 19, 2021 at 12:59 snowapesnowape 1,40413 silver badges26 bronze badges
Add a ment  | 

2 Answers 2

Reset to default 6 +50

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

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.

发布评论

评论列表(0)

  1. 暂无评论