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: