Non-editable text selection in selectable nodes

Description

Hi, we’re building an editor on top of ProseMirror and we have several selectable nodes which don’t contain any editable content but do contain some non-editable text. We want this text to also be selectable so that the user can copy it without having to copy the whole node.

For example, consider a node which has an image with a caption - we want the user to be able to click any part of it to select the whole node. After that, the user should be able to select just the caption and copy it while the node remains selected in the editor. Take a look at the gifs below to get a better idea of what we’re aiming for:

Default behaviour:

default

Expected behaviour:

expected

Implementation

We’re trying to implement the described behaviour for any node which:

  • Is selectable, i.e. has selectable: true in its spec
  • Doesn’t contain content, i.e. has content: "" in its spec
  • Has a node view, created in a plugin
  • Renders some DOM element which contains only plain text

The default behaviour doesn’t let you set the DOM selection to a piece of text within such a node. However, using a combination of nodeView.selectNode, nodeView.stopEvent, and nodeView.ignoreMutation, we’ve managed to get things mostly working, but we’re still encountering some oddities, and aren’t sure if we’re doing things the “correct” way.

We’ve tried 2 approaches - one in which the focus/document.activeElement remains on the editor when the node is selected, and one where it moves to the root DOM element of the node view. I’ve created a minimal sandbox for each approach, which include imageWithText node as an example to show the issues we’re running into:

  1. Focus remains on the editor

The main issue we’re encountering here is that the DOM selection highlights the entire node view, while we only want to show an outline to indicate that the node is selected. We have 2 ways in which we can fix this.

The first is to just check whether the DOM selection contains the node and apply a class name to it, in order to hide the selection via CSS. However, it seems like ProseMirror sets the DOM selection after nodeView.selectNode is called, so we have to use a selectionchange event listener inside the node view to do this.

The second is to move the DOM selection ourselves, to an empty one inside the node. Since it’s empty, copying will return false in nodeView.stopEvent, and so the editor will handle the event and copy the whole block anyway. Because the DOM selection is collapsed, there is no selection that we need to hide. However, we again have to do this in a selectionchange event listener as ProseMirror sets the DOM selection after nodeView.selectNode is called.

In both of these cases, it would be easier & cleaner if we could prevent ProseMirror from setting the DOM selection and handle it ourselves inside nodeView.selectNode. We also tried setting tabIndex to -1 on the node view root element as suggested in this post, though this didn’t seem to fix the issue.

  1. Focus moves to the node view root element

It’s expected that keyboard navigation and other editor key handling isn’t working here since I wanted to keep this sandbox minimal. However, there are two issues that we’re running into with this approach.

First, is that cut/copy events are not always captured by nodeView.stopEvent. If you have the editor focused and the selection in one of the paragraphs, then click the image and hit Cmd+C, nothing happens. However, if you click the text in the imageWithText node and hit Cmd+C, a copy event is captured by nodeView.stopEvent, which you can see logged to the console. After clicking the text and then clicking the image, copying also works fine. So it doesn’t work if you click the image without having clicked the text right before.

Second, is that sometimes when the imageWithText node is selected, clicking on one of the paragraphs focuses the editor but doesn’t reliably move the selection. While this is flaky, you should be able to reproduce this by first selecting the imageWithText node, then clicking a paragraph somewhere to the right of its text content. The selection changes and then immediately changes again. If you clicked on the first paragraph, the selection moves to the imageWithText node and then the paragraph, while the opposite happens when clicking the second paragraph instead.

So overall, it would be great to get some feedback on anything we might be doing wrong with these two approaches, or if there’s anything else wrong with our implementations of them.

NOTE: Since creating this post, I realized that it’s hard to follow but I’m no longer able to edit it - see this reply for a more concise overview:

Description

Hi, we’re building an editor on top of ProseMirror and we have several selectable nodes which don’t contain any editable content but do contain some non-editable text. We want this text to also be selectable so that the user can copy it without having to copy the whole node.

For example, consider a node which has an image with a caption - we want the user to be able to click any part of it to select the whole node. After that, the user should be able to select just the caption and copy it while the node remains selected in the editor. Take a look at the gifs below to get a better idea of what we’re aiming for:

