Selecting a Block-Level Node after Insertion

Hi everyone! I’ve been biting my teeth out on this problem for a while now (ProseMirror beginner here!) and now I’m at a point where I just have to ask for an outside opinion on this.

I have a custom Image-Node, which renders as a NodeView. I create and insert that with the following code:

const cleanImageAttrs = Object.entries(data).reduce((acc, [key, value]) => { …unrelated code…  }}, {});
const { tr } = this.editorState;
const node = this.editorState.schema.nodes.image.createAndFill(cleanImageAttrs, null);
tr.replaceSelectionWith(node);
// TODO: find a way to select the image after it was inserted
// It doesn’t work yet when the node was inserted at the end of another node or the document
tr.setSelection(new NodeSelection(tr.doc.resolve(Math.max(0, (tr.selection.$anchor.depth > 0 ? tr.selection.$anchor.before() : tr.selection.anchor) - node.nodeSize))));

this.editorView.dispatch(tr.scrollIntoView());
this.editorView.focus();

I’ve tried simply using the method that was described here, but I keep getting an error that nodeBefore is null, which I can confirm when logging the selection.

My method sort of works, unless the image is inserted after a TextNode at the end of the document (or a block-level node such as a quote for example). That causes an error in the NodeSelection constructor, because it tries to read nodeSize of the nodeAfter property of the passed position (which is null because we’re at the end of the document).

Why is this happening? I feel like I’ve got something fundamental wrong about how selections work, or my Image-node is somehow malformed.

I’d appreciate any pointers in the right direction.

This is a bit more difficult to do than one would expect, due to replaceSelection having a bunch of heuristics around what to replace and where. But what replaceSelection will do is put the cursor after the inserted element, so you could scan the document (the new one, tr.doc) for the last instance of your node type before the cursor, and select that one.

Thank you for that pointer! I have updated my code with the following snippet and it almost works:

tr.replaceSelectionWith(node);
let found;
tr.doc.nodesBetween(0, tr.selection.anchor + 1, (node, pos) => {
  if (node.type.name === 'image') found = { pos };
  if (found) return false;
  return true;
});
tr.setSelection(NodeSelection.create(tr.doc, found.pos));

I’ve had to add the + 1 to the anchor so it would actually select the last image (and not the one before it), but unfortunately that also means that if a new image is inserted between two images, the one after gets selected.

On top of that, if my NodeView has a contentDOM, the editor loses focus after the NodeView loads for some reason? :flushed:

I’m sorry if I’m missing something really obvious here. :sweat_smile:

That’s odd. If the selection is after the node, iterating up to the selection should definitely include it. Do you have a specific example of a document and selection where that goes wrong?

That sounds fishy. Where does the focus move? Does your node view create a new focusable element? Or do anything strange in its selectNode method?

I thought that perhaps the nodesBetween-Method was not including the end position, because the newly inserted node is always missing in the iteration, but upon further investigation, it seems like the anchor is not in fact after the node.

When I insert an image into an empty document (Edit: empty in the sense that it contains only an empty paragraph), tr.selection.anchor is 0 after tr.replaceSelectionWith(). Shouldn’t it be 1 if it was really after the inserted node?

The only time iterating to tr.selection.anchor without +1 seems to work is when inserting the image in the middle of a paragraph node, e.g. when splitting a word in half.

Re losing focus when contentDOM is present, that was a false alarm, I had an unrelated element stealing focus from the editor when it was loaded in a modal. The selection via iteration works the same whether the NodeView has a contentDOM or not.

Okay, so over the past few days, I’ve given this another shot and here’s what I came up with:

// NOTE: this code is largely based on trial and error, so there may be some edge-cases in which the selection doesn’t get set correctly
let foundBefore;
let found;
tr.doc.nodesBetween(0, tr.selection.$anchor.nodeAfter === image ? tr.selection.anchor + 1 : tr.selection.anchor, (node, pos) => { // if the node after the selection anchor is the image we just inserted, we need to offset the position by one so that image gets included
  if (node.type.name === 'image') {
    if (found) foundBefore = found; // HACK: since when captions are enabled the image after the one we want gets selected, we store the previous one here
    found = { pos };
  }
  if (found) return false;
  return true;
});
if (found) {
  if (!this.formatOptions.allowImageCaptions || tr.selection.$anchor.nodeAfter !== null) tr.setSelection(NodeSelection.create(tr.doc, found.pos)); // when captions are disabled, or a caption was inserted before a node, the image found last is the one we want
  else tr.setSelection(NodeSelection.create(tr.doc, (foundBefore && foundBefore.pos) || found.pos)); // otherwise it's the second to last
}

This code works, but it feels really fragile. It’s based around me trying out a lot of different variations of how and where such a block could be inserted and trying to account for all the quirks that came along the way.

Unfortunately, it’s the best I could come up with my (admittedly limited) understanding of ProseMirror.

Is this really the way to go, or am I doing something fundamentally wrong?

What kind of selection are you getting then? If it’s a node selection (which seems likely if no other selection is valid in the document), it’s anchor will indeed point in front of the node, but its head will point after it.

Yes, you are right. When it’s a NodeSelection, headdoes indeed point after the inserted node. That solves that mystery +1. :+1: