Rich text editor using contenteditable in Elm

I’m creating a rich text editor using contenteditable with Elm.

You can see the initial prototype here, although it still has a lot of bugs, especially in Safari and with selection updates after style changes. It also doesn’t have a lot of features implemented yet (for example: undo/redo, list items, drag/drop, good mobile support)

My goal for this project is to create a set of tools for creating custom rich text editors in Elm, similar to projects like DraftJS, Slate, and ProseMirror. I think current pattern in Elm when you want a rich text editor is to use an existing javascript project via a web component, which I think is okay, but is sort of lacking when you need to customize the editor’s behavior.

The inspiration for this project came when I created a hobby language exchange site (en.modole.io) a few years ago. Initially I wanted to write it in Elm, but when I needed to create a custom rich text editor (for correcting people’s grammar), the choices in Elm were non existent. I ended up switching to React and DraftJS at the time. I came back recently to Elm to see if this had been implemented yet, and it seems like it still hasn’t. So, I decided it would be a good open source project to start, and here we are.

I’m running into some problems with selection and dom state, and most of them revolve around these two issues:

  1. The first is that when the Elm VirtualDOM gets out of sync with the real DOM, it throws a lot of exceptions and doesn’t find a way to resolve itself (at least when debugging). This is very common when you use contenteditable; there’s a lot of times where the browser will randomly insert a text node or element that wasn’t there before, which often triggers the Elm VirtualDOM getting out of sync with the real dom state. This in turn makes it impossible for the VirtualDOM to rerender itself if there’s an error. The way I resolve this right now is by using a mutation observer and aggresively remove nodes that don’t match the specific format that I expect, but this is very very hacky. I’m wondering if there’s a better way to resolve this, or if there’s a way to make it so the VirtualDOM is better at handling errors (or even just detecting errors so I can force a complete rerender with a keyed node) when it goes out of sync with the real DOM for whatever reason.

  2. The other issue I’m running into is that a lot of events need to have preventDefault called depending on logic that relies on the DOM selection API and the clipboard API. This logic is very dependent on being syncronous with the event, either because of permission issues (e.g. clipboard API) or because of concurrency issues with other events being called (e.g. beforeinput, keypress, input). However, it’s hard to have any synchronous two-way communication between Elm and javascript code. I think this was done previously with NativeCode, but was taken away in 0.19. Is there some sort of replacement for this?

Anyway, I appreciate all the work that’s been done to get Elm where it is today. I’m sorry if any of this is unclear, please let me know and I’ll be glad to try to clarify anything. My next steps are to more carefully study ProseMirror and Slate to try and fix the large amount selection and input issues that I’m discovering with the current implementation. I’d appreciate any feedback people have, thanks!

6 Likes

Hey, great progress here! I’ve tried this before myself and have been pretty frustrated at the same issues you list. My approach looked like this:

First, render something like this in Elm:

editor : Html msg -> Html (EditorMsg msg)
editor content =
    node "my-custom-element"
        []
        [ div [ style "display" "none" ] [ content ]
        , div [ attribute "contenteditable" "true" ] [ ]
        ]

Then I set up two mutation observers, one each on the rendered-but-invisible content and the contenteditable. Whenever they changed I would try as hard as I could to keep them in sync by firing custom messages (EditorMsg included things like “the content changed according to this diff”.)

That’s pretty much where I stopped, though—I foresaw the event issues and did not get far enough along to solve them! I hope you have better luck.

Other libraries you might look at for inspiration: Quill and Trix

Ah, that’s an interesting approach, thank you for sharing that with me. I’ll keep it in mind, since I think I’ll have to switch from use beforeinput/input events to something that relies on mutation observers. After testing out editors on different platforms, I’ve found that only ProseMirror, which I believe relies heavily on mutation observers, works on Android. I believe this is because Android does not fire input events correctly from the software keyboard, so projects that rely on beforeinput/input events like DraftJS and SlateJS are really broken. I’ll also check out Quill and Trix to see if they do anything differently.

You may find this article about the medium editor interesting (2014) https://medium.engineering/why-contenteditable-is-terrible-122d8a40e480. They talk about the problems they experienced building an editor using contenteditable, why they moved away from it and a little about the solution they arrived at.

3 Likes

I spent a lot of time with Quill. It demos well, but has a bunch of global leakages which caused problems for me. I had great success working with Trix and that team behind it seemed to get a lot of things right.

https://trix-editor.org/

Thank you for embarking on this journey. It’d be great to have an elm native RTE without a bunch of side effects.

5 Likes

I did something similar and ended up giving up on contenteditable. It just doesn’t play well with virtualdoms.

This isn’t necessarily a suggestion but a request for advice—what’s wrong with using ports to access the browser’s built-in RTE functions, and leaving the editable div alone after creating it? The code is dramatically simpler, and one can keep track of the resulting innerHtml for whatever purpose it is needed later.

https://dkodaj.github.io/simplerte

@dkodaj Thank you for writing that! I think just setting the HTML and letting the browser handle it is great in a few ways. The biggest one in my eyes is that it side steps issues with the virtual DOM by making it always kept up to date. Also taking advantage of the built in document.execCommand behavior should mean less bugs.

I think the reason why most RTE frameworks don’t use that approach (at least the ones I mentioned above) is consistency between browsers (edit: an example after reading the execCommand documentation I think is in Safari, the header command may not work in your example in Safari because formatBlock with heading is unsupported in that browser?) and the ability to define custom behavior outside of what execCommand lets you do, like updating inline elements to whatever you’d like. This somewhat old stack overflow answer summarizes why CKEditor doesn’t use it: https://stackoverflow.com/questions/12158503/definition-of-execcommand-function-for-bold/12166267#12166267 But actually I haven’t worked out this approach, and perhaps there are ways around these issues while still using execCommand.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.