Remove contenteditable completely?

As mentioned in the docs, using contenteditable for rendering/cursor tracking reduces complexity by avoiding the need to implement layout. However, this means ProseMirror is not aware of where things are rendered on the screen.

For most use cases, this is not a problem. However, this makes implementing certain features that rely on knowing where things are rendered more difficult. From what I’ve seen, commercial close-sourced word processors such as Google Docs and MS Word Online handle rendering/cursor tracking instead of delegating to contenteditable.

Some example use cases:

  • Break up document to fixed-size pages - can’t do this without knowing the height of each line being rendered
  • Multiple cursor placement in collaborative editing - the browser can only render one cursor, to render multiple cursors we need to be able to project document position to screen coordinates, expensive to do without having rendered layout information cached
  • More sophisticated marks such as inline highlighting and commenting - as far as I know, marks in ProseMirror are rendered by wrapping inline text with spans and decorating the wrapper spans, if you want to render more complex structures, such as a popover comment box centered/above the highlighted text, you’d have to render the highlight as a span, and then determine the wrapper span bounding box position after the mark is rendered and append the popover comment box, which I think is a workaround to the system

I have been exploring the possibility of handling layout/rendering instead of delegating to contenteditable. It seems this involves breaking up the document into boxes that cannot be broken up further and laying them out as lines with the document’s size constraints, before finally rendering them. I don’t think this can fit with the current APIs of ProseMirror, since the concept of box and layout are missing and model toDOM simplify returns DOM specifications with no layout information.

Is there any recommendation for bridging the gap here? I’d love to use ProseMirror for its beautiful model and state implementation, but I’d also like to have more control over the view layer.

I really appreciate all the work put into ProseMirror, it’s a complicated but useful problem to solve and it’s a very generous act to open source it!

1 Like

You can do this, but it’d be a different project with different issues. It’s definitely not something that ProseMirror will ever do.

I was hoping there might be a way to implement a custom view layer that deals with layout. But if not, I’ll take a stab at implementing this as a different project.

I’ve been experimenting with this, now I can better appreciate why you’re using contenteditable, it does greatly simplify capture of inputs, especially for mobile. My requirement is to be aware of where things are rendered, so that there is more flexibility with fixing the page height and rendering complex UI overlays on top of the document. I no longer think it conflicts with adopting contenteditable.

I’m starting to experiment with determining the document layout (words + lines + pages) myself but have them inside a contenteditable, and expose an API that allows convenient / efficient conversion of document position to screen coordinates (and vice-versa). Any thoughts on this approach? I think this is closer to prosemirror’s implementation, do you think this can somehow fit with the library?

I’m curious how you’re planning to do the positioning, and how you’ll address the potential disruptive effect on the editing experience this may have. You might be able to get quite far with decorations adding CSS (or even break nodes) to specific elements in the document.

I don’t expect the core library will be doing things like this, but if you find a promising approach and run into problems caused by the library, we can certainly discuss them.

All the editing apps need to use contenteditable to some extent because certain text editing features (IME, etc.) don’t really work without it. It is true that some hide contenteditable a lot more than others, but as of today, one cannot live completely without it. There have been some early proposals by some of the major vendors to add more basic level support for editing in browsers over the last few years, but so far it has all been at an early draft stage and it did not develop beyond that.

I’m not sure how the editing experience will be yet. I’ve been experimenting in a new project without prosemirror as a dependency.

So far, the same model implementation seems to work - I’m using the same flat token model for applying transformations, and I’m constructing a tree model from the tokens for convenient access to nodes.

When it comes to the view layer, things start to diverge. I’m introducing the concept of “words” - a range of tokens that cannot be split for line wrapping. A list of “words” is constructed for each block-level node, and each word may reference multiple inline nodes (if, say, the first half of a word is bolded and the second half is not). The screen size of each word is then computed. Knowing the width of each word and the width of the document, I group them into lines.

I’m also playing around with adding “pages” to the document. With lines built from words, I then group lines into pages, based on the height of each line and the height of each page.

I haven’t looked into performance optimization yet, but the screen size of each word is cached. With this information, I’m able to map document position to screen coordinates and vice-versa without repeatedly calling getBoundingClientRect.

As for cursor handling, I’m still experimenting, but it seems it’s better to let the browser capture cursor-related events and we can map it to changes to the cursor state. And for rendering the cursor, I think it’s more useful to do it ourselves, since there is little cross-browser/device concerns. This is assuming there is only one editing cursor that we need to listen to events for, which I think is a fair assumption for WYSIWYG. For collaborative editing, additional observing cursors can be easily rendered the same way as the editing cursor.

Not sure how exactly this can fit with prosemirror yet. The view module would have to be completely replaced. The Node class will probably also have to be modified, since right now it defines to-DOM for the node. I don’t think there is much conflict with the other modules.

Honestly, I wish the specification for content-editable would better handle primitive positioning and selection problems, you wouldn’t believe the amount of CSS & decoration stuff I had to code to get something even-approaching word-style selection behaviour.

In terms of complex UI overlays, inline hilighting/commenting, etc. I thought I’d pipe in, you can quite nicely implement sophisticated overlays with decorations and Vue.js where that’s concerned. I have a special “ReactivePlugin” array in my Vue.js. I can feed my plugins a special callback containing the reactively recorded vue and state.

I’ll try and outline the code (though I decouple a lot so I apologise if it’s difficult to follow).

this is the mount point in the Vue template (the bit where the virtual DOM that maintains Vue’s reactivity is transalted into actual DOM nodes. If you’re not familiar with Vue, for the sake of argument, treat it like $(document).ready() :

mounted () {
    ...
    this.fooPlugin = createFooPlugin(this.actionReactivePlugin)
    this.barPlugin = createBarPlugin(this.actionReactivePlugin)
    this.state = createEditorState(
      this, [this.fooPlugin, this.barPlugin])
    ... 
}

the actionReactivePlugin is a callback containing a callback. It’s pretty rudimentary, it just fires the supplied command from the plugin; It’s really just there to supply the reactively maintained state and view into the sub-menus (I also supply it pos data so that whichever event-handler can use the pos data inside the callback)

actionReactivePlugin (pos, pluginCallback) {
  pluginCallback(this.state, this.view.dispatch)
},

thus when you create the event-handlers in the decorations function of your plugin, it simply needs to fire the callback supplied via the function that instantiated the plugin:

callback(pos, function (state, dispatch) {
          const { tr } = state
          dispatch(
            tr.setNodeMarkup(
              pos,
              undefined,
              {
                class: node.attrs.class,
               ... etc ...
              }))
        })

Obviously, you can switch out Vue and implement reactivity with a different framework (or roll your own, loads of good tutorials for that online)

I hope that was vaguely helpful :laughing::sweat_drops: I can elaborate some more should you need it!