DioxusLabs / dioxus

Fullstack app framework for web, desktop, mobile, and more.
https://dioxuslabs.com
Apache License 2.0
20.69k stars 798 forks source link

State management with dynamic rendering inside contenteditable divs #1962

Open bishoyroufael opened 7 months ago

bishoyroufael commented 7 months ago

State isn't synced properly when user deletes children nodes in contentetitable div

the user can delete elements by using the backspace inside contentetitable elements. Video below.

I also have a question regarding the event listeners of the child elements. Why they aren't triggered at all? Only the parent element event listeners are called.

Steps To Reproduce

Code:


#![allow(non_snake_case)]
// import the prelude to get access to the `rsx!` macro and the `Scope` and `Element` types
use dioxus::prelude::*;
use dioxus_desktop::{tao::event_loop::EventLoop, use_window, LogicalSize, WindowBuilder};
use uuid::Uuid;

fn main() {
    // launch the dioxus app in a webview
    dioxus_desktop::launch_cfg(
        App,
        dioxus_desktop::Config::new()
            .with_custom_head(
                r#"
            <head> 
                <link rel="stylesheet" href="public/tailwind.css">
            </head>
            "#
                .to_string(),
            )
            .with_window(
                WindowBuilder::new()
                    .with_title("Desktop App")
                    .with_inner_size(LogicalSize {
                        width: 400.0,
                        height: 400.0,
                    })
                    .with_min_inner_size(LogicalSize {
                        width: 400.0,
                        height: 400.0,
                    })
                    .with_transparent(true),
            ),
    )
}

enum Blocks {
    ParagraphBlock(Uuid)
}

pub fn Paragraph(cx: Scope) -> Element {
    let inner_text = use_state(cx, || "".to_string());

    cx.render(rsx! {
        div {
            class: "border-solid border-2 border-indigo-300 p-4 rounded-lg some-block",
            onkeydown: move |e| {println!("Inner State (onkeydown) {:?}", e.key().to_string());},
            oninput : move |e| {println!("Inner State (oninput) {:?}", e.data.value.clone()); inner_text.set(e.data.value.clone())},
            p{
                class: "text-xs font-semibold text-gray-600 uppercase some-block-header",
                "contenteditable": "false",
                "Paragraph"
            },
            p {
                class: "text-base text-gray-800 empty:before:content-[attr(data-placeholder)] empty:before:text-gray-300 whitespace-pre-wrap selectable-area",
                "data-placeholder" : "Write text here ..",
                inner_text.as_str()

            }

        }
    }
)
}

fn App(cx: Scope) -> Element {
    let window = use_window(&cx);

    let some_blocks = use_ref(cx, Vec::<Blocks>::new);
    if some_blocks.read().is_empty() {
        some_blocks
            .write()
            .push(Blocks::ParagraphBlock(Uuid::new_v4()));

        some_blocks
            .write()
            .push(Blocks::ParagraphBlock(Uuid::new_v4()));

        some_blocks
            .write()
            .push(Blocks::ParagraphBlock(Uuid::new_v4()));
    }

    let binding = some_blocks.read();
    let blocks_rendered = binding
        .iter()
        .map(|block| match block {
            Blocks::ParagraphBlock(id) => rsx! {Paragraph{key: "{id}"}},
        });

    cx.render(rsx! {
         div {
            class: "h-screen w-screen flex bg-pink-100 p-4 rounded-lg flex-col space-y-4 overflow-auto",
            "contenteditable" : "true",
            onkeydown: move |_e| { println!("Number of blocks: {:?} ", some_blocks.read().len()) },
            onmousedown: move |_| {window.drag()},
            blocks_rendered
         }
    })
}

Expected behavior

State should be maintained and synced

Screenshots

dioxus-bug

Environment:

Questionnaire

jkelleyrtp commented 7 months ago

Oh, this is a bit of a tough one due to how we manage the link between the virutaldom and the dom itself.

I'm not sure if we can even support this - it looks like react and solid don't support it out of the box (though svelte and vue do).

https://github.com/solidjs/solid-docs/issues/128

There is definitely a way to build an abstraction around this (handle deletes and propagate it back up) but something we probably won't support soon.

Can you just make the blocks themselves editable?

bishoyroufael commented 7 months ago

There are a bunch of workarounds that can work currently with reliance being shifted towards JS. The thing is, I am not sure what would be the best practice since my project relies heavily on this concept of having nested components in contenteditable areas. I am trying to figure out what should be handled on the Dioxus side and what could be shifted to the JS side. Do you have an idea why the event listeners inside the components aren't triggered as well?

The current options are as follows:

I am still unsure what's the correct road to take.

jkelleyrtp commented 7 months ago

There are a bunch of workarounds that can work currently with reliance being shifted towards JS. The thing is, I am not sure what would be the best practice since my project relies heavily on this concept of having nested components in contenteditable areas. I am trying to figure out what should be handled on the Dioxus side and what could be shifted to the JS side. Do you have an idea why the event listeners inside the components aren't triggered as well?

The current options are as follows:

  • [painful] Completely prevent all the keyboard event listeners defaults (onkeydown , onkeyup) and write logic for taking the input from the user and handle the logic. This ensures that the dom is always controlled from Dioxus side.
  • Write JS code to prevent specifically the behavior of deleting elements (i.e checking backspace, delete keys). This leaves some state handled on the Dioxus side regarding the number of elements and their ordering.
  • Just use Dioxus as an interface to send components to the dom without maintaining/caring about any state on the Dioxus side. This means that Dioxus will simply throw plain JS to the dom and that's it.

I am still unsure what's the correct road to take.

It seems like events don't work as you would expect with contenteditable. It could be an issue on the dioxus side but I think it might just be that contenteditable is a bit of a weirdo.

https://stackoverflow.com/questions/1391278/contenteditable-change-events

That being said, contenteditable is basically an island where one-way-data-flow is not reliable, so you can't try to drive it using one-way-data-flow. You'll need to capture inputs (using mutation observer) and then modify your idea of the data.

FWIW, notion, which seems somewhat similar to your app, uses contenteditable only for text inputs - it captures enters/deletes using regular divs + event listeners likely to solve the issues you're running into.

conorbergin commented 6 months ago

An approach which I have used several time to build toy editors is to prevent default on the beforeinput event on the root contenteditable element , and update the state manually, and let the framework handle the rendering, I believe this is what Slate does with React.

Performance is fine, the limiting factor is the text data structure, an array of strings will get you pretty far.

This has the advantage of allowing you to put all your application logic in Rust, the only ugly bit is I have to use the selection api to find the cursor location, and map that to a location in the document state, I've done this by storing the offset in a html attr, I think Slate uses hashmaps.

Another advantage of this is incredibly flexible for rich text, you really can render anything, as long as you provide caret locations, for rendering markdown tables in a css grid to keep the columns aligned.

What might be cool would be to implement some sort of wrapper over the modern contenteditable spec, which is very nice for writing text editors, the events are all things like "insertText", "deleteContentBackwards" and "insertFromPaste". This could probably be ported to non-browser platforms.

Another caveat is not all browser platforms respect beforeinput events, the android mobile browser does its own weird thing with composition events and the Slate devs went to a lot of trouble to make it work there.