import { chainCommands as chain, autoJoin } from "prosemirror-commands"; import { Fragment, NodeRange, NodeType as ProsemirrorNodeType, ResolvedPos, Slice } from "prosemirror-model"; import { EditorState, NodeSelection, Transaction } from "prosemirror-state"; import { canSplit, liftTarget, ReplaceAroundStep } from "prosemirror-transform"; import { UxCommand } from "../constants"; import { closest } from "../pquery"; import { filter, not, startCursor, textSelected } from "../predicate"; import { EditorSchema, schema } from "../schema"; import { Command, Dispatch } from "../types"; import { cutMatches, findCutAfter, findCutBefore, getCursor, getCursorIfEnd, getCursorIfStart } from "../util"; export function sinkListItem(state: EditorState, dispatch?: Dispatch): boolean { const { $from, $to } = state.selection; // Multiple sibling list items can be sunk together. We use block range to // select adjacent list items containing the selection. // // Example: (selection=5-15) // // 0 │ // ul // 1 └── li // 2 └── p 4 5 6 7 8 // 3 └── 'I' ─ 't' ━ 'e' ━ 'm' ━ ' ' ━ '1' ━┓ // 9 ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ // 10 ┏━━ /p // /li // 11 ┃ // li // 12 ┗━━ p 14 15 16 17 18 // 13 ┗━━ 'I' ━ 't' ━ 'e' ─ 'm' ─ ' ' ─ '2' ─┐ // 19 ┌──────────────────────────────────────┘ // 20 ┌── /p // /li // 21 │ // li // 22 └── p 24 25 26 27 28 // 23 └── 'I' ─ 't' ─ 'e' ─ 'm' ─ ' ' ─ '3' ─┐ // 29 ┌──────────────────────────────────────┘ // 30 ┌── /p // 31 ┌── /li // /ul // 32 │ // // Result: (selection=1-21) // // 0 │ // ul // 1 ┗━━ li // 2 ┗━━ p 4 5 6 7 8 // 3 ┗━━ 'I' ━ 't' ━ 'e' ━ 'm' ━ ' ' ━ '1' ━┓ // 9 ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ // 10 ┏━━ /p // /li // 11 ┃ // li // 12 ┗━━ p 14 15 16 17 18 // 13 ┗━━ 'I' ━ 't' ━ 'e' ━ 'm' ━ ' ' ━ '2' ━┓ // 19 ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ // 20 ┏━━ /p // /li // 21 ┃ // li // 22 └── p 24 25 26 27 28 // 23 └── 'I' ─ 't' ─ 'e' ─ 'm' ─ ' ' ─ '3' ─┐ // 29 ┌──────────────────────────────────────┘ // 30 ┌── /p // 31 ┌── /li // /ul // 32 │ // const range = $from.blockRange($to, node => node.firstChild != null && node.firstChild.type == schema.nodes.li); if (range != null) { const tr = state.tr; if (doSinkListItem(tr, range)) { if (dispatch !== undefined) { dispatch(tr.scrollIntoView()); } return true; } } return false; } function doSinkListItem(tr: Transaction, range: NodeRange): boolean { // The first item in a list can't be sunk, as sinking requires nesting // a new list inside a previous-sibling list item. // // Example: // // 0 │ // ul // 1 └── li // 2 └── p // 3 │ // 4 ┌── /p // /li // 5 │ // li ⬅ sink // 6 └── p // 7 │ // 8 ┌── /p // 9 ┌── /li // /ul // 10 │ // // Result: // // 0 │ // ul ⬅ oldList // 1 └── li ⬅ newListParent // 2 └── p // 3 │ // /p // 4 │ // ul ⬅ newList // 5 └── li // 6 └── p // 7 │ // 8 ┌── /p // 9 ┌── /li // 10 ┌── /ul // 11 ┌── /li // /ul // 12 │ // if (range.startIndex > 0) { const oldList = range.parent; const newListParent = oldList.child(range.startIndex - 1); const newListAlreadyExists = newListParent.lastChild != null && newListParent.lastChild.type == oldList.type; const before = range.start; const after = range.end; if (newListAlreadyExists) { // // Slice: // // 0 ┆ // li ⬅ synthetic newListParent AKA previous list item // 1 └┄┄ ul ⬅ copy of oldList (used as newList) // 2 └┄┄ li // 3 ┃ ⬅ start [openStart=3] // 4 ┏━━ /li // 5 ┏━━ /ul // /li // 6 ┃ ⬅ end [openEnd=0] // const slice = new Slice( Fragment.from(schema.nodes.li.create(undefined, Fragment.from(oldList.copy(Fragment.from(schema.nodes.li.create()))))), 3, 0 ); // The step moves and splices the selected list items onto the end of the // new host list. tr.step(new ReplaceAroundStep(before - 3, after, before, after, slice, 1, true)); } else { // // Slice: // // 0 ┆ // li ⬅ synthetic `newListParent` AKA previous list item // 1 └┄┄ ul ⬅ copy of `oldList` (used as `newList`) // 2 ┃ ⬅ start [openStart=1] // 3 ┏━━ /ul // /li // 4 ┃ ⬅ end [openEnd=0] // const slice = new Slice(Fragment.from(schema.nodes.li.create(undefined, oldList.copy())), 1, 0); tr.step(new ReplaceAroundStep(before - 1, after, before, after, slice, 1, true)); } return true; } return false; } function joinPToPreviousListTail(state: EditorState, dispatch?: Dispatch): boolean { // Example: // // 0 1 2 3 (depth) // ↓ ↓ ↓ ↓ // (index) doc // 0 → 0 └── ul // 0 → 1 └── li // 0 → 2 └── p 4 5 6 7 8 // 3 └── 'I' ─ 't' ─ 'e' ─ 'm' ─ ' ' ─ '1' ─┐ // 9 ┌──────────────────────────────────────┘ // 10 ┌── /p // 11 ┌── /li // /ul // 12 │ // 1 → p 14 15 16 17 18 // 13 └── 'F' ─ 'o' ─ 'o' ─ 'm' ─ ' ' ─ '1' ─┐ ⬅ $cursor.pos=13 (depth=1) // 19 ┌──────────────────────────────────────┘ // 20 ┌── /p // doc // // // Result: // // 0 1 2 3 (depth) // ↓ ↓ ↓ ↓ // (index) doc // 0 → 0 └── ul // 0 → 1 └── li // 0 → 2 └── p 4 5 6 7 8 9 10 11 12 13 14 // 3 └── 'I' ─ 't' ─ 'e' ─ 'm' ─ ' ' ─ '1' ─ 'F' ─ 'o' ─ 'o' ─ 'm' ─ ' ' ─ '1' ─┐ ⬅ $cursor.pos=9 // 15 ┌──────────────────────────────────────────────────────────────────────────┘ // 16 ┌── /p // 17 ┌── /li // 18 ┌── /ul // doc // const $cursor = getCursorIfStart(state); if ($cursor !== null) { const $cut = findCutBefore($cursor); if ($cut !== null && cutMatches($cut, ["ul", "ol"], ["p"])) { const tr = state.tr; if (doJoinPToPreviousList(tr, $cut)) { if (dispatch !== undefined) { dispatch(tr.scrollIntoView()); } return true; } } } return false; } function joinNextPToListTail(state: EditorState, dispatch?: Dispatch): boolean { // Example: // // 0 1 2 3 (depth) // ↓ ↓ ↓ ↓ // (index) doc // 0 → 0 └── ul // 0 → 1 └── li // 0 → 2 └── p 4 5 6 7 8 // 3 └── 'I' ─ 't' ─ 'e' ─ 'm' ─ ' ' ─ '1' ─┐ // 9 ┌──────────────────────────────────────┘ ⬅ $cursor.pos=9 (depth=3) // 10 ┌── /p // 11 ┌── /li // /ul // 12 │ ⬅ $cut // 1 → p 14 15 16 17 18 // 13 └── 'F' ─ 'o' ─ 'o' ─ 'm' ─ ' ' ─ '1' ─┐ // 19 ┌──────────────────────────────────────┘ // 20 ┌── /p // doc // // Result: // // 0 1 2 3 (depth) // ↓ ↓ ↓ ↓ // (index) doc // 0 → 0 └── ul // 0 → 1 └── li // 0 → 2 └── p 4 5 6 7 8 9 10 11 12 13 14 // 3 └── 'I' ─ 't' ─ 'e' ─ 'm' ─ ' ' ─ '1' ─ 'F' ─ 'o' ─ 'o' ─ 'm' ─ ' ' ─ '1' ─┐ ⬅ $cursor.pos=9 // 15 ┌──────────────────────────────────────────────────────────────────────────┘ // 16 ┌── /p // 17 ┌── /li // 18 ┌── /ul // doc // // This isn't limited to single depth lists, it's generalised to arbitrary depth lists. const $cursor = getCursorIfEnd(state); if ($cursor !== null) { const $cut = findCutAfter($cursor); if ($cut !== null && cutMatches($cut, ["ul", "ol"], ["p"])) { const tr = state.tr; if (doJoinPToPreviousList(tr, $cut)) { if (dispatch !== undefined) { dispatch(tr.scrollIntoView()); } return true; } } } return false; } function doJoinPToPreviousList(tr: Transaction, $cut: ResolvedPos): boolean { // Resolve the position in the last p in the list, this is the insertion // point. let $insert = tr.doc.resolve($cut.pos - 1); while ($insert.parent.type !== schema.nodes.p) { $insert = tr.doc.resolve($insert.pos - 1); } tr.step( new ReplaceAroundStep( $insert.pos, $cut.pos + $cut.nodeAfter!.nodeSize, $cut.pos + 1, $cut.pos + $cut.nodeAfter!.nodeSize - 1, // Slice: // // 0 │ // ul // 1 └── li // 2 └── p 4 5 6 7 8 // 3 └── 'I' ─ 't' ─ 'e' ─ 'm' ─ ' ' ─ '1' ━┓ // 9 ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ⬅ $insert, from // 10 ┏━━ /p // 11 ┏━━ /li // /ul // 12 ┃ ⬅ $cut // p 14 15 16 17 18 // 13 └── 'F' ─ 'o' ─ 'o' ─ 'm' ─ ' ' ─ '1' ─┐ // 19 ┌──────────────────────────────────────┘ // /p // 20 │ ⬅ to // tr.doc.slice($insert.pos, $cut.pos), 0, true ) ); return true; } export function liftListItem(state: EditorState, dispatch?: Dispatch): boolean { const { $from, $to } = state.selection; const range = $from.blockRange($to, node => node.firstChild != null && node.firstChild.type == schema.nodes.li); if (range == null) { return false; } const tr = state.tr; if (doLiftListItem(tr, range)) { if (dispatch !== undefined) { dispatch(tr.scrollIntoView()); } return true; } return false; } function doLiftListItem(tr: Transaction, range: NodeRange): boolean { const isNested = range.$from.node(range.depth - 1).type == schema.nodes.li; return isNested ? doliftToOuterList(tr, range) : doliftOutOfList(tr, range); } function doliftToOuterList(tr: Transaction, range: NodeRange): boolean { const end = range.end; const endOfList = range.$to.end(range.depth); if (end < endOfList) { // The li nodes being lifted are not the tail of the list (there are other // li nodes after the the ones being lifted. The tail of li nodes need to // moved into the last lifted li as a nested list. // // This is achieved by essentially "sinking" those li nodes into the last // lifted li node before we do the lift. There are two possible scenarios we // need to deal with: // // The last lifted li contains: // // 1. a single paragraph, or // 2. a paragraph with an adjacent nested list if (tr.doc.resolve(end).nodeBefore!.childCount === 1) { // Scenario #1: // // In the case of #1, we can wrap all the tail li nodes in a list, and then // splice it immediately after the p: // // 0 │ // ul // 1 └── li // 2 └── p // 3 └── '1' ─┐ // 4 ┌────────┘ // /p // 5 │ // ul // 6 └── li // 7 └── p 9 10 // 8 └── '1' ─ '.' ─ '1' ─┐ // 11 ┌────────────────────┘ // 12 ┌── /p ⬅ ReplaceAroundStep#from (12) // /li // 13 │ ⬅ range.end, ReplaceAroundStep#gapFrom (13) // li // 14 └── p 16 17 // 15 └── '1' ─ '.' ─ '2' ─┐ // 18 ┌────────────────────┘ // 19 ┌── /p // 20 ┌── /li ⬅ endOfList, ReplaceAroundStep#to, ReplaceAroundStep#gapTo (20) // 21 ┌── /ul // 22 ┌── /li // /ul // 23 │ // // Slice: // // 0 │ // li // 1 └── ul ⬅ openStart (1) // 2 │ ⬅ ReplaceAroundStep#insert (1) // 3 ┌── /ul // /li // 4 │ ⬅ openEnd (0) // tr.step( new ReplaceAroundStep( end - 1, endOfList, end, endOfList, new Slice(Fragment.from(schema.nodes.li.create(undefined, range.parent.copy())), 1, 0), 1, true ) ); } else { // Scenario #2: // // In the case of #2, there's already a nested list in the last lifted li // node, so we don't want to add in *another* list (it's invalid to have // multiple nested lists in a single item), instead we just want to move all // the items up into that list. // // 0 │ // ul // 1 └── li // 2 └── p // 3 └── '1' ─┐ // 4 ┌────────┘ // /p // 5 │ // ul // 6 └── li // 7 └── p 9 10 // 8 └── '1' ─ '.' ─ '1' ─┐ // 11 ┌────────────────────┘ // /p // 12 │ // ul // 13 └── li // 14 └── p 16 17 18 19 // 15 └── '1' ─ '.' ─ '1' ─ '.' ─ '1' ─┐ // 20 ┌────────────────────────────────┘ // 21 ┌── /p // 22 ┌── /li ⬅ ReplaceAroundStep#from (23) // 23 ┌── /ul // /li // 24 │ ⬅ range.end, ReplaceAroundStep#gapFrom (24) // li // 25 └── p 27 28 // 26 └── '1' ─ '.' ─ '2' ─┐ // 29 ┌────────────────────┘ // 30 ┌── /p // 31 ┌── /li ⬅ endOfList, ReplaceAroundStep#to, ReplaceAroundStep#gapTo (31) // 32 ┌── /ul // 33 ┌── /li // /ul // 34 │ // // Slice: // // 0 │ // li // 1 └── ul // 2 │ ⬅ openStart (2), ReplaceAroundStep#insert (0) // 3 ┌── /ul // /li // 4 │ ⬅ openEnd (0) // tr.step( new ReplaceAroundStep( end - 2, endOfList, end, endOfList, new Slice(Fragment.from(schema.nodes.li.create(undefined, range.parent.copy())), 2, 0), 0, true ) ); } range = new NodeRange(tr.doc.resolve(/*NoCache*/ range.$from.pos), tr.doc.resolve(/*NoCache*/ endOfList), range.depth); } const liftTargetForRange = liftTarget(range); if (liftTargetForRange == null) { return false; } tr.lift(range, liftTargetForRange); return true; } function doliftOutOfList(tr: Transaction, range: NodeRange): boolean { const 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); const item = $start.nodeAfter; const atStart = range.startIndex == 0; const atEnd = range.endIndex == list.childCount; const parent = $start.node(-1); const indexBefore = $start.index(-1); if (item == null) { return false; } if ( !parent.canReplace( indexBefore + (atStart ? 0 : 1), indexBefore + 1, item.content.append(atEnd ? Fragment.empty : Fragment.from(list)) ) ) { return false; } const start = $start.pos; const 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())).append(atEnd ? Fragment.empty : Fragment.from(list.copy())), atStart ? 0 : 1, atEnd ? 0 : 1 ), atStart ? 0 : 1 ) ); return true; } export function splitListItem(state: EditorState, dispatch?: Dispatch): boolean { const { $from, $to } = state.selection; if ((state.selection instanceof NodeSelection && state.selection.node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) { return false; } const grandParent = $from.node(-1); if (grandParent.type !== schema.nodes.li) { return false; } // tslint:disable-next-line:no-any const nextType = $to.pos == $from.end() ? ((grandParent as any).defaultContentType(0) as ProsemirrorNodeType) : null; const tr = state.tr.delete($from.pos, $to.pos); // https://github.com/bradleyayers/getdocs2ts/issues/17 // tslint:disable-next-line:no-any const types = nextType !== null ? [(null as any) as { type: ProsemirrorNodeType }, { type: nextType }] : undefined; if (!canSplit(tr.doc, $from.pos, 2, types)) { return false; } if (dispatch !== undefined) { dispatch(tr.split($from.pos, 2, types).scrollIntoView()); } return true; } export function cursorAtEmptyLastLiPredicate(state: EditorState): boolean { const $cursor = getCursor(state); if ($cursor == null) { return false; } const range = $cursor.blockRange(undefined, node => node.firstChild != null && node.firstChild.type == schema.nodes.li); if (range == null) { return false; } return range.parent.childCount === range.endIndex && $cursor.parent.content.size === 0; } export function joinListItemBackward(state: EditorState, dispatch?: Dispatch): boolean { const $cursor = getCursor(state); if ($cursor == null) { return false; } if ($cursor.parentOffset > 0) { // A list is only joined backwards if the cursor is at the start of a list // item. return false; } const range = $cursor.blockRange($cursor, node => node.firstChild != null && node.firstChild.type == schema.nodes.li); if (range == null) { // No list ancestor was found, and so no sinking is possible. return false; } // Example: // // ul // ├── li // │  └── p // │  └── "1" // └── li // └── p // ├── "2" ⬅ joinListTextBackward // └── ul // └── li // └── p // └── "2.1" // // Expected result: // // ul // └── li //   ├── p //   │ └── "12" // └── ul // └── li // └── p // └── "2.1" const list = range.parent; const prevItem = list.maybeChild(range.startIndex - 1); if (prevItem == null || prevItem.lastChild == null || prevItem.lastChild.type !== schema.nodes.p) { // It's not possible to join the text backwards if the previous item has // nested lists in it. return false; } // // List preview: (bold should be deleted) // // 0 │ // ul // 1 └── li // 2 └── p 4 5 6 7 // 3 └── 'i' ─ 't' ─ 'e' ─ 'm' ─ '1' ━┓ // 8 ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ // 9 ┏━━ /p // /li // 10 ┃ // li // 11 ┗━━ p 13 14 15 16 // 12 ┗━━ 'i' ─ 't' ─ 'e' ─ 'm' ─ '2' ─┐ ⬅ $cursor.pos=12 // 17 ┌────────────────────────────────┘ // 18 ┌── /p // 19 ┌── /li // /ul // 20 │ // // From this we can see we need to delete 4 positions backwards from the current position. // if (dispatch !== undefined) { dispatch(state.tr.delete($cursor.pos - 4, $cursor.pos)); } return true; } export function joinListItemForward(state: EditorState, dispatch?: Dispatch): boolean { // Example: // // 0 │ // ul // 1 └── li // 2 └── p // 3 └── '1' ─┐ // 4 ┌────────┘ ⬅ joinListTextForward // 5 ┌── /p // /li // 6 │ // li // 7 └── p // 8 └── '2' ─┐ // 9 ┌────────┘ // /p // 10 │ // ul // 11 └── li // 12 └── p 14 15 // 13 └── '2' ─ '.' ─ '1' ─┐ // 16 ┌────────────────────┘ // 17 ┌── /p // 18 ┌── /li // 19 ┌── /ul // 20 ┌── /li // /ul // 21 │ // // Expected result: // // 0 │ // ul // 1 └── li // 2 └── p 4 // 3 └── '1' ─ '2' ─┐ // 5 ┌──────────────┘ // /p // 6 │ // ul // 7 └── li // 8 └── p 10 11 // 9 └── '2' ─ '.' ─ '1' ─┐ // 12 ┌────────────────────┘ // 13 ┌── /p // 14 ┌── /li // 15 ┌── /ul // 16 ┌── /li // /ul // 17 │ // const $cursor = getCursorIfEnd(state); if ($cursor !== null) { const $cut = findCutAfter($cursor); if ($cut !== null && $cut.depth == $cursor.depth - 2 && cutMatches($cut, ["li"], ["li"])) { // // List preview: (bold should be deleted) // // 0 │ // ul // 1 └── li // 2 └── p 4 5 6 7 // 3 └── 'i' ─ 't' ─ 'e' ─ 'm' ─ '1' ━┓ // 8 ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ⬅ $pos.pos=8 // 9 ┏━━ /p // /li // 10 ┃ ⬅ $cut // li // 11 ┗━━ p 13 14 15 16 // 12 ┗━━ 'i' ─ 't' ─ 'e' ─ 'm' ─ '2' ─┐ // 17 ┌────────────────────────────────┘ // 18 ┌── /p // 19 ┌── /li // /ul // 20 │ // const tr = state.tr; if (doJoinListItemForward(tr, $cut)) { if (dispatch !== undefined) { dispatch(tr.scrollIntoView()); } return true; } } } return false; } export function doJoinListItemForward(tr: Transaction, $cut: ResolvedPos): boolean { // Example: (bold is deleted) // // 0 │ // ul // 1 └── li // 2 └── p // 3 └── '1' ━┓ // 4 ┏━━━━━━━━┛ // 5 ┏━━ /p // /li // 6 ┃ ⬅ $cut // li // 7 ┗━━ p // 8 ┗━━ '2' ─┐ // 9 ┌────────┘ // /p // 10 │ // ul // 11 └── li // 12 └── p 14 15 // 13 └── '2' ─ '.' ─ '1' ─┐ // 16 ┌────────────────────┘ // 17 ┌── /p // 18 ┌── /li // 19 ┌── /ul // 20 ┌── /li // /ul // 21 │ // // Result: // // 0 │ // ul // 1 └── li // 2 └── p 4 // 3 └── '1' ─ '2' ─┐ // 5 ┌──────────────┘ // /p // 6 │ // ul // 7 └── li // 8 └── p 10 11 // 9 └── '2' ─ '.' ─ '1' ─┐ // 12 ┌────────────────────┘ // 13 ┌── /p // 14 ┌── /li // 15 ┌── /ul // 16 ┌── /li // /ul // 17 │ // tr.delete($cut.pos - 2, $cut.pos + 2); return true; } export function doJoinP(tr: Transaction, $cut: ResolvedPos): boolean { // Example: (bold is deleted) // // 0 │ // p // 1 └── '1' ━┓ // 2 ┏━━━━━━━━┛ // /p // 3 ┃ ⬅ $cut // p 5 6 // 4 ┗━━ '1' ─ '.' ─ '1' ─┐ // 7 ┌────────────────────┘ // /p // 8 │ // // Result: // // 0 │ // p 2 3 4 // 1 └── '1' ─ '1' ─ '.' ─ '1' ─┐ // 5 ┌──────────────────────────┘ // /p // 6 │ // tr.delete($cut.pos - 1, $cut.pos + 1); return true; } /** * Perform `doLiftAndMergeNestedList` if the cursor is in a valid position. */ function liftAndMergeListAfterP(state: EditorState, dispatch?: Dispatch): boolean { const $cursor = getCursorIfEnd(state); if ($cursor !== null) { const $cut = findCutAfter($cursor); if ($cut !== null && cutMatches($cut, ["p"], ["ul", "ol"])) { const tr = state.tr; if (doLiftAndMergeListAfterP(tr, $cut)) { if (dispatch !== undefined) { dispatch(tr.scrollIntoView()); } return true; } } } return false; } function doLiftAndMergeListAfterP(tr: Transaction, $cut: ResolvedPos): boolean { // This is slightly different to just a lift, in that it merges the *p of the // first li* into the *before p*. // // Potential state #1: (when in adjacent li) // // 0 │ // ul // 1 └── li // 2 └── p // 3 └── '1' ─┐ // 4 ┌────────┘ // 5 ┌── /p // /li // 6 │ ⬅ $cut // li // 7 └── p 9 10 // 8 └── '1' ─ '.' ─ '1' ─┐ // 11 ┌────────────────────┘ // /p // 12 │ // ul // 13 └── li // 14 └── p 16 17 18 19 // 15 └── '1' ─ '.' ─ '1' ─ '.' ─ '1' ─┐ // 20 ┌────────────────────────────────┘ // 21 ┌── /p // /li // 24 │ // li // 25 └── p 27 28 // 26 └── '1' ─ '.' ─ '2' ─┐ // 29 ┌────────────────────┘ // 30 ┌── /p // 31 ┌── /li // 32 ┌── /ul // 33 ┌── /li // /ul // 34 │ // // Potential state #2: (when ul is adjacent) // // 0 │ // p // 1 └── '1' ─┐ // 2 ┌────────┘ // /p // 3 │ ⬅ $cut // ul // └── li // └── p 5 6 // 4 └── '1' ─ '.' ─ '1' ─┐ // 7 ┌────────────────────┘ // 8 ┌── /p // 9 ┌── /li // /ul // 10 │ // // Example: // // 0 │ // p ⬅ before p // 1 └── '1' ─┐ // 2 ┌────────┘ // /p // 3 │ ⬅ $cut // ul // 4 └── li // 5 └── p 7 8 ⬅ p of the first li // 6 └── '1' ─ '.' ─ '1' ─┐ // 9 ┌────────────────────┘ // /p // 10 │ // ul // 11 └── li // 12 └── p 14 15 16 17 // 13 └── '1' ─ '.' ─ '1' ─ '.' ─ '1' ─┐ // 18 ┌────────────────────────────────┘ // 19 ┌── /p // 20 ┌── /li // 21 ┌── /ul // /li // 22 │ // li // 23 └── p 25 26 // 24 └── '1' ─ '.' ─ '2' ─┐ // 27 ┌────────────────────┘ // 28 ┌── /p // 29 ┌── /li // /ul // 30 │ // // Result: // // 0 │ // p 2 3 4 // 1 └── '1' ─ '1' ─ '.' ─ '1' ─┐ // 5 ┌──────────────────────────┘ // /p // 6 │ // ul // 7 └── li // 8 └── p 10 11 12 13 // 9 └── '1' ─ '.' ─ '1' ─ '.' ─ '1' ─┐ // 14 ┌────────────────────────────────┘ // 15 ┌── /p // /li // 16 │ // li // 17 └── p 19 20 // 18 └── '1' ─ '.' ─ '2' ─┐ // 21 ┌────────────────────┘ // 22 ┌── /p // 23 ┌── /li // /ul // 24 │ // const range = tr.doc .resolve($cut.pos + 2) .blockRange(undefined, node => node.firstChild != null && node.firstChild.type == schema.nodes.li); if (range != null) { if (doLiftListItem(tr, range)) { const inList = $cut.parent.type == schema.nodes.li; if (inList) { const $liCut = tr.doc.resolve($cut.pos + 1); return doJoinListItemForward(tr, $liCut); } else { return doJoinP(tr, $cut); } } } return false; } function liftDistantAfterList(state: EditorState, dispatch?: Dispatch): boolean { // Example: // // 0 │ // ul // 1 └── li // 2 └── p // 3 └── '1' ─┐ // 4 ┌────────┘ // /p // 5 │ // ul // 6 └── li // 7 └── p 9 10 // 8 └── '1' ─ '.' ─ '2' ─┐ // 11 ┌────────────────────┘ ⬅ $cursor.pos (11) // 12 ┌── /p // 13 ┌── /li // 14 ┌── /ul // /li // 15 │ // li // 16 └── p // 17 └── '2' ─┐ // 18 ┌────────┘ // 19 ┌── /p // 20 ┌── /li // /ul // 21 │ // // const $cursor = getCursorIfEnd(state); if ($cursor !== null) { const $cut = findCutAfter($cursor); if ($cut !== null && cutMatches($cut, ["li"], ["li"])) { const range = new NodeRange($cut, state.doc.resolve($cut.pos + $cut.nodeAfter!.nodeSize), $cut.depth); const tr = state.tr; if (doSinkListItem(tr, range)) { if (dispatch !== undefined) { dispatch(tr.scrollIntoView()); } return true; } } } return false; } function toggleList(nodeType: "ul" | "ol"): Command { const primary = nodeType === "ul" ? schema.nodes.ul : schema.nodes.ol; const alternate = nodeType === "ul" ? schema.nodes.ol : schema.nodes.ul; return (state, dispatch) => { const { $anchor } = state.selection; let match; if (closest($anchor, node => node.type === primary) !== null) { return liftListItem(state, dispatch); } else if ((match = closest($anchor, node => node.type === alternate)) !== null) { if (dispatch !== undefined) { dispatch(state.tr.setNodeMarkup(match.pos, primary)); } return true; } else if ((match = closest($anchor, node => node.type === schema.nodes.p)) !== null) { const $start = state.doc.resolve(match.start); const pNodeRange = $start.blockRange(); if ($start.depth === 1 && pNodeRange != null) { // top level paragraph if (dispatch !== undefined) { dispatch(state.tr.wrap(pNodeRange, [{ type: primary }, { type: schema.nodes.li }])); } return true; } } return false; }; } export function toggleOrderedList(state: EditorState, dispatch?: Dispatch): boolean { return autoJoin(toggleList("ol"), node => node.type === schema.nodes.ol)(state, dispatch); } export function toggleUnorderedList(state: EditorState, dispatch?: Dispatch): boolean { return autoJoin(toggleList("ul"), node => node.type === schema.nodes.ul)(state, dispatch); } export const ux = { [UxCommand.Lift]: liftListItem, [UxCommand.Sink]: sinkListItem, [UxCommand.Enter]: chain( filter(not(cursorAtEmptyLastLiPredicate), splitListItem), filter(cursorAtEmptyLastLiPredicate, liftListItem) ), [UxCommand.DeleteBackward]: chain(joinPToPreviousListTail, joinListItemBackward, filter(startCursor, liftListItem)), [UxCommand.DeleteForward]: chain(joinNextPToListTail, joinListItemForward, liftDistantAfterList, liftAndMergeListAfterP), [UxCommand.TabBackward]: filter(not(textSelected), liftListItem), [UxCommand.TabForward]: filter(not(textSelected), sinkListItem), [UxCommand.ToggleOrderedList]: toggleOrderedList, [UxCommand.ToggleUnorderedList]: toggleUnorderedList };