index.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import { findWrapping, ReplaceAroundStep, canSplit, liftTarget, canJoin } from 'prosemirror-transform';
  2. import { NodeRange, Fragment, Slice } from 'prosemirror-model';
  3. import { Selection } from 'prosemirror-state';
  4. const olDOM = ["ol", 0], ulDOM = ["ul", 0], liDOM = ["li", 0];
  5. /**
  6. An ordered list [node spec](https://prosemirror.net/docs/ref/#model.NodeSpec). Has a single
  7. attribute, `order`, which determines the number at which the list
  8. starts counting, and defaults to 1. Represented as an `<ol>`
  9. element.
  10. */
  11. const orderedList = {
  12. attrs: { order: { default: 1 } },
  13. parseDOM: [{ tag: "ol", getAttrs(dom) {
  14. return { order: dom.hasAttribute("start") ? +dom.getAttribute("start") : 1 };
  15. } }],
  16. toDOM(node) {
  17. return node.attrs.order == 1 ? olDOM : ["ol", { start: node.attrs.order }, 0];
  18. }
  19. };
  20. /**
  21. A bullet list node spec, represented in the DOM as `<ul>`.
  22. */
  23. const bulletList = {
  24. parseDOM: [{ tag: "ul" }],
  25. toDOM() { return ulDOM; }
  26. };
  27. /**
  28. A list item (`<li>`) spec.
  29. */
  30. const listItem = {
  31. parseDOM: [{ tag: "li" }],
  32. toDOM() { return liDOM; },
  33. defining: true
  34. };
  35. function add(obj, props) {
  36. let copy = {};
  37. for (let prop in obj)
  38. copy[prop] = obj[prop];
  39. for (let prop in props)
  40. copy[prop] = props[prop];
  41. return copy;
  42. }
  43. /**
  44. Convenience function for adding list-related node types to a map
  45. specifying the nodes for a schema. Adds
  46. [`orderedList`](https://prosemirror.net/docs/ref/#schema-list.orderedList) as `"ordered_list"`,
  47. [`bulletList`](https://prosemirror.net/docs/ref/#schema-list.bulletList) as `"bullet_list"`, and
  48. [`listItem`](https://prosemirror.net/docs/ref/#schema-list.listItem) as `"list_item"`.
  49. `itemContent` determines the content expression for the list items.
  50. If you want the commands defined in this module to apply to your
  51. list structure, it should have a shape like `"paragraph block*"` or
  52. `"paragraph (ordered_list | bullet_list)*"`. `listGroup` can be
  53. given to assign a group name to the list node types, for example
  54. `"block"`.
  55. */
  56. function addListNodes(nodes, itemContent, listGroup) {
  57. return nodes.append({
  58. ordered_list: add(orderedList, { content: "list_item+", group: listGroup }),
  59. bullet_list: add(bulletList, { content: "list_item+", group: listGroup }),
  60. list_item: add(listItem, { content: itemContent })
  61. });
  62. }
  63. /**
  64. Returns a command function that wraps the selection in a list with
  65. the given type an attributes. If `dispatch` is null, only return a
  66. value to indicate whether this is possible, but don't actually
  67. perform the change.
  68. */
  69. function wrapInList(listType, attrs = null) {
  70. return function (state, dispatch) {
  71. let { $from, $to } = state.selection;
  72. let range = $from.blockRange($to), doJoin = false, outerRange = range;
  73. if (!range)
  74. return false;
  75. // This is at the top of an existing list item
  76. if (range.depth >= 2 && $from.node(range.depth - 1).type.compatibleContent(listType) && range.startIndex == 0) {
  77. // Don't do anything if this is the top of the list
  78. if ($from.index(range.depth - 1) == 0)
  79. return false;
  80. let $insert = state.doc.resolve(range.start - 2);
  81. outerRange = new NodeRange($insert, $insert, range.depth);
  82. if (range.endIndex < range.parent.childCount)
  83. range = new NodeRange($from, state.doc.resolve($to.end(range.depth)), range.depth);
  84. doJoin = true;
  85. }
  86. let wrap = findWrapping(outerRange, listType, attrs, range);
  87. if (!wrap)
  88. return false;
  89. if (dispatch)
  90. dispatch(doWrapInList(state.tr, range, wrap, doJoin, listType).scrollIntoView());
  91. return true;
  92. };
  93. }
  94. function doWrapInList(tr, range, wrappers, joinBefore, listType) {
  95. let content = Fragment.empty;
  96. for (let i = wrappers.length - 1; i >= 0; i--)
  97. content = Fragment.from(wrappers[i].type.create(wrappers[i].attrs, content));
  98. tr.step(new ReplaceAroundStep(range.start - (joinBefore ? 2 : 0), range.end, range.start, range.end, new Slice(content, 0, 0), wrappers.length, true));
  99. let found = 0;
  100. for (let i = 0; i < wrappers.length; i++)
  101. if (wrappers[i].type == listType)
  102. found = i + 1;
  103. let splitDepth = wrappers.length - found;
  104. let splitPos = range.start + wrappers.length - (joinBefore ? 2 : 0), parent = range.parent;
  105. for (let i = range.startIndex, e = range.endIndex, first = true; i < e; i++, first = false) {
  106. if (!first && canSplit(tr.doc, splitPos, splitDepth)) {
  107. tr.split(splitPos, splitDepth);
  108. splitPos += 2 * splitDepth;
  109. }
  110. splitPos += parent.child(i).nodeSize;
  111. }
  112. return tr;
  113. }
  114. /**
  115. Build a command that splits a non-empty textblock at the top level
  116. of a list item by also splitting that list item.
  117. */
  118. function splitListItem(itemType, itemAttrs) {
  119. return function (state, dispatch) {
  120. let { $from, $to, node } = state.selection;
  121. if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to))
  122. return false;
  123. let grandParent = $from.node(-1);
  124. if (grandParent.type != itemType)
  125. return false;
  126. if ($from.parent.content.size == 0 && $from.node(-1).childCount == $from.indexAfter(-1)) {
  127. // In an empty block. If this is a nested list, the wrapping
  128. // list item should be split. Otherwise, bail out and let next
  129. // command handle lifting.
  130. if ($from.depth == 3 || $from.node(-3).type != itemType ||
  131. $from.index(-2) != $from.node(-2).childCount - 1)
  132. return false;
  133. if (dispatch) {
  134. let wrap = Fragment.empty;
  135. let depthBefore = $from.index(-1) ? 1 : $from.index(-2) ? 2 : 3;
  136. // Build a fragment containing empty versions of the structure
  137. // from the outer list item to the parent node of the cursor
  138. for (let d = $from.depth - depthBefore; d >= $from.depth - 3; d--)
  139. wrap = Fragment.from($from.node(d).copy(wrap));
  140. let depthAfter = $from.indexAfter(-1) < $from.node(-2).childCount ? 1
  141. : $from.indexAfter(-2) < $from.node(-3).childCount ? 2 : 3;
  142. // Add a second list item with an empty default start node
  143. wrap = wrap.append(Fragment.from(itemType.createAndFill()));
  144. let start = $from.before($from.depth - (depthBefore - 1));
  145. let tr = state.tr.replace(start, $from.after(-depthAfter), new Slice(wrap, 4 - depthBefore, 0));
  146. let sel = -1;
  147. tr.doc.nodesBetween(start, tr.doc.content.size, (node, pos) => {
  148. if (sel > -1)
  149. return false;
  150. if (node.isTextblock && node.content.size == 0)
  151. sel = pos + 1;
  152. });
  153. if (sel > -1)
  154. tr.setSelection(Selection.near(tr.doc.resolve(sel)));
  155. dispatch(tr.scrollIntoView());
  156. }
  157. return true;
  158. }
  159. let nextType = $to.pos == $from.end() ? grandParent.contentMatchAt(0).defaultType : null;
  160. let tr = state.tr.delete($from.pos, $to.pos);
  161. let types = nextType ? [itemAttrs ? { type: itemType, attrs: itemAttrs } : null, { type: nextType }] : undefined;
  162. if (!canSplit(tr.doc, $from.pos, 2, types))
  163. return false;
  164. if (dispatch)
  165. dispatch(tr.split($from.pos, 2, types).scrollIntoView());
  166. return true;
  167. };
  168. }
  169. /**
  170. Create a command to lift the list item around the selection up into
  171. a wrapping list.
  172. */
  173. function liftListItem(itemType) {
  174. return function (state, dispatch) {
  175. let { $from, $to } = state.selection;
  176. let range = $from.blockRange($to, node => node.childCount > 0 && node.firstChild.type == itemType);
  177. if (!range)
  178. return false;
  179. if (!dispatch)
  180. return true;
  181. if ($from.node(range.depth - 1).type == itemType) // Inside a parent list
  182. return liftToOuterList(state, dispatch, itemType, range);
  183. else // Outer list node
  184. return liftOutOfList(state, dispatch, range);
  185. };
  186. }
  187. function liftToOuterList(state, dispatch, itemType, range) {
  188. let tr = state.tr, end = range.end, endOfList = range.$to.end(range.depth);
  189. if (end < endOfList) {
  190. // There are siblings after the lifted items, which must become
  191. // children of the last item
  192. tr.step(new ReplaceAroundStep(end - 1, endOfList, end, endOfList, new Slice(Fragment.from(itemType.create(null, range.parent.copy())), 1, 0), 1, true));
  193. range = new NodeRange(tr.doc.resolve(range.$from.pos), tr.doc.resolve(endOfList), range.depth);
  194. }
  195. const target = liftTarget(range);
  196. if (target == null)
  197. return false;
  198. tr.lift(range, target);
  199. let after = tr.mapping.map(end, -1) - 1;
  200. if (canJoin(tr.doc, after))
  201. tr.join(after);
  202. dispatch(tr.scrollIntoView());
  203. return true;
  204. }
  205. function liftOutOfList(state, dispatch, range) {
  206. let tr = state.tr, list = range.parent;
  207. // Merge the list items into a single big item
  208. for (let pos = range.end, i = range.endIndex - 1, e = range.startIndex; i > e; i--) {
  209. pos -= list.child(i).nodeSize;
  210. tr.delete(pos - 1, pos + 1);
  211. }
  212. let $start = tr.doc.resolve(range.start), item = $start.nodeAfter;
  213. if (tr.mapping.map(range.end) != range.start + $start.nodeAfter.nodeSize)
  214. return false;
  215. let atStart = range.startIndex == 0, atEnd = range.endIndex == list.childCount;
  216. let parent = $start.node(-1), indexBefore = $start.index(-1);
  217. if (!parent.canReplace(indexBefore + (atStart ? 0 : 1), indexBefore + 1, item.content.append(atEnd ? Fragment.empty : Fragment.from(list))))
  218. return false;
  219. let start = $start.pos, end = start + item.nodeSize;
  220. // Strip off the surrounding list. At the sides where we're not at
  221. // the end of the list, the existing list is closed. At sides where
  222. // this is the end, it is overwritten to its end.
  223. 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)))
  224. .append(atEnd ? Fragment.empty : Fragment.from(list.copy(Fragment.empty))), atStart ? 0 : 1, atEnd ? 0 : 1), atStart ? 0 : 1));
  225. dispatch(tr.scrollIntoView());
  226. return true;
  227. }
  228. /**
  229. Create a command to sink the list item around the selection down
  230. into an inner list.
  231. */
  232. function sinkListItem(itemType) {
  233. return function (state, dispatch) {
  234. let { $from, $to } = state.selection;
  235. let range = $from.blockRange($to, node => node.childCount > 0 && node.firstChild.type == itemType);
  236. if (!range)
  237. return false;
  238. let startIndex = range.startIndex;
  239. if (startIndex == 0)
  240. return false;
  241. let parent = range.parent, nodeBefore = parent.child(startIndex - 1);
  242. if (nodeBefore.type != itemType)
  243. return false;
  244. if (dispatch) {
  245. let nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type == parent.type;
  246. let inner = Fragment.from(nestedBefore ? itemType.create() : null);
  247. let slice = new Slice(Fragment.from(itemType.create(null, Fragment.from(parent.type.create(null, inner)))), nestedBefore ? 3 : 1, 0);
  248. let before = range.start, after = range.end;
  249. dispatch(state.tr.step(new ReplaceAroundStep(before - (nestedBefore ? 3 : 1), after, before, after, slice, 1, true))
  250. .scrollIntoView());
  251. }
  252. return true;
  253. };
  254. }
  255. export { addListNodes, bulletList, liftListItem, listItem, orderedList, sinkListItem, splitListItem, wrapInList };