List Type Toggle

I’m not a big fan of the default list behaviour of wrapInList from prosemirror-schema-list. I want the button to act as a toggle. So if you click the bullet list button in a bullet list, it removes the list, if you click a bullet list button in a numbered list, it swaps the numbered list for a bullet list. If you click it outside of any list, it just wraps in a bullet list.

So far, I have:

function isList(node: Node) {
  return (
    node.type === spec.nodes.bullet_list ||
    node.type === spec.nodes.ordered_list
  );
}
function toggleList(listType: NodeType) {
  const lift = liftListItem(spec.nodes.list_item);
  const wrap = wrapInList(listType);
  return (
    state: EditorState,
    dispatch?: (tr: Transaction) => void,
  ): boolean => {
    const {$from, $to} = state.selection;
    const range = $from.blockRange($to);
    if (!range) {
      return false;
    }
    if (range.depth >= 2 && $from.node(range.depth - 1).type === listType) {
      return lift(state, dispatch);
    } else if (range.depth >= 2 && isList($from.node(range.depth - 1))) {
      const tr = state.tr;
      // const node = $from.node(range.depth - 1);
      // TODO: how do I pass the node above to `setNodeType`?
      // tr.setNodeType(range.start, listType);
      if (dispatch) dispatch(tr);
      return false;
    } else {
      return wrap(state, dispatch);
    }
  };
}

But I’m stuck on how to resolve that TODO item. This is my first time trying to write my own transaction code, so any help is really appreciated.

1 Like

Toggles don’t work with nested lists, and the schema-list package is written for nested lists. So feel free to implement this, but my implementation is going to keep working the way it currently does.

I think what you want in your code is $from.before(range.depth - 1) – that’ll give you the position before the node, which is what setNodeType expects.

Thank you for the quick response. My plan is for the button to act as a toggle when at the top level, and then lift for nested lists. The indent/outdent buttons (and tab/shift+tab) buttons allow for nested lists. I think this most closely matches the behaviour that people seem to expect.

I understand that this isn’t the behaviour you want to build into your library; I’m very happy to have the custom logic on my end.

1 Like

It looks like simpler lists are something many people want, would you (and anyone interested in that) be interested in collaborating on a prosemirror-schema-simple-list?

Hi,

First I’d like to thank you for making PM available, the APIs look really impressive and powerful!.

Please allow me to ask a noob question cause I just started learning how to build an usable rich text editor and I quickly got stuck by this issue.

I did spend quiet some on searching for the right answer and it appears that people at Atlassian did come up a working solution:

To my surprise, the amount of codes written by Atlassian to make list toggling work is quite big, and I haven’t be able to re-implement it with a much simpler solution and my knowledge with PM is still very limited.

I was hoping I could use the codes from Atlassian but unfortunately it’s written in Typescript and it still used the old version of PM.

Anyway, any lead of the potential solution will be highly appreciated, I can see that it will be super valuable to deliver the same kind of user experience for list editing like Google Doc does, which does feel more nature and easier to use.

Thanks.

It should be possible to implement a list toggle on top of the existing list commands with very little code—a wrapper command can check whether the selection is in a list, and calls liftListItem if it is, and wrapInList otherwise.

1 Like

chainCommands( liftListItem(listItemType), wrapInList(listNodeType),
)(state, dispatch);

Thank you for the quick response! It turns out that I was confused by the API without throughly check the implementation. In case there might be new folks who might encounter this issue, I’d like to share what I’ve learned below.

While conceptually I know how this could be done, I did not use the right APIs to do it.

First I started with the following codes:

chainCommands(
  liftListItem(listItemType),
  wrapInList(listNodeType),  
)(state, dispatch);

But it did not work, because chainCommands() does not run all commands chained, instead, it will stop at the first command that returns true.

So I proceeded using my own chaining logic by manually applying sequential transactions to state:

liftListItem(listItemType)(state, (transaction => {
  const nextState = state.apply(transaction);
  wrapInList(listNodeType)(nextState, (transaction) => {
    dispatch(transaction);
  });
}));

But it dos not work because PM will complain that inconsistency state changes and throw error.

So I looked inside the actual codes of prosmirror-schema-list, then realized that commands like liftListItem and wrapInList are just a wrapper of multiple transforms, and to make it does what I need, I should change their API from

liftListItem(listItemType)(state, dispatch); // This returns a boolean

to

liftListItem(listItemType)(state.tr); // This returns a transaction.

Thus, I could do this:

function toggleList(state, dispatch) {
  let tr = state.tr;
  tr = liftListItem(listItemType)(tr);
  tr = wrapInList(listNodeType)(tr);
  if (state.tr !== tr) {
    // something changed, will apply.
    dispatch && dispatch(tr);
    return true;
  } else {
    return false;
  }
}

What I did is just refactor “command” into “transform”. I also learned that people had asked for the similar thing at Refactor Commands into Transform Helpers which is also worthy reading.

Thanks.

2 Likes

So is there any solution to chain two commands?

I would like to chain liftListItem and wrapInList

to go from bullet list to ordered list and vice versa.

I found a solution that requires very little code based on a few discussions on these forums. I hope this will help someone:

function chainTransactions(...commands: EditorCommand[]): EditorCommand {
    return (state, dispatch): boolean => {
        const dispatcher = (tr: Transaction) => {
            state = state.apply(tr);
            dispatch(tr);
        };
        const last = commands.pop();
        const reduced = commands.reduce((result, command) => {
            return result || command(state, dispatcher);
        }, false);
        return reduced && last !== undefined && last(state, dispatch);
    };
}

function toggleList(listType: NodeType, schema: Schema, attrs: any): EditorCommand {
    return function (state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
        if (!dispatch) return true;
        const currentNode = getSelectedNodeType(state);
        const oppositeListOf: { [key: string]: string } = {
            bullet_list: 'ordered_list',
            ordered_list: 'bullet_list',
        };

        let transactions: EditorCommand;
        if (currentNode.name === listType.name) transactions = liftListItem(schema.nodes.list_item);
        else if (oppositeListOf[currentNode.name] === listType.name)
            transactions = chainTransactions(liftListItem(schema.nodes.list_item), wrapInList(listType, attrs));
        else transactions = wrapInList(listType, attrs);

        return transactions(state, dispatch);
    };
}

export function toggleListItem(nodeType: NodeType, schema: Schema, options: any): MenuItem {
    return cmdItem(toggleList(nodeType, schema, options.attrs), { ...options, active: isListActive(nodeType) });
}
2 Likes

hi thanks @DigiBytes and everyone here. The last piece of code was really helpful, but i did some updates and created gist for it, in case someone intersted:

1 Like

Hello all :wave:

We’re trying to get our list behavior in line with the expectations of most users, and to do this we’re attempting to emulate the way lists behave in virtually every popular example we’ve checked – Slack, Google, Outlook, etc. The ability to convert from one list to another was a big piece of this puzzle, and the suggestions in this thread seem to work fine – but seemingly only if the user’s selection doesn’t exceed the boundaries of a single list. Example:

rte-lists-1

Our design team (who I agree with) feels that users will expect to be able to convert more or less any selection of blocks into a list, regardless of whether they’re already in a list of the same type – or in a list at all. Pretty much exactly like this:

rte-lists-slack-1

I’ve spent 20-30 hours trying to study the library and implement this behavior in ProseMirror, and have nothing to show for it :sweat_smile: So I’ve broken down and come looking for guidance. I would share what I’ve tried, but honestly none of it seemed to show much promise, so I’m pretty much right back at the code DigiBytes suggested.

Even the slightest nudge in the right direction would be much appreciated.

Speaking of much appreciated – thanks to the maintainer(s) for this powerful library :pray:

This type of interaction seem to be working from a model that treats the document as a flat list of textblocks, each of which can have a list style associated with them. That’s not how ProseMirror works, as I’m sure you noticed—lists involve a single parent node for the entire list, sub-lists are children of the outer list node, and so on. As such, this kind of toggling command introduces a bunch of dubious corner cases that the built-in list manipulation commands intentionally avoid dealing with.

  • What happens if your range of selected blocks spans the start/end of some other node (say, a blockquote)

  • What happens if some of the blocks are nested list items?

  • Should lists created this way always automatically be joined to adjacent lists?

It’s probably possible to write something more or less reasonable that covers the use cases you have in mind, but you’ll have to precisely formulate it in terms of ProseMirror’s document model first, before you start implementing.

1 Like

Hi Marijn, thanks for the response!

That’s not how ProseMirror works, as I’m sure you noticed

For sure – every effort I’ve made to get this done seems to break things in other ways, and I certainly feel like I’m going against the grain.

The edge cases you listed seem to be handled fairly gracefully in Slack’s editor, and that’s probably the one that we’d be most keen to emulate.

If that’s the behavior we’re after, do you think there’s any hope of building on top of the built-in list functionalities? Or are you suggesting a completely fresh and different approach, from the schema on up?

You could probably do this on top of the regular list schema. Maybe the approach could compute the changes you want to make from a ‘flattened’ representation of the selected blocks, and then somehow figure out which tree manipulations are necessary to produce the computed changes.

Gotcha. That’s precisely the approach I tried first, but the code I wrote to do those tree manipulations grew so convoluted and prone to errors that I eventually scrapped it, assuming that the overall strategy was flawed.

Perhaps I’ll give it another go…

Hi. We have implemented some list type toggle features in Remirror. Now you can toggle list type in two ways:

  • toggle the whole list to another type by using toggleTaskList, toggleBulletList, toggleOrderedList commands.
  • toggle only selected list items to another type by using wrapSelectedItems API.

Specifically, in the example on our website, you can toggle list type by

  • Press the three buttons at the top to toggle the whole list.
  • Select one or multiple items and then press Ctrl-Enter or Comman-Enter to toggle the selected items.
  • Input - , 1. , [] at the beginning of a list item to transform the current item.

Check the video below for a demo:

The current implementation is not perfect, and there are still some minor edge cases that need to deal with. I would like to know what people think about it. Like where it can still be improved. Thanks in advance.

For tiptap we built a simple but very effective feature for list toggling. We check if wrapInList can be executed before toggling. If not, we convert the selected nodes to default nodes (based on the schema, in most cases paragraphs).

// within `toggleList` command

const canWrapInList = can().wrapInList(listType)

if (!canWrapInList) {
  return chain()
    .clearNodes()
    .wrapInList(listType)
    .run()
}

return commands.wrapInList(listType)

Then we try again to convert the selection to lists (99% successful).

See it in action: ezgif-7-25030411dfae

@ocavue How could you upload videos? I can only insert images here :sweat_smile:

1 Like

I just drag my video file into a random GitHub issue, so I can upload it to GitHub’s server. Then I paste the link back to here. :blush:

Thanks for the suggestions all :pray: I’ll look into these today!

First of all, that’s a pretty impressive library. Excellent type work!

I created an adaptation of your clearNodes() command and it’s working wonderfully so far. There will be a few kinks to work out, I’m sure, but this will be a good starting place for us I think.

Thanks!