Backspace and Enter (and Delete, I guess)

So the initial ad-hoc behavior I programmed turned out to be woefully inadequate (to my defense, it wasn’t much worse than that in many widely used WYSIWYG tools). But I wanted to define these in a way that actually followed some principle more rigorous than ‘I guess this is sort of what other tools do’.

So I searched for literature on the subject. This was actually really hard to find—either I didn’t find the right jargon, or academics are so embarrassed by WYSIWYG-style editing interfaces that they refuse to touch them. But I did find a few useful papers, all of them by the Inria people (most norably Irène Vatton and Vincent Quint) working on a series of semantic editors: Grif, Thot, and Amaya. Especially this section of the Thot manual and section 4.3 of this paper on Amaya were inspiring.

Enter

Enter takes on various variations of the theme ‘split’. In the simple case of the cursor being in the middle of a textblock, it will split the textblock in two at the cursor position. If the cursor was at the end of the block, the newly split off block will be a regular paragraph, rather than inheriting the parent block’s type.

(Note that all these behaviors occur only as allowed by the schema – if the schema forbids the resulting document shape, the behavior will be suppressed and the next possible behavior will be tried.)

Enter at the start of a textblock does not split that block in two, but rather splits that block off from its enclosing block. If it has a sibling before it, the enclosing block is split above the cursor’s block. If not, the current block is lifted out of the enclosing block. If it has no enclosing block, I do currently fall back on the ‘split paragraph’ behavior (in this case, split off an empty paragraph). I’m still debating whether to do something else here. One option would be to do nothing, another would be to create that empty paragraph and immediately move the cursor into it.

So the typical illustration of this behavior is that you are in a list item (using a document schema that allows multiple blocks per list item), and you press enter. The first thing that happens is that you get a new paragraph inside of your current list item. (This is somewhat non-standard – most tools will immediately create a new list item. And many of them make it quite hard or even impossible to put multiple paragraphs inside of a single item.) To get a new list item, you have to press enter again, which will split the original list item above the new paragraph. Pressing enter again will lift your new paragraph out of the list. I think this is a rather nicely predictable (once you’ve seen it a few times), linear progression.

There’s a special case when the cursor is inside a block type that’s marked as containing code (such as code_block). Since newlines in such blocks are meaningful, enter will insert a newline character. At the end of a code block, enter does move to the next paragraph (but you can use shift or ctrl-enter to insert a newline instead).

Backspace

Backspace, then, is the ‘join’ operation, i.e. the inverse of Enter. Sometimes. At other times, such as when something is selected or the cursor is after text, it obviously deletes the selection or the character before the cursor.

But when pressed at the start of a textblock, it removes one ‘barrier’ between this textblock and the preceding node, where ‘preceding node’ is defined as either its preceding sibling, or if it’s a first child the sibling before its parent, or failing that the sibling before its grandparent, and so on.

If this preceding node is a leaf node, for example a horizontal rule, which can’t have child nodes, we can’t join with it, so we simply delete it. If there is no preceding node, we also can’t join, and we perform a ‘lift’ (moving out of a parent node) if possible.

If there is a non-leaf preceding node, we try to move the node with the cursor closer to it. If that node, or one of its ancestors, can be joined with the preceding node, we do that. Joining paragraphs together is a case of this, but it can also be used to join two adjacent lists or blockquotes together. Failing that, f the node with the cursor is directly after the preceding node (with no wrapping nodes around it), we try to move it into the preceding node. This allows you to backspace a paragraph after a list into that list (without immediately joining it with the last paragraph in the list).

Failing that, if the node with the cursor is wrapped relative to the preceding node, we again try to lift it up out of its wrappers. This means that if joining with or moving into a preceding node isn’t possible, backspace has the visually expected effect of rubbing out the nesting before the cursor.

The main departure relative to the convention used by most tools is that this approach to backspace will join at any block level, not only at the textblock level. I think there is value in having an intermediate step, which allows moving things into blocks without losing them as separate blocks. If you do want to join to the preceding textblock, pressing backspace again will do so.

Delete

Delete acts like a sort of inversed backspace. It also deletes the selection, if any, or the character after the cursor. If those don’t apply, it looks for the block after the cursor, and again deletes that if it is a leaf block. If not, it applies the algorithm used by backspace to try and ‘pull’ the succeeding block closer to the cursor. This might pull a paragraph into a list, or might join a directly adjacent paragraph with the current textblock.

4 Likes

Thanks for clearing this up. I think this is a good general solution to accommodate for different and flexible schema definitions that are not tied to any specific node types.

Unfortunately, the resulting default behavior in bullet lists is less than ideal for consumer facing products as most users expect the behavior of programs like Google Docs and Word, which is quite different. So my question is, what would be the best way to change the default behavior for bullet/ordered lists to behave more similarly to Google Docs. For example, I do not necessarily need to allow multiple paragraphs inside a list item and hitting enter should immediately create a new list item (instead of hitting enter twice).

It would be very helpful if you could provide some guidance on how to achieve this. One idea I have would involve creating a custom command, which first checks if the current selection is a list item, then performs the appropriate transformation and finally skips any other commands. Or is it maybe possible to define that kind of behavior inside a custom schema?

I strongly disagree. The behavior was designed to be close to other tools whenever it makes sense, and is intended as something that users can figure out easily. If it wasn’t, there would be no point to it.

You can define custom commands and bind keys to them, and you can change your schema to allow only inline content inside of list_item nodes (in which case the existing commands will automatically do the thing you wanted). But what I’m working on here is fixing this type of interface. So ideally, unfixing it again shouldn’t be the first thing sites that integrate it do.

