Skip to content

Commit 3e38618

Browse files
authored
feat(Lists): support for joining the previous list (#92)
1 parent 9d055c4 commit 3e38618

File tree

10 files changed

+259
-156
lines changed

10 files changed

+259
-156
lines changed

src/commands/join.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import {Node} from 'prosemirror-model';
2+
import {EditorState, TextSelection, Transaction, Selection, Command} from 'prosemirror-state';
3+
import {builders} from 'prosemirror-test-builder';
4+
5+
import {ExtensionsManager} from '../core';
6+
import {get$Cursor, isNodeSelection} from '../utils/selection';
7+
import {BaseNode, BaseSpecsPreset} from '../extensions/base/specs';
8+
import {
9+
blockquoteNodeName,
10+
BlockquoteSpecs,
11+
isBlockqouteNode,
12+
DeflistNode,
13+
DeflistSpecs,
14+
HtmlAttr,
15+
HtmlNode,
16+
Html,
17+
} from '../extensions/markdown/specs';
18+
19+
import {joinPreviousBlock} from './join';
20+
21+
const {schema} = new ExtensionsManager({
22+
extensions: (builder) =>
23+
builder.use(BaseSpecsPreset, {}).use(BlockquoteSpecs).use(Html).use(DeflistSpecs, {}),
24+
}).buildDeps();
25+
26+
const {doc, p, bq, htmlBlock, dList, dTerm, dDesc} = builders(schema, {
27+
doc: {nodeType: BaseNode.Doc},
28+
p: {nodeType: BaseNode.Paragraph},
29+
bq: {nodeType: blockquoteNodeName},
30+
htmlBlock: {nodeType: HtmlNode.Block},
31+
dList: {nodeType: DeflistNode.List},
32+
dTerm: {nodeType: DeflistNode.Term},
33+
dDesc: {nodeType: DeflistNode.Desc},
34+
}) as PMTestBuilderResult<'doc' | 'p' | 'bq' | 'htmlBlock', 'dList' | 'dTerm' | 'dDesc'>;
35+
36+
function shouldDispatch(
37+
cmd: Command,
38+
init: {doc: Node; sel?: (doc: Node) => Selection},
39+
expected: {doc: Node; sel?: (sel: Selection) => boolean},
40+
onSuccess?: (tr: Transaction) => void,
41+
) {
42+
const state = EditorState.create({
43+
doc: init.doc,
44+
selection: init.sel?.(init.doc),
45+
});
46+
47+
let tr: Transaction;
48+
const res = cmd(state, (tr_) => {
49+
tr = tr_;
50+
});
51+
52+
expect(res).toBe(true);
53+
expect(tr!.doc).toMatchNode(expected.doc);
54+
if (expected.sel) expect(expected.sel(tr!.selection)).toBe(true);
55+
56+
onSuccess?.(tr!);
57+
}
58+
59+
describe('joinPreviousBlock', () => {
60+
const joinPrevQuote = joinPreviousBlock({
61+
checkPrevNode: isBlockqouteNode,
62+
skipNode: isBlockqouteNode,
63+
});
64+
65+
const shouldDispatchWithCursorSelection = (
66+
initDoc: Node,
67+
initCursorSel: number,
68+
expectDoc: Node,
69+
expectCursor: number,
70+
) =>
71+
shouldDispatch(
72+
joinPrevQuote,
73+
{doc: initDoc, sel: (doc) => TextSelection.create(doc, initCursorSel)},
74+
{doc: expectDoc},
75+
(tr) => expect(get$Cursor(tr.selection)?.pos).toBe(expectCursor),
76+
);
77+
78+
const shouldDispatchWithNodeSelection = (
79+
initDoc: Node,
80+
initCursorSel: number,
81+
expectDoc: Node,
82+
expectNodeSel: number,
83+
) =>
84+
shouldDispatch(
85+
joinPrevQuote,
86+
{doc: initDoc, sel: (doc) => TextSelection.create(doc, initCursorSel)},
87+
{doc: expectDoc, sel: isNodeSelection},
88+
(tr) => expect(tr.selection.from).toBe(expectNodeSel),
89+
);
90+
91+
it('should join to textblock in quote', () => {
92+
shouldDispatchWithCursorSelection(
93+
doc(bq(p('para in blockqoute')), p('text')),
94+
23, // at start of second paragraph
95+
doc(bq(p('para in blockqoutetext'))),
96+
20,
97+
);
98+
});
99+
100+
it('should join to textblock in nested quotes', () => {
101+
shouldDispatchWithCursorSelection(
102+
doc(bq(bq(bq(p('para in nested blockqoutes')))), p('text')),
103+
35, // at start of second paragraph
104+
doc(bq(bq(bq(p('para in nested blockqoutestext'))))),
105+
30,
106+
);
107+
});
108+
109+
it('should join to last textblock in qoute', () => {
110+
shouldDispatchWithCursorSelection(
111+
doc(bq(p('first'), p('second')), p('third')),
112+
18, // at start of third paragraph
113+
doc(bq(p('first'), p('secondthird'))),
114+
15,
115+
);
116+
});
117+
118+
it('should select last atom block', () => {
119+
shouldDispatchWithNodeSelection(
120+
doc(bq(htmlBlock({[HtmlAttr.Content]: '<div/>'})), p('para')),
121+
4, // at start of para
122+
doc(bq(htmlBlock({[HtmlAttr.Content]: '<div/>'})), p('para')),
123+
1, // before html-block
124+
);
125+
});
126+
127+
it('should select last atom block and remove current empty textblock', () => {
128+
shouldDispatchWithNodeSelection(
129+
doc(bq(htmlBlock({[HtmlAttr.Content]: '<div/>'})), p('')),
130+
4, // at start of para
131+
doc(bq(htmlBlock({[HtmlAttr.Content]: '<div/>'}))),
132+
1, // before html-block
133+
);
134+
});
135+
136+
it('should move current textblock to end of last non-qoute block', () => {
137+
shouldDispatchWithCursorSelection(
138+
doc(bq(dList(dTerm('Term'), dDesc(p('Definition')))), p('current')),
139+
25, // at start of current paragraph
140+
doc(bq(dList(dTerm('Term'), dDesc(p('Definition'), p('current'))))),
141+
22, // at start of current paragraph
142+
);
143+
});
144+
});

src/commands/join.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type {Node} from 'prosemirror-model';
2+
import {Command, NodeSelection, TextSelection} from 'prosemirror-state';
3+
import {getLastChildOfNode, NodeChild} from '../utils/nodes';
4+
import {get$CursorAtBlockStart} from '../utils/selection';
5+
6+
export type JoinPreviousBlockParams = {
7+
checkPrevNode: (node: Node) => boolean;
8+
skipNode: (node: Node) => boolean;
9+
};
10+
11+
export const joinPreviousBlock =
12+
({checkPrevNode, skipNode}: JoinPreviousBlockParams): Command =>
13+
(state, dispatch) => {
14+
const $cursor = get$CursorAtBlockStart(state.selection);
15+
if (!$cursor) return false;
16+
const index = $cursor.index(-1);
17+
const nodeBefore = $cursor.node(-1).maybeChild(index - 1);
18+
if (!nodeBefore || !checkPrevNode(nodeBefore)) return false;
19+
20+
const textBlock = $cursor.parent;
21+
const docWithTextBlock = state.schema.topNodeType.create(null, textBlock);
22+
const isEmptyTextblock = textBlock.childCount === 0;
23+
24+
let node = nodeBefore;
25+
let offset = $cursor.before() - nodeBefore.nodeSize;
26+
let lastChild: NodeChild;
27+
while ((lastChild = getLastChildOfNode(node))) {
28+
if (lastChild.node.isTextblock) {
29+
const tr = state.tr;
30+
const insertPos = offset + lastChild.offset + lastChild.node.nodeSize;
31+
tr.delete($cursor.before(), $cursor.after());
32+
tr.insert(insertPos, textBlock.content);
33+
tr.setSelection(TextSelection.create(tr.doc, insertPos));
34+
dispatch?.(tr.scrollIntoView());
35+
return true;
36+
}
37+
38+
if (!skipNode(lastChild.node) && lastChild.node.canAppend(docWithTextBlock)) {
39+
const tr = state.tr;
40+
const insertPos = offset + 1 + lastChild.offset + lastChild.node.nodeSize - 1;
41+
tr.delete($cursor.before(), $cursor.after());
42+
tr.insert(insertPos, textBlock);
43+
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
44+
dispatch?.(tr.scrollIntoView());
45+
return true;
46+
}
47+
48+
if (lastChild.node.isAtom || lastChild.node.isLeaf) {
49+
const {tr} = state;
50+
if (isEmptyTextblock) {
51+
tr.delete($cursor.before(), $cursor.after());
52+
tr.setSelection(NodeSelection.create(tr.doc, offset + 1 + lastChild.offset));
53+
} else if (!skipNode(node) && node.canAppend(docWithTextBlock)) {
54+
const insertPos = offset + node.nodeSize - 1;
55+
tr.insert(insertPos, textBlock);
56+
tr.setSelection(TextSelection.create(tr.doc, insertPos));
57+
} else {
58+
tr.setSelection(NodeSelection.create(tr.doc, offset + 1 + lastChild.offset));
59+
}
60+
dispatch?.(tr.scrollIntoView());
61+
return true;
62+
}
63+
64+
node = lastChild.node;
65+
offset += lastChild.offset + 1;
66+
}
67+
68+
return false;
69+
};

src/extensions/markdown/Blockquote/BlockquoteSpecs/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import type {Node} from 'prosemirror-model';
12
import type {ExtensionAuto} from '../../../../core';
23
import {nodeTypeFactory} from '../../../../utils/schema';
34

45
export const blockquoteNodeName = 'blockquote';
56
export const blockquoteType = nodeTypeFactory(blockquoteNodeName);
7+
export const isBlockqouteNode = (node: Node) => node.type.name === blockquoteNodeName;
68

79
export const BlockquoteSpecs: ExtensionAuto = (builder) => {
810
builder.addNode(blockquoteNodeName, () => ({

src/extensions/markdown/Blockquote/commands.ts

Lines changed: 13 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,22 @@
1-
import type {Node, ResolvedPos} from 'prosemirror-model';
1+
import type {ResolvedPos} from 'prosemirror-model';
22
import {lift, wrapIn} from 'prosemirror-commands';
3-
import {Command, NodeSelection, Selection, TextSelection} from 'prosemirror-state';
4-
import {isTextSelection} from '../../../utils/selection';
5-
import {NodeChild, getLastChildOfNode} from '../../../utils/nodes';
6-
import {bqType} from './const';
7-
import '../../../types/spec';
3+
import type {Command} from 'prosemirror-state';
84

9-
function getCursor(sel: Selection) {
10-
if (isTextSelection(sel)) return sel.$cursor;
11-
return undefined;
12-
}
5+
import '../../../types/spec';
6+
import {joinPreviousBlock} from '../../../commands/join';
7+
import {get$CursorAtBlockStart, isTextSelection} from '../../../utils/selection';
8+
import {bqType, isBlockqouteNode} from './const';
139

1410
export const liftFromQuote: Command = (state, dispatch) => {
15-
const $cursor = getCursor(state.selection);
16-
if (!$cursor || $cursor.parentOffset > 0 || $cursor.node(-1).type !== bqType(state.schema)) {
17-
return false;
18-
}
11+
const $cursor = get$CursorAtBlockStart(state.selection);
12+
if (!$cursor || !isBlockqouteNode($cursor.node(-1))) return false;
1913
return lift(state, dispatch);
2014
};
2115

22-
export const joinPrevQuote: Command = (state, dispatch) => {
23-
const $cursor = getCursor(state.selection);
24-
if (!$cursor || $cursor.parentOffset > 0 || $cursor.node(-1).isTextblock) return false;
25-
const index = $cursor.index(-1);
26-
const nodeBefore = $cursor.node(-1).maybeChild(index - 1);
27-
if (!nodeBefore || nodeBefore.type !== bqType(state.schema)) return false;
28-
29-
const textBlock = $cursor.parent;
30-
const docWithTextBlock = state.schema.topNodeType.create(null, textBlock);
31-
const isEmptyTextblock = textBlock.childCount === 0;
32-
const isBlockqoute = (node: Node): boolean => node.type === bqType(state.schema);
33-
34-
let node = nodeBefore;
35-
let offset = $cursor.before() - nodeBefore.nodeSize;
36-
let lastChild: NodeChild;
37-
while ((lastChild = getLastChildOfNode(node))) {
38-
if (lastChild.node.isTextblock) {
39-
const tr = state.tr;
40-
const insertPos = offset + lastChild.offset + lastChild.node.nodeSize;
41-
tr.delete($cursor.before(), $cursor.after());
42-
tr.insert(insertPos, textBlock.content);
43-
tr.setSelection(TextSelection.create(tr.doc, insertPos));
44-
dispatch?.(tr.scrollIntoView());
45-
return true;
46-
}
47-
48-
if (!isBlockqoute(lastChild.node) && lastChild.node.canAppend(docWithTextBlock)) {
49-
const tr = state.tr;
50-
const insertPos = offset + 1 + lastChild.offset + lastChild.node.nodeSize - 1;
51-
tr.delete($cursor.before(), $cursor.after());
52-
tr.insert(insertPos, textBlock);
53-
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
54-
dispatch?.(tr.scrollIntoView());
55-
return true;
56-
}
57-
58-
if (lastChild.node.isAtom || lastChild.node.isLeaf) {
59-
const {tr} = state;
60-
if (isEmptyTextblock) {
61-
tr.delete($cursor.before(), $cursor.after());
62-
tr.setSelection(NodeSelection.create(tr.doc, offset + 1 + lastChild.offset));
63-
} else if (!isBlockqoute(node) && node.canAppend(docWithTextBlock)) {
64-
const insertPos = offset + node.nodeSize - 1;
65-
tr.insert(insertPos, textBlock);
66-
tr.setSelection(TextSelection.create(tr.doc, insertPos));
67-
} else {
68-
tr.setSelection(NodeSelection.create(tr.doc, offset + 1 + lastChild.offset));
69-
}
70-
dispatch?.(tr.scrollIntoView());
71-
return true;
72-
}
73-
74-
node = lastChild.node;
75-
offset += lastChild.offset + 1;
76-
}
77-
78-
return false;
79-
};
16+
export const joinPrevQuote = joinPreviousBlock({
17+
checkPrevNode: isBlockqouteNode,
18+
skipNode: isBlockqouteNode,
19+
});
8020

8121
export const toggleQuote: Command = (state, dispatch) => {
8222
const {selection} = state;
@@ -90,7 +30,7 @@ export const toggleQuote: Command = (state, dispatch) => {
9030
if (!nodeSpec.complex || nodeSpec.complex === 'root') {
9131
const targetDepth = depth - 1;
9232
const range = getBlockRange($cursor, depth);
93-
if ($cursor.node(targetDepth).type === qType) {
33+
if (isBlockqouteNode($cursor.node(targetDepth))) {
9434
dispatch?.(state.tr.lift(range!, targetDepth - 1).scrollIntoView());
9535
} else {
9636
dispatch?.(state.tr.wrap(range!, [{type: qType}]).scrollIntoView());

src/extensions/markdown/Blockquote/const.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {blockquoteNodeName, blockquoteType} from './BlockquoteSpecs';
22

3-
export {blockquoteNodeName, blockquoteType} from './BlockquoteSpecs';
3+
export {blockquoteNodeName, blockquoteType, isBlockqouteNode} from './BlockquoteSpecs';
44
/** @deprecated Use `blockquoteNodeName` instead */
55
export const blockquote = blockquoteNodeName;
66
/** @deprecated Use `blockquoteType` instead */

src/extensions/markdown/Lists/commands.test.ts

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {get$Cursor} from '../../../utils/selection';
66
import {BaseNode, BaseSpecsPreset} from '../../base/specs';
77
import {ListsSpecs} from './ListsSpecs';
88
import {ListNode} from './const';
9-
import {liftIfCursorIsAtBeginningOfItem, moveTextblockToEndOfLastItemOfPrevList} from './commands';
9+
import {liftIfCursorIsAtBeginningOfItem} from './commands';
1010

1111
const {schema} = new ExtensionsManager({
1212
extensions: (builder) => builder.use(BaseSpecsPreset, {}).use(ListsSpecs),
@@ -38,22 +38,4 @@ describe('Lists commands', () => {
3838
expect(view.state.doc).toMatchNode(doc(list(li(p('111'))), p('222'), list(li(p('333')))));
3939
expect(cursorpos).toStrictEqual(10);
4040
});
41-
42-
it.each([
43-
['bullet list', bl],
44-
['ordered list', ol],
45-
])('should move current textblock to end of last item of previous %s', (_0, list) => {
46-
const document = doc(list(li(p('111'))), p('222'), list(li(p('333'))));
47-
const view = new EditorView(null, {
48-
state: EditorState.create({
49-
doc: document,
50-
selection: TextSelection.create(document, 10),
51-
}),
52-
});
53-
const res = moveTextblockToEndOfLastItemOfPrevList(view.state, view.dispatch, view);
54-
const cursorpos = get$Cursor(view.state.selection)?.pos;
55-
expect(res).toStrictEqual(true);
56-
expect(view.state.doc).toMatchNode(doc(list(li(p('111'), p('222'))), list(li(p('333')))));
57-
expect(cursorpos).toStrictEqual(8);
58-
});
5941
});

0 commit comments

Comments
 (0)