import { Fragment, Node as ProseMirrorNode, NodeType, Slice, } from '@tiptap/pm/model' import { TextSelection } from '@tiptap/pm/state' import { canSplit } from '@tiptap/pm/transform' import { getNodeType } from '../helpers/getNodeType.js' import { getSplittedAttributes } from '../helpers/getSplittedAttributes.js' import { RawCommands } from '../types.js' declare module '@tiptap/core' { interface Commands { splitListItem: { /** * Splits one list item into two list items. */ splitListItem: (typeOrName: string | NodeType) => ReturnType } } } export const splitListItem: RawCommands['splitListItem'] = typeOrName => ({ tr, state, dispatch, editor, }) => { const type = getNodeType(typeOrName, state.schema) const { $from, $to } = state.selection // @ts-ignore // eslint-disable-next-line const node: ProseMirrorNode = state.selection.node if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) { return false } const grandParent = $from.node(-1) if (grandParent.type !== type) { return false } const extensionAttributes = editor.extensionManager.attributes if ($from.parent.content.size === 0 && $from.node(-1).childCount === $from.indexAfter(-1)) { // In an empty block. If this is a nested list, the wrapping // list item should be split. Otherwise, bail out and let next // command handle lifting. if ( $from.depth === 2 || $from.node(-3).type !== type || $from.index(-2) !== $from.node(-2).childCount - 1 ) { return false } if (dispatch) { let wrap = Fragment.empty // eslint-disable-next-line const depthBefore = $from.index(-1) ? 1 : $from.index(-2) ? 2 : 3 // Build a fragment containing empty versions of the structure // from the outer list item to the parent node of the cursor for (let d = $from.depth - depthBefore; d >= $from.depth - 3; d -= 1) { wrap = Fragment.from($from.node(d).copy(wrap)) } // eslint-disable-next-line const depthAfter = $from.indexAfter(-1) < $from.node(-2).childCount ? 1 : $from.indexAfter(-2) < $from.node(-3).childCount ? 2 : 3 // Add a second list item with an empty default start node const newNextTypeAttributes = getSplittedAttributes( extensionAttributes, $from.node().type.name, $from.node().attrs, ) const nextType = type.contentMatch.defaultType?.createAndFill(newNextTypeAttributes) || undefined wrap = wrap.append(Fragment.from(type.createAndFill(null, nextType) || undefined)) const start = $from.before($from.depth - (depthBefore - 1)) tr.replace(start, $from.after(-depthAfter), new Slice(wrap, 4 - depthBefore, 0)) let sel = -1 tr.doc.nodesBetween(start, tr.doc.content.size, (n, pos) => { if (sel > -1) { return false } if (n.isTextblock && n.content.size === 0) { sel = pos + 1 } }) if (sel > -1) { tr.setSelection(TextSelection.near(tr.doc.resolve(sel))) } tr.scrollIntoView() } return true } const nextType = $to.pos === $from.end() ? grandParent.contentMatchAt(0).defaultType : null const newTypeAttributes = getSplittedAttributes( extensionAttributes, grandParent.type.name, grandParent.attrs, ) const newNextTypeAttributes = getSplittedAttributes( extensionAttributes, $from.node().type.name, $from.node().attrs, ) tr.delete($from.pos, $to.pos) const types = nextType ? [ { type, attrs: newTypeAttributes }, { type: nextType, attrs: newNextTypeAttributes }, ] : [{ type, attrs: newTypeAttributes }] if (!canSplit(tr.doc, $from.pos, 2)) { return false } if (dispatch) { const { selection, storedMarks } = state const { splittableMarks } = editor.extensionManager const marks = storedMarks || (selection.$to.parentOffset && selection.$from.marks()) tr.split($from.pos, 2, types).scrollIntoView() if (!marks || !dispatch) { return true } const filteredMarks = marks.filter(mark => splittableMarks.includes(mark.type.name)) tr.ensureMarks(filteredMarks) } return true }