prosemirror-flat-list
Version:
Powerful list support for ProseMirror
212 lines (180 loc) • 5.77 kB
text/typescript
import { chainCommands } from 'prosemirror-commands'
import {
Fragment,
NodeRange,
type Node as ProsemirrorNode,
Slice,
} from 'prosemirror-model'
import {
type Command,
type EditorState,
Selection,
TextSelection,
type Transaction,
} from 'prosemirror-state'
import { canSplit } from 'prosemirror-transform'
import type { ListAttributes } from '../types'
import { withAutoFixList } from '../utils/auto-fix-list'
import { createAndFill } from '../utils/create-and-fill'
import { isBlockNodeSelection } from '../utils/is-block-node-selection'
import { isListNode } from '../utils/is-list-node'
import { isTextSelection } from '../utils/is-text-selection'
import { dedentNodeRange } from './dedent-list'
import { enterWithoutLift } from './enter-without-lift'
/**
* Returns a command that split the current list node.
*
* @public @group Commands
*
*/
export function createSplitListCommand(): Command {
return withAutoFixList(
chainCommands(splitBlockNodeSelectionInListCommand, splitListCommand),
)
}
function deriveListAttributes(listNode: ProsemirrorNode): ListAttributes {
// For the new list node, we don't want to inherit any list attribute (For example: `checked`) other than `kind`
return { kind: (listNode.attrs as ListAttributes).kind }
}
const splitBlockNodeSelectionInListCommand: Command = (state, dispatch) => {
if (!isBlockNodeSelection(state.selection)) {
return false
}
const selection = state.selection
const { $to, node } = selection
const parent = $to.parent
// We only cover the case that
// 1. the list node only contains one child node
// 2. this child node is not a list node
if (
isListNode(node) ||
!isListNode(parent) ||
parent.childCount !== 1 ||
parent.firstChild !== node
) {
return false
}
const listType = parent.type
const nextList = listType.createAndFill(deriveListAttributes(parent))
if (!nextList) {
return false
}
if (dispatch) {
const tr = state.tr
const cutPoint = $to.pos
tr.replace(
cutPoint,
cutPoint,
new Slice(Fragment.fromArray([listType.create(), nextList]), 1, 1),
)
const newSelection = TextSelection.near(tr.doc.resolve(cutPoint))
if (isTextSelection(newSelection)) {
tr.setSelection(newSelection)
dispatch(tr)
}
}
return true
}
const splitListCommand: Command = (state, dispatch): boolean => {
if (isBlockNodeSelection(state.selection)) {
return false
}
const { $from, $to } = state.selection
if (!$from.sameParent($to)) {
return false
}
if ($from.depth < 2) {
return false
}
const listDepth = $from.depth - 1
const listNode = $from.node(listDepth)
if (!isListNode(listNode)) {
return false
}
const parent = $from.parent
const indexInList = $from.index(listDepth)
const parentEmpty = parent.content.size === 0
// When the cursor is inside the first child of the list:
// If the parent block is empty, dedent the list;
// otherwise split and create a new list node.
// When the cursor is inside the second or further children of the list:
// Create a new paragraph.
if (indexInList === 0) {
if (parentEmpty) {
const $listEnd = state.doc.resolve($from.end(listDepth))
const listParentDepth = listDepth - 1
const listParent = $from.node(listParentDepth)
const indexInListParent = $from.index(listParentDepth)
const isLastChildInListParent =
indexInListParent === listParent.childCount - 1
// If the list is the last child of the list parent, we want to dedent
// the whole list; otherwise, we only want to dedent the list content
// (and thus unwrap these content from the list node)
const range = isLastChildInListParent
? new NodeRange($from, $listEnd, listParentDepth)
: new NodeRange($from, $listEnd, listDepth)
const tr = state.tr
if (range && dedentNodeRange(range, tr)) {
dispatch?.(tr)
return true
}
return false
} else {
return doSplitList(state, listNode, dispatch)
}
} else {
if (parentEmpty) {
return enterWithoutLift(state, dispatch)
} else {
return false
}
}
}
/**
* @internal
*/
export function doSplitList(
state: EditorState,
listNode: ProsemirrorNode,
dispatch?: (tr: Transaction) => void,
): boolean {
const tr = state.tr
const listType = listNode.type
const attrs: ListAttributes = listNode.attrs
const newAttrs: ListAttributes = deriveListAttributes(listNode)
tr.delete(tr.selection.from, tr.selection.to)
const { $from, $to } = tr.selection
const { parentOffset } = $to
const atStart = parentOffset == 0
const atEnd = parentOffset == $to.parent.content.size
if (atStart) {
if (dispatch) {
const pos = $from.before(-1)
tr.insert(pos, createAndFill(listType, newAttrs))
dispatch(tr.scrollIntoView())
}
return true
}
if (atEnd && attrs.collapsed) {
if (dispatch) {
const pos = $from.after(-1)
tr.insert(pos, createAndFill(listType, newAttrs))
tr.setSelection(Selection.near(tr.doc.resolve(pos)))
dispatch(tr.scrollIntoView())
}
return true
}
// If split the list at the start or at the middle, we want to inherit the
// current parent type (e.g. heading); otherwise, we want to create a new
// default block type (typically paragraph)
const nextType = atEnd ? listNode.contentMatchAt(0).defaultType : undefined
const typesAfter = [
{ type: listType, attrs: newAttrs },
nextType ? { type: nextType } : null,
]
if (!canSplit(tr.doc, $from.pos, 2, typesAfter)) {
return false
}
dispatch?.(tr.split($from.pos, 2, typesAfter).scrollIntoView())
return true
}