@paperbits/prosemirror
Version:
Paperbits HTML editor based on ProseMirror.
194 lines (173 loc) • 9.31 kB
text/typescript
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;
};
}