Cursor Not Moving Correctly with updated Text selection

What I’m Trying to Accomplish: I’m trying to create a popup that lets users select which node type should be inserted next when they press Enter.

What I’ve Done:

  • I created a plugin that displays a popup on Enter key press.
  • The plugin checks the current position, retrieves the next possible sibling nodes, and renders an HTML list of these options.
  • The list items have two event listeners:
    • Click: Selecting an item with the mouse correctly inserts the node and moves the cursor to the expected position.
    • Keyboard (Enter key): Pressing Enter inserts the node, but there is a problem (see below).

My problem is: Whilst mouse selection works perfectly, keyboard selection does not. When selecting with the keyboard, the cursor does not move to the expected position even though the selection is correct. I expected the cursor to be positioned inside the new node I have inserted, this does happen with mouse selection, but not with keyboard selection.

I noticed that if I insert a zero-width space character (\u200B) at the new selection position, the cursor moves correctly and the selection is as expected. However, this feels like a workaround rather than a proper solution; as we would need to later strip these zero-width space characters back out.

My Question is: How can we have insertion of a new node via keyboard selection place the cursor in the correct position?

Code Snippets: Here’s our plugin code that shows the popup on Enter key press:

export const suggestionPlugin = new Plugin({
  props: {
    handleKeyDown(view, event) {
      if (event.key === 'Enter') {
        if (isEOL(view.state.tr, 0) || isEmpty(view.state.tr, 0)) {
          const suggestions = getSuggestions(view);
          const suggestionPopup = new SuggestionPopup(view, suggestions);
          view.dom.parentNode?.appendChild(suggestionPopup.dom);
          const firstListItem = suggestionPopup.dom.querySelector(".suggestionsList li") as HTMLLIElement;
          firstListItem?.focus();
          return true;
        }
      }
      return false;
    }
  }
});

The popup creates list items and attaches click and keyboard event listeners:

li.addEventListener("click", (e) => {
  e.preventDefault();
  insertNode(editorView, followingSibling.type);
  this.destroy();
});

keyboardNavHandler(e: KeyboardEvent) {
  e.preventDefault();
  const focusedElement = document.activeElement as HTMLLIElement;
  if (e.key === "Enter") {
    focusedElement.click();
  }
}

The insertNode function handles inserting the node and updating the selection:

function insertNode(view: EditorView, _nodeType: NodeType) {
  const { dispatch, state } = view;
  const { tr, selection, schema } = state;
  const { $from } = selection;
  const type = schema.nodes['p'];
  const node = type.createAndFill(null, schema.text('\u200B')); // Zero-width space workaround
  if (!node) return;
  tr.insert($from.pos, node);
  const mapped = tr.mapping.map($from.pos);
  const resolvedPos = tr.doc.resolve(mapped + node.nodeSize);
  tr.setSelection(TextSelection.create(tr.doc, resolvedPos.pos));
  dispatch(tr.scrollIntoView());
}

Live Demo: https://dev.petal.evolvedbinary.com/ This is related to: Resolved Pos node is not the same as doc.nodeAt()

That link doesn’t seem to show the issue—either because it has the workaround active or because I’m misunderstanding the instructions. When you say “keyboard selection”, do you mean after picking a node type with the keyboard? Does the selection land in the wrong place immediately after picking? Or is it impossible to navigate to the node afterwards?

Hi @marijn Thanks for taking a look, indeed the workaround was active, I removed it and you can see the issue now. https://dev.petal.evolvedbinary.com/ Here are the instructions:

  1. Place the cursor at the end of a paragraph eg after any software product.
  2. Press enter, a pop up should show up.
  3. Click on paragraph in the popup using the mouse, and check the cursor position
  4. Undo the changes or put the cursor at the end of another paragraph.
  5. Press enter, the pop up should show up.
  6. Press enter again to select the first option paragraph.
  7. Notice the cursor position.

Do you mean after picking a node type with the keyboard?

Yes, with the enter press.

Does the selection land in the wrong place immediately after picking?

Yes, it does not update to the right place.

You did something that causes ProseMirror to not render a <br> node in empty paragraphs (maybe marked them as inline?). That is breaking the browser’s selection handling (you can also see that it skips empty paragraphs when you try to go into them with arrow up/down).

Hi @marijn, Thanks for pointing that out, indeed we had some of our nodes marked as inline.

I wonder if the difficulties we are experiencing are related to understanding how we should model something in ProseMirror Schema.

We have the situation where we would like an img element to be able to be used as both a block element, and as an inline element.

It may be that we still don’t quite understand the ProseMirror definitions of “block” and “inline” nodes. We have read the documentation repeatedly, but we are still a little confused.

Our first example shows an img element mixed with other block elements (html, body, h1, etc.) in this case we think we need to define it as a block node in ProseMirror schema.

<html>
  <title>Example of image mixed with block nodes</title>
  <body>
    <h1>Example 1</h1>
    <img src="image1.png"/>
  </body>
</html>

Our second example shows an img nested within a p element that also contains text and markup. In this case, we think we need to define it as an inline node in the ProseMirror schema.

<html>
  <title>Example of image mixed with inline nodes</title>
  <body>
    <h1>Example 2</h1>
    <p>Here an image <img src="image1.png"/> for you</p>
  </body>
</html>

Both use cases are valid HTML.

If we try to model this in a ProseMirror schema, we seem to face an issue with mixing block and inline nodes in the body content, e.g.

const nodeSpec: NodeSpec = {
doc: {
      content: "html",
    },
    html: {
      content: "title body?",
      toDOM() {
        return ["html", {}, 0];
      },
    },
    title: {
      content: "text*",
      toDOM() {
        return ["title", {}, 0];
      },
    },
    body: {
      content: "(h1|img|p)*", // this is where we are struggling
      toDOM() {
        return ["body", {}, 0];
      },
    },
    h1: {
      content: "text*",
      toDOM() {
        return ["h1", {}, 0];
      },
    },
    img: {
      inline: true,
      attrs: {
        src: {
          default: "https://picsum.photos/200/300",
        },
      },
      toDOM(node) {
        return ["img", { src: node.attrs.src }];
      },
    },
    p: {
      content: "(img|text)*",
      toDOM() {
        return ["p", {}, 0];
      },
    },
    text: {
      inline: true,
    },
}

Is it possible to create a schema that can have img nodes as both block and inline elements? If not, what are out options please?

Here’s a demo of the code: https://codesandbox.io/p/devbox/confident-hooks-forked-5pm7q7?workspaceId=ws_GkHAzkZJBA2J7JZx2YhuSS

No. You’ll have to define two different node types for these.

2 Likes

Thanks @marijn.

You may find similar situations with:

  • table cells’ content: in HTML you can have block or directly inline content
  • list items
  • text (inline) and display (block) math, as in TeX

In Pandoc, they define a Plain block that is in the group of blocks, and it’s a container of inlines.

It’s like a paragraph, without being a paragraph, just a block containing inlines.

It’s a useful abstraction, that lets you define a schema where table cells and list items always contain blocks, and formulas are always inline; when needed, you surround the inline content with a Plain block.

So, if you need only block and inline images, just define two different nodes, like inlineImage and blockImage, but if you have multiple similar situations, you might use the Plain abstraction.

1 Like