Dealing with cursor with controlled contenteditable in React
Asked Answered
H

2

8

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?

Hypercriticism answered 19/9, 2021 at 12:59 Comment(0)
H
4

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
Hypercriticism answered 19/9, 2021 at 16:36 Comment(2)
Hi, how to do it if the div will have multiple childs?Fixer
@Hypercriticism this brings error when there's a line break, do you know how to set range with list of NodesTumbledown
M
2

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.

Monteverdi answered 19/9, 2021 at 13:12 Comment(6)
I tried your idea to save the range immediately before setting it with setSelectionRange. Now the selection gets reset to zero already at the beginning of render "on rerender", and cursor is still stuck. Thanks for trying.Hypercriticism
let me ask you something. What are you trying to do? Are you trying to make sure the cursor follows the typing, so this is a typing software on screen, something like that? just curious.Monteverdi
yeah, that's exactly what I'm trying to doHypercriticism
I experiment with your code, here's the codepen codepen.io/windmaomao/pen/wvemxOG?editors=1111. If i remove dangerouslySetInnerHTML, it works. But of course i still don't know what you expect, because with your code you can't type in HTML in the div.Monteverdi
The idea is to have a controlled contentEditable div the same way you might have a controlled textarea. Changing the html in the div can be done by changing the HTML state variable. This way you can intercept the user input and clean up the html.Hypercriticism
i see. in this case, i think you have the onInput, and you don't need to set the state back. Instead, whenever you need to access the html or content, just use the html you received. This way you don't need to deal with the internal of this input, treating it as a black box. Let me know if there's any task you can't do with this approach.Monteverdi

© 2022 - 2024 — McMap. All rights reserved.