Default behaviour:

default

Expected behaviour:

expected

Implementation

We’re trying to implement the described behaviour for any node which:

  • Is selectable, i.e. has selectable: true in its spec
  • Doesn’t contain content, i.e. has content: "" in its spec
  • Has a node view, created in a plugin
  • Renders some DOM element which contains only plain text

The default behaviour doesn’t let you set the DOM selection to a piece of text within such a node. However, even using a combination of nodeView.selectNode, nodeView.stopEvent, and nodeView.ignoreMutation, we’re struggling to get the behaviour we want and aren’t sure if we’re doing things the “correct” way.

I’ve created a minimal sandbox for each approach, which include imageWithText node as a base example to show the issues we’re running into:

Demo

The main behaviour we want to get working is having the user be able to select the node without giving it the blue background (border only), while also letting them highlight text within it. Any help regarding this would be appreciated!

I’m not sure what the blue background thing means (doesn’t node selection only add a border?). But a node view like this might get you somewhere:

      imageWithText: node => {
        return {
          dom: createImageWithTextDOM()
          ignoreMutation: m => m.type == "selection",
          stopEvent: e => {
            return /mouse/.test(e.type) && e.target.className == "image-text"
          }
        }
      },

This assumes you add an image-text class to the node containing the caption text, so you can distinguish mouse events happening on that from mouse events on the image itself.

One thing this doesn’t handle is that, once the node has been selected (as a ProseMirror node selection), the editor will set it as draggable, so any mouse dragging on it will be interpreted as a drag event, not a selection. One workaround there would be to add an event handler on the text that, on mousedown, if the editor has a node selection on that node, clears that selection, so that the draggable attribute goes away.

Thanks for your reply, it did indeed get us closer to what we’re trying to achieve. Regarding your suggested workaround for the dragging though - I’d like to keep the browser selection and editor selection somewhat consistent, so that when you highlight text, the node that text is in is also selected in the editor. Therefore I’ve tried a different approach to try to fix this, since it seems like your suggestion requires the editor and browser selections to be in separate nodes.

First, I call event.preventDefault() on all drag events in stopEvent, so that mouse dragging is always interpreted as selection. I also made it so the node gets selected on mousedown, if it isn’t already.

The caveat here is that unless I set selectNode: () => {}, the text is still not selectable. This would be fine, except it also seems to automatically set the browser selection on the entire node. This is the blue background I was referring to in the post and what you can see in the default behaviour GIF.

Does this approach seem reasonable? I think preventing the browser selection being set when setting the editor selection to the node is the last thing we need to do to get the behaviour we want, but I’m not sure where to look for this.

Here’s a demo to show where we we are right now

When ProseMirror is focused, it is going to keep syncing the DOM selection to the selection in its state (which also means, for node selections, that it selects that node). I don’t think there’s currently a way to treat a node as an atom in the editor but still have a text selection inside of it.

Hi Marijn, hope things are well! Matthew is working with me on BlockNote and went deep into this issue. Thanks for your input. Do I understand correctly that there’s no way to prevent Prosemirror from setting the DOM selection to span the entire DOM-element when a Prosemirror Node is selected?

Do you have any other suggestions how we can achieve the desired UX? Or directions what changes would be necessary within Prosemirror to support this? (we could look into contributing this)

I’m curious how other people solve this with PM. As another reference, here’s a capture of the experience in Slite (slite.com), that shows a similar user experience we’re after - I suppose this scenario must be quite common?

node selection

(Note that our actual use case is actually a bit more complicated. I suppose the image with caption could technically be split into two PM nodes and the caption could be part of the PM schema in that way. However, in our actual use-case we also want it to be possible to embed a larger, more complex React component like AG-Grid in our nodeview, and still be able to select text inside it)

There is—as I said: move the focus away from ProseMirror. That’ll prevent it from syncing the DOM selection to its state.selection.

Clear; I suppose in that case it makes most sense to give the content of the nodeview a tabindex to make it focusable and move the focus to that whenever the Prosemirror node is selected, correct?

Possibly the browser’s default focus on click behavior takes care of that when the user selects text in the element.