UNPKG

@paperbits/prosemirror

Version:
194 lines (173 loc) 9.31 kB
import { findWrapping, liftTarget, canSplit, ReplaceAroundStep } from "prosemirror-transform"; import { Slice, Fragment, NodeRange } from "prosemirror-model"; // :: (NodeType, ?Object) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool // returns a command function that wraps the selection in a list with // the given type an attributes. If `dispatch` is null, only return a // value to indicate whether this is possible, but don't actually // perform the change. export function wrapInList(listType, attrs?) { return (state, dispatch) => { const { $from, $to } = state.selection; let range = $from.blockRange($to), doJoin = false, outerRange = range; if (!range) { return false; } // this is at the top of an existing list item if (range.depth >= 2 && $from.node(range.depth - 1).type.compatibleContent(listType) && range.startIndex === 0) { // don't do anything if this is the top of the list if ($from.index(range.depth - 1) === 0) { return false; } const $insert = state.doc.resolve(range.start - 2); outerRange = new NodeRange($insert, $insert, range.depth); if (range.endIndex < range.parent.childCount) { range = new NodeRange($from, state.doc.resolve($to.end(range.depth)), range.depth); } doJoin = true; } const wrap = findWrapping(outerRange, listType, attrs, range); if (!wrap) { return false; } if (dispatch) { dispatch(doWrapInList(state.tr, range, wrap, doJoin, listType).scrollIntoView()); } return true; }; } function doWrapInList(tr, range, wrappers, joinBefore, listType) { let content = Fragment.empty; for (let i = wrappers.length - 1; i >= 0; i--) { content = Fragment.from(wrappers[i].type.create(wrappers[i].attrs, content)); } tr.step(new ReplaceAroundStep(range.start - (joinBefore ? 2 : 0), range.end, range.start, range.end, new Slice(content, 0, 0), wrappers.length, true)); let found = 0; for (let i = 0; i < wrappers.length; i++) { if (wrappers[i].type === listType) { found = i + 1; } } const splitDepth = wrappers.length - found; let splitPos = range.start + wrappers.length - (joinBefore ? 2 : 0), parent = range.parent; for (let i = range.startIndex, e = range.endIndex, first = true; i < e; i++ , first = false) { if (!first && canSplit(tr.doc, splitPos, splitDepth)) { tr.split(splitPos, splitDepth); splitPos += 2 * splitDepth; } splitPos += parent.child(i).nodeSize; } return tr; } // :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool // build a command that splits a non-empty textblock at the top level // of a list item by also splitting that list item. export function splitListItem(itemType) { return (state, dispatch) => { const { $from, $to, node } = state.selection; if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) { return false; } const grandParent = $from.node(-1); if (grandParent.type !== itemType) { return false; } if ($from.parent.content.size === 0) { // 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 !== itemType || $from.index(-2) !== $from.node(-2).childCount - 1) { return false; } if (dispatch) { let wrap = Fragment.empty, keepItem = $from.index(-1) > 0; // 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 - (keepItem ? 1 : 2); d >= $from.depth - 3; d--) { wrap = Fragment.from($from.node(d).copy(wrap)); } // add a second list item with an empty default start node wrap = wrap.append(Fragment.from(itemType.createAndFill())); const tr = state.tr.replace($from.before(keepItem ? null : -1), $from.after(-3), new Slice(wrap, keepItem ? 3 : 2, 2)); tr.setSelection(state.selection.constructor.near(tr.doc.resolve($from.pos + (keepItem ? 3 : 2)))); dispatch(tr.scrollIntoView()); } return true; } const nextType = $to.pos === $from.end() ? grandParent.contentMatchAt($from.indexAfter(-1)).defaultType : null; const tr = state.tr.delete($from.pos, $to.pos); const types = nextType && [null, { type: nextType }]; if (!canSplit(tr.doc, $from.pos, 2, types)) { return false; } if (dispatch) { dispatch(tr.split($from.pos, 2, types).scrollIntoView()); } return true; }; } // :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool // create a command to lift the list item around the selection up into // a wrapping list. export function liftListItem(itemType) { return (state, dispatch) => { const { $from, $to } = state.selection; const range = $from.blockRange($to, node => node.childCount && node.firstChild.type === itemType); if (!range) { return false; } if (!dispatch) { return true; } if ($from.node(range.depth - 1).type === itemType) { // inside a parent list return liftToOuterList(state, dispatch, itemType, range); } else { // outer list node return liftOutOfList(state, dispatch, range); } }; } function liftToOuterList(state, dispatch, itemType, range) { const tr = state.tr, end = range.end, 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); } dispatch(tr.lift(range, liftTarget(range)).scrollIntoView()); return true; } function liftOutOfList(state, dispatch, range) { const tr = state.tr, 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), item = $start.nodeAfter; const atStart = range.startIndex === 0, atEnd = range.endIndex === list.childCount; const parent = $start.node(-1), 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, 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)); dispatch(tr.scrollIntoView()); return true; } // :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool // create a command to sink the list item around the selection down // into an inner list. export function sinkListItem(itemType) { return function (state, dispatch) { const { $from, $to } = state.selection; const range = $from.blockRange($to, node => node.childCount && node.firstChild.type === itemType); if (!range) { return false; } const startIndex = range.startIndex; if (startIndex === 0) { return false; } const parent = range.parent, nodeBefore = parent.child(startIndex - 1); if (nodeBefore.type !== itemType) { return false; } if (dispatch) { const nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type === parent.type; const inner = Fragment.from(nestedBefore ? itemType.create() : null); const slice = new Slice(Fragment.from(itemType.create(null, Fragment.from(parent.copy(inner)))), nestedBefore ? 3 : 1, 0); const before = range.start, after = range.end; dispatch(state.tr.step(new ReplaceAroundStep(before - (nestedBefore ? 3 : 1), after, before, after, slice, 1, true)) .scrollIntoView()); } return true; }; }