Toggling nested lists

For anyone also wondering how to toggle nested lists, this was our approach:


/**
 *
 * @param state
 * @param dispatch
 * Toggles a list off (removes the list).
 */
export function removeListItems(
    state: EditorState,
    dispatch: (transaction: Transaction) => void,
) {
    // Select all paragraph nodes inside of the user's selection
    const contentNodes: Node[] = [];
    state.doc.nodesBetween(
        state.selection.from,
        state.selection.to,
        (node: Node, pos: number) => {
            if (
                [
                    state.schema.nodes.bullet_list,
                    state.schema.nodes.ordered_list,
                    state.schema.nodes.list_item,
                ].includes(node.type)
            ) {
                return true;
            } else {
                contentNodes.push(node);
                return false;
            }
        },
    );

    // Get the start pos (position) and end pos of the selection.
    // Delete content from the start pos to the end pos.
    const tr = state.tr;
    const start = state.selection.$from.start();
    const end = state.selection.$to.end();
    tr.delete(start, end);

    let currentDepth;

    // The do...while loop below is liftListItem() from the prosemirror-schema-list package but modified.
    // What it does: after deleting the content selected by the user, a bullet point (for an unordered list)
    // or a number (for an ordered list) will remain. Unindent this bullet point or number until
    // currentDepth reaches 1. For info about depth: https://prosemirror.net/docs/ref/#model.ResolvedPos.depth
    do {
        const { $from, $to } = tr.selection;
        const range = $from.blockRange(
            $to,
            (node: Node) =>
                node.childCount > 0 &&
                node.firstChild!.type === state.schema.nodes.list_item,
        );
        if (!range) return false;

        currentDepth = range.depth;

        if ($from.node(range.depth - 1).type === state.schema.nodes.list_item) {
            // Inside a parent list
            liftToOuterList(tr, state.schema.nodes.list_item, range);
        }
        // Outer list node
        else {
            liftOutOfList(tr, range);
        }
    } while (currentDepth > 1);

    const cursorPosAfterUnindenting = tr.selection.to;

    tr.insert(tr.selection.from, contentNodes);

    // After insertion, the cursor should be at the end of the inserted content.
    const cursorPosAfterInsertion = tr.selection.to;
    // There'll be a newline at the top and bottom of the inserted content, so join the top node
    // with the node before it and join the bottom node with the node below it.
    tr.join(cursorPosAfterInsertion - 1);
    tr.join(cursorPosAfterUnindenting + 1);

    dispatch(tr);
}


/**
 *
 * @param state
 * @param dispatch
 * @param itemType
 * @param range
 * This is a function provided by the prosemirror-schema-list package that is not exported which we have copied and tweaked
 */
function liftToOuterList(
    tr: Transaction,
    itemType: NodeType,
    range: NodeRange,
) {
    const end = range.end;
    const endOfList = range.$to.end(range.depth);
    if (end < endOfList) {
        // There are siblings after the lifted items, which must become
        // children of the last item
        tr.step(
            new ReplaceAroundStep(
                end - 1,
                endOfList,
                end,
                endOfList,
                new Slice(
                    Fragment.from(itemType.create(null, range.parent.copy())),
                    1,
                    0,
                ),
                1,
                true,
            ),
        );
        range = new NodeRange(
            tr.doc.resolve(range.$from.pos),
            tr.doc.resolve(endOfList),
            range.depth,
        );
    }
    const target = liftTarget(range);
    if (target === null) return false;
    if (target !== undefined) {
        tr.lift(range, target);
    }
    const after = tr.mapping.map(end, -1) - 1;
    if (canJoin(tr.doc, after)) tr.join(after);
}

/**
 *
 * @param state
 * @param dispatch
 * @param range
 * This is a function provided by the prosemirror-schema-list package that is not exported which we have copied and tweaked
 */
function liftOutOfList(tr: Transaction, range: NodeRange) {
    const list = range.parent;
    // Merge the list items into a single big item
    for (
        let pos = range.end, i = range.endIndex - 1, e = range.startIndex;
        i > e;
        i--
    ) {
        pos -= list.child(i).nodeSize;
        tr.delete(pos - 1, pos + 1);
    }
    const $start = tr.doc.resolve(range.start);
    const item = $start.nodeAfter!;
    const atStart = range.startIndex === 0;
    const atEnd = range.endIndex === list.childCount;
    const parent = $start.node(-1);
    const indexBefore = $start.index(-1);
    if (
        !parent.canReplace(
            indexBefore + (atStart ? 0 : 1),
            indexBefore + 1,
            item.content.append(atEnd ? Fragment.empty : Fragment.from(list)),
        )
    ) {
        return false;
    }
    const start = $start.pos;
    const end = start + item.nodeSize;
    // Strip off the surrounding list. At the sides where we're not at
    // the end of the list, the existing list is closed. At sides where
    // this is the end, it is overwritten to its end.
    tr.step(
        new ReplaceAroundStep(
            start - (atStart ? 1 : 0),
            end + (atEnd ? 1 : 0),
            start + 1,
            end - 1,
            new Slice(
                (atStart
                    ? Fragment.empty
                    : Fragment.from(list.copy(Fragment.empty))
                ).append(
                    atEnd
                        ? Fragment.empty
                        : Fragment.from(list.copy(Fragment.empty)),
                ),
                atStart ? 0 : 1,
                atEnd ? 0 : 1,
            ),
            atStart ? 0 : 1,
        ),
    );
}

This is how it works:

togglingLists