Personally, I prefer the consistent and predictable interface you designed. The problem is that the majority of (non tech-affine) users are very resistant to changing their behavior or learning new tools, especially if they’ve been using other text editors in a different way their whole lives. In these cases, I’m convinced that sticking to conventions leads to a better overall user experience, even if these conventions might be inferior on a conceptual or rational level.

This sounds like a good solution for what I’m trying to achieve. I will try that and see how it works.

I see it the same way and am really resistant to changing the default behavior of ProseMirror, but our first usability tests showed us that the current handling of bullet lists is a major usability pain point in our internally used prototype.

The redefinition of enter and backspace as split and join definitely matches the ideas I’ve been playing with, and I think it provides a lot of consistency among different document models.

One thing I really like (that I’ve struggled with) is the hierarchy of actions, especially around blockquotes and lists. In the project I’m currently working on, all of our wysiwyg areas are limited to inline elements (since they exist in block-level components that technically live outside the confines of the wysiwyg interface). This was great for paragraphs, where we could use a representational text model to manage the splitting and joining of inline elements and the paragraphs themselves, but it breaks down the minute you go past that shallow structure.

I’ve hit a number of stumbling blocks now that I’m trying to tackle blockquotes (should they allow multiple paragraphs?), list items (same question), and dealing with things like multi-paragraph operations. The hierarchy of actions that you described seems like they’ll provide a consistent experience around those (and other complicated data structures).

I’m struggling with the same question as to whether to allow multiple paragraphs inside a list item by default or not. Currently, I tend towards choosing a default where only inline elements are allowed. A user would still be able to enforce multiple lines inside a list item by hitting Shift+Enter to create a break line. Are there any concrete practical benefits of allowing multiple paragraphs inside list items? Maybe there are some interesting use cases that I’m overseeing or that I simply haven’t thought of?

Are there any concrete practical benefits of allowing multiple paragraphs inside list items?

Nested lists are a pretty big one.

You are right, nested lists are definitely very important. I guess that I didn’t think of them as “multiple paragraphs” from a user perspective. Only allowing inline elements in list items would prevent nested lists, as lists are represented as block elements, right? Disallowing nested lists would definitely be be too restrictive on the schema level. In that case I’m still not sure how I would disallow the possibility of creating multiple paragraphs inside a list item. Is it possible to specify multiple allowed child node types on the schema level? For example saying that a list item can only contain inline elements or lists. Is this something you have thought about? I saw that NodeTypes have a “kind” attribute, but I didn’t have the time to explore what they actually do yet.

Yes, but it is not possible to mix inline and block content in a single node. You could allow only paragraphs and lists in list items if you wanted. You’d do so by labeling the paragraph and ordered/bullet lists with an extra kind (say kind: "listable") and setting the content of list_item to this kind (contains: "listable").

1 Like

Weighing in on the improved way vs. standard way discussion.

I think its good to carefully examine when something “makes sense” and what “fixing” means within the context above.

I certainly respect the goal of improving mechanics but WYSIWYM editors and the standard mechanics they ascribe to, are in lots of products users work in everyday. To the degree possible I’d prefer not to force my users to adopt any new mechanics only when using Prosemirror.

It doesn’t really matter if the end-user can figure out something easily, if it surprises them or is otherwise non-standard without providing obvious value to them then it distracted them from what you as a developer want them focusing on and it could easily be taking away from their overall experience of the application Prosemirror is integrated into.

This is one area where I think a different default perspective is warranted from IDEs like CodeMirror. IDE using programmers are way more likely to value optimized mechanics and expect needing to learn them. WYSIWYM users are more likely to value following standard convention over improved mechanics.

So I truly believe that breaking with a standard convention should only “make sense” when absolutely unavoidable. If you have to break standard expectations to “fix” the interface and that learning process for the user takes non-zero time, then it will be broken for each and every user before it is fixed in their eyes. As it stands I do think a majority of integrators would opt to “unfix” anything that violates standard convention if given the choice.

2 Likes

I’ve come to realize that having ‘auto splitting list items’ actually doesn’t interfere with the behavior I defined in any problematic way.

And I apologize about the angry tone I took in reply #3. That was uncalled for and poorly thought through.

You can still, with a single backspace press, get back into your previous list item after pressing enter (when enter causes a list item split). The only tricky part is that it might not be entirely obvious anymore how to create a nested list. But if we take the approach proposed in issue 70, and disallow creating a directly nested list, and if that is tried in a list item that’s not the first item, to automatically join that item with the one above (creating the more typical structure of a list item with first a paragraph, and then a nested list).

That’s great to hear!

No worries, I didn’t take it personally.

I really like this approach! It would make it much easier for new users to work with bullet lists while still adhering to the split/join principle you defined. So I guess this new behavior would be implemented on a command level and leave the current schema unchanged, right? Regarding nested lists, I would additionally suggest the introduction of hotkeys for indention/unindention like they were proposed in issue 21. This should definitely help users create nested lists as these hotkeys are pretty common in other editors.

The revised commands (both for list-wrapping and for list-item splitting) have been merged into master.

I’m not doing indentation-vocabulary-based commands right now. Feel free to try to implement them yourself, but for now my intuition is that we can get by without them, and that having two different vocabularies for this kind of nesting manipulation built in is too much.

Awesome!! :smiley: One thing I noticed in the current master branch and the online demo is that it’s still possible to create a directly nested list item like described in Issue 70.

Alright!

How? (As in, what actions are you taking.)

For example: Hit "* " and then "* " again.

Yes, I was talking about the ‘wrap in list’ menu buttons. If you type '* ’ at the top of a list item, you are suggesting really strongly that you want to create a sublist there (visually, you created two bullets next to each other), so I don’t think having that actually create a sublist is a problem.

Ah ok, makes sense!