Suitable backspace command for lists with restricted schema?

I’m implementing a schema that contains lists, but has more restricted content than the example setup:

export const ul: NodeSpec = {
  content: "li+",
  parseDOM: [{ tag: "ul" }],
  toDOM() {
    return ["ul", 0];
  }
};

export const li: NodeSpec = {
  // Note that this is `p` rather than `p+`
  content: "p",
  parseDOM: [{ tag: "li" }],
  toDOM() {
    return ["li", 0];
  },
  defining: true
};

This is great. Basically I want really simple lists — lists that have a single paragraph child (in the future I’ll probably make it more sophisticated, but keeping it simple for now).

The problem I’m facing is that the default backspace behaviour (the command that comes from the base keymap in prosemirror-commands) doesn’t elegantly handle the case where you backspace in an empty list item, and expect the cursor to move to the end of the previous list item. Here’s a screen capture of what happens:

What’s happening here is that when I press backspace, joinBackward attempts to move the paragraph in the second list item, up into the first list item. Since my schema doesn’t support this (list items can only have one paragraph), it fails and selects the entire first list item instead.

What would be a more suitable set of commands to bind to backspace?

I think we can extend the default joinBackward to do something more sensible here without breaking any existing behavior – the ‘select the thing before’ fallback could check whether there are textblocks before and after the cut, and if there are, issue a delete from the end of the one before to the start of the one after instead of selecting a node.

Does that sound like it’d work for you?

I don’t know the exact definition of what a cut is (I tried to discern it from reading the code, but I think I need some more high-level documentation to really understand it), but deleting from the end of one to the start of the other sounds promising.

It’s hard for me to know the ramifications of this for other node types, and consequently if this would be considered backwards compatible or not.

In joinBackward/joinForward the ‘cut’ is the shallowest point in the tree directly in front of/after the cursor position, which is generally the place around which the joining should happen.

Looking at this more closely, I’m a bit wary about adding another form of behavior to these commands, since they already do a lot. Instead, I’ve separated out the node-selecting behavior in this patch, to make it easier to add extra functionality between the regular joining behavior and the node-selection fallback.

The hard-joining of textblocks I’m not so sure about anymore – I can imagine situations, like having the cursor in front of a required textblock node and pressing delete, where it would result in weird behavior (pulling the node’s text into the current node, and then creating a new empty instance of it to satisfy the schema requirements).

So I don’t really know what the correct way to define what you need here is. The above patch allows you to wire in a special-cased command for this situation, which would work, but somehow feels a bit more cumbersome than necessary – it’d be nice if the standard command could handle this properly.

Thanks! I’ve been tossing around the idea of writing up some notes on exactly what these commands do with some diagrams for my own benefit as well as others. It can be quite hard to understand and visualise exactly what all the different commands do just from reading the code.

I’m a big fan of those changes, providing more granular composable commands is very useful.

To be honest I’ve always been amazed just how far the standard commands go in delivering a satisfactory experience in a completely customisable editor.

In my experience though I’ve always found it very hard to reason about the impact of making changes to those commands, and have often wanted a slightly customised behaviour based on the context. (e.g. nested lists can be particularly delicate with backspace, tab, enter behaviour). As a result I often found myself going down the route of building higher-order commands that only executed if they were in the correct context (for example an empty selection in a list item).

Same – these are an absolute nightmare to get right, because they are going to be executed in just about every possible context, and have to satisfy a host of (largely vague) expectations. I suppose we’ll just have to keep iterating, considering new cases, and adding tests to prevent regressions.

Hello I’m facing a very similar issue as the listItem equivalent for me has content like this paragraph bullet_list? instead of paragraph block*

Having this restriction has two main undesirable effects in our case

  • the enter key pressed at the end of the line no longer create a new node (which may be a totally different issues)
  • more important the backspace key press when the cursor is at the beginning of a list_item starts behaving very oddly.

backspace-restricted-list

The only key being pressed here is the backspace key.

I’m not exactly surprised that the list item got converted into <ul><li> since the content does mention paragraph list_item? but even so on 2nd backspace press the earlier entry’s whole paragraph content ‘ab’ gets selected. Is this expected?

Ideally I would want the text to just get merged with the paragraph ‘ab’ so that on backspace the scheme becomes

  • ab12
  • 34 since having backspace act like a ‘tab’ or indent command might be a little confusing to the users.

I’m quite willing to write a custom command for the backspace bindings is that is what is recommended but I thought to mention here since the issue seems somewhat related to me.

If custom bindings are suggested, what would be the best way to approach it? Should I base it on the prosemirror-schema-list commands?