prosemirror-flat-list
Version:
Powerful list support for ProseMirror
186 lines (159 loc) • 4.95 kB
text/typescript
import { Fragment, type NodeRange, Slice } from 'prosemirror-model'
import type { Command, Transaction } from 'prosemirror-state'
import { ReplaceAroundStep } from 'prosemirror-transform'
import type { ListAttributes } from '../types'
import { withAutoFixList } from '../utils/auto-fix-list'
import {
atEndBlockBoundary,
atStartBlockBoundary,
} from '../utils/block-boundary'
import { getListType } from '../utils/get-list-type'
import { inCollapsedList } from '../utils/in-collapsed-list'
import { isListNode } from '../utils/is-list-node'
import { findListsRange } from '../utils/list-range'
import { mapPos } from '../utils/map-pos'
import { zoomInRange } from '../utils/zoom-in-range'
import { withVisibleSelection } from './set-safe-selection'
/**
* @public @group Commands
*/
export interface IndentListOptions {
/**
* A optional from position to indent.
*
* @defaultValue `state.selection.from`
*/
from?: number
/**
* A optional to position to indent.
*
* @defaultValue `state.selection.to`
*/
to?: number
}
/**
* Returns a command function that increases the indentation of selected list
* nodes.
*
* @public @group Commands
*/
export function createIndentListCommand(options?: IndentListOptions): Command {
const indentListCommand: Command = (state, dispatch): boolean => {
const tr = state.tr
const $from =
options?.from == null ? tr.selection.$from : tr.doc.resolve(options.from)
const $to =
options?.to == null ? tr.selection.$to : tr.doc.resolve(options.to)
const range = findListsRange($from, $to) || $from.blockRange($to)
if (!range) return false
if (indentRange(range, tr)) {
dispatch?.(tr)
return true
}
return false
}
return withVisibleSelection(withAutoFixList(indentListCommand))
}
function indentRange(
range: NodeRange,
tr: Transaction,
startBoundary?: boolean,
endBoundary?: boolean,
): boolean {
const { depth, $from, $to } = range
startBoundary = startBoundary || atStartBlockBoundary($from, depth + 1)
if (!startBoundary) {
const { startIndex, endIndex } = range
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range)
return contentRange ? indentRange(contentRange, tr) : false
} else {
return splitAndIndentRange(range, tr, startIndex + 1)
}
}
endBoundary = endBoundary || atEndBlockBoundary($to, depth + 1)
if (!endBoundary && !inCollapsedList($to)) {
const { startIndex, endIndex } = range
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range)
return contentRange ? indentRange(contentRange, tr) : false
} else {
return splitAndIndentRange(range, tr, endIndex - 1)
}
}
return indentNodeRange(range, tr)
}
/**
* Split a range into two parts, and indent them separately.
*/
function splitAndIndentRange(
range: NodeRange,
tr: Transaction,
splitIndex: number,
): boolean {
const { $from, $to, depth } = range
const splitPos = $from.posAtIndex(splitIndex, depth)
const range1 = $from.blockRange(tr.doc.resolve(splitPos - 1))
if (!range1) return false
const getRange2From = mapPos(tr, splitPos + 1)
const getRange2To = mapPos(tr, $to.pos)
indentRange(range1, tr, undefined, true)
const range2 = tr.doc
.resolve(getRange2From())
.blockRange(tr.doc.resolve(getRange2To()))
if (range2) {
indentRange(range2, tr, true, undefined)
}
return true
}
/**
* Increase the indentation of a block range.
*/
function indentNodeRange(range: NodeRange, tr: Transaction): boolean {
const listType = getListType(tr.doc.type.schema)
const { parent, startIndex } = range
const prevChild = startIndex >= 1 && parent.child(startIndex - 1)
// If the previous node before the range is a list node, move the range into
// the previous list node as its children
if (prevChild && isListNode(prevChild)) {
const { start, end } = range
tr.step(
new ReplaceAroundStep(
start - 1,
end,
start,
end,
new Slice(Fragment.from(listType.create(null)), 1, 0),
0,
true,
),
)
return true
}
// If we can avoid to add a new bullet visually, we can wrap the range with a
// new list node.
const isParentListNode = isListNode(parent)
const isFirstChildListNode = isListNode(parent.maybeChild(startIndex))
if ((startIndex === 0 && isParentListNode) || isFirstChildListNode) {
const { start, end } = range
const listAttrs: ListAttributes | null = isFirstChildListNode
? parent.child(startIndex).attrs
: isParentListNode
? parent.attrs
: null
tr.step(
new ReplaceAroundStep(
start,
end,
start,
end,
new Slice(Fragment.from(listType.create(listAttrs)), 0, 0),
1,
true,
),
)
return true
}
// Otherwise we cannot indent
return false
}