prosemirror-flat-list
Version:
Powerful list support for ProseMirror
280 lines (238 loc) • 6.92 kB
text/typescript
import { Fragment, NodeRange, Slice } from 'prosemirror-model'
import type { Command, Transaction } from 'prosemirror-state'
import { ReplaceAroundStep } from 'prosemirror-transform'
import { withAutoFixList } from '../utils/auto-fix-list'
import {
atEndBlockBoundary,
atStartBlockBoundary,
} from '../utils/block-boundary'
import { getListType } from '../utils/get-list-type'
import { isListNode } from '../utils/is-list-node'
import { findListsRange, isListsRange } from '../utils/list-range'
import { mapPos } from '../utils/map-pos'
import { safeLift } from '../utils/safe-lift'
import { zoomInRange } from '../utils/zoom-in-range'
import { withVisibleSelection } from './set-safe-selection'
/**
* @public @group Commands
*/
export interface DedentListOptions {
/**
* 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 decreases the indentation of selected list nodes.
*
* @public @group Commands
*/
export function createDedentListCommand(options?: DedentListOptions): Command {
const dedentListCommand: 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)
if (!range) return false
if (dedentRange(range, tr)) {
dispatch?.(tr)
return true
}
return false
}
return withVisibleSelection(withAutoFixList(dedentListCommand))
}
function dedentRange(
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 ? dedentRange(contentRange, tr) : false
} else {
return splitAndDedentRange(range, tr, startIndex + 1)
}
}
endBoundary = endBoundary || atEndBlockBoundary($to, depth + 1)
if (!endBoundary) {
fixEndBoundary(range, tr)
const endOfParent = $to.end(depth)
range = new NodeRange(
tr.doc.resolve($from.pos),
tr.doc.resolve(endOfParent),
depth,
)
return dedentRange(range, tr, undefined, true)
}
if (
range.startIndex === 0 &&
range.endIndex === range.parent.childCount &&
isListNode(range.parent)
) {
return dedentNodeRange(new NodeRange($from, $to, depth - 1), tr)
}
return dedentNodeRange(range, tr)
}
/**
* Split a range into two parts, and dedent them separately.
*/
function splitAndDedentRange(
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)
dedentRange(range1, tr, undefined, true)
let range2 = tr.doc
.resolve(getRange2From())
.blockRange(tr.doc.resolve(getRange2To()))
if (range2 && range2.depth >= depth) {
range2 = new NodeRange(range2.$from, range2.$to, depth)
dedentRange(range2, tr, true, undefined)
}
return true
}
export function dedentNodeRange(range: NodeRange, tr: Transaction) {
if (isListNode(range.parent)) {
return safeLiftRange(tr, range)
} else if (isListsRange(range)) {
return dedentOutOfList(tr, range)
} else {
return safeLiftRange(tr, range)
}
}
function safeLiftRange(tr: Transaction, range: NodeRange): boolean {
if (moveRangeSiblings(tr, range)) {
const $from = tr.doc.resolve(range.$from.pos)
const $to = tr.doc.resolve(range.$to.pos)
range = new NodeRange($from, $to, range.depth)
}
return safeLift(tr, range)
}
function moveRangeSiblings(tr: Transaction, range: NodeRange): boolean {
const listType = getListType(tr.doc.type.schema)
const { $to, depth, end, parent, endIndex } = range
const endOfParent = $to.end(depth)
if (end < endOfParent) {
// There are siblings after the lifted items, which must become
// children of the last item
const lastChild = parent.maybeChild(endIndex - 1)
if (!lastChild) return false
const canAppend =
endIndex < parent.childCount &&
lastChild.canReplace(
lastChild.childCount,
lastChild.childCount,
parent.content,
endIndex,
parent.childCount,
)
if (canAppend) {
tr.step(
new ReplaceAroundStep(
end - 1,
endOfParent,
end,
endOfParent,
new Slice(Fragment.from(listType.create(null)), 1, 0),
0,
true,
),
)
return true
} else {
tr.step(
new ReplaceAroundStep(
end,
endOfParent,
end,
endOfParent,
new Slice(Fragment.from(listType.create(null)), 0, 0),
1,
true,
),
)
return true
}
}
return false
}
function fixEndBoundary(range: NodeRange, tr: Transaction): void {
if (range.endIndex - range.startIndex >= 2) {
range = new NodeRange(
range.$to.doc.resolve(
range.$to.posAtIndex(range.endIndex - 1, range.depth),
),
range.$to,
range.depth,
)
}
const contentRange = zoomInRange(range)
if (contentRange) {
fixEndBoundary(contentRange, tr)
range = new NodeRange(
tr.doc.resolve(range.$from.pos),
tr.doc.resolve(range.$to.pos),
range.depth,
)
}
moveRangeSiblings(tr, range)
}
export function dedentOutOfList(tr: Transaction, range: NodeRange): boolean {
const { startIndex, endIndex, parent } = range
const getRangeStart = mapPos(tr, range.start)
const getRangeEnd = mapPos(tr, range.end)
// Merge the list nodes into a single big list node
for (let end = getRangeEnd(), i = endIndex - 1; i > startIndex; i--) {
end -= parent.child(i).nodeSize
tr.delete(end - 1, end + 1)
}
const $start = tr.doc.resolve(getRangeStart())
const listNode = $start.nodeAfter
if (!listNode) return false
const start = range.start
const end = start + listNode.nodeSize
if (getRangeEnd() !== end) return false
if (
!$start.parent.canReplace(
startIndex,
startIndex + 1,
Fragment.from(listNode),
)
) {
return false
}
tr.step(
new ReplaceAroundStep(
start,
end,
start + 1,
end - 1,
new Slice(Fragment.empty, 0, 0),
0,
true,
),
)
return true
}