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 =;
      // 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.

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?


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.


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.

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:

)(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) => {

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


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

Thus, I could do this:

function toggleList(state, dispatch) {
  let tr =;
  tr = liftListItem(listItemType)(tr);
  tr = wrapInList(listNodeType)(tr);
  if ( !== 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.



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);
        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 ( === transactions = liftListItem(schema.nodes.list_item);
        else if (oppositeListOf[] ===
            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) });
1 Like