Skip to content

Commit 9d055c4

Browse files
authored
feat(Blockquote): support for joining the previous blockquote (#90)
1 parent 2cde04a commit 9d055c4

File tree

8 files changed

+213
-29
lines changed

8 files changed

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

src/extensions/markdown/Blockquote/commands.ts

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

@@ -18,17 +19,63 @@ export const liftFromQuote: Command = (state, dispatch) => {
1819
return lift(state, dispatch);
1920
};
2021

21-
export const selectQuoteBeforeCursor: Command = (state, dispatch) => {
22+
export const joinPrevQuote: Command = (state, dispatch) => {
2223
const $cursor = getCursor(state.selection);
23-
if (!$cursor || $cursor.parentOffset > 0) return false;
24+
if (!$cursor || $cursor.parentOffset > 0 || $cursor.node(-1).isTextblock) return false;
2425
const index = $cursor.index(-1);
2526
const nodeBefore = $cursor.node(-1).maybeChild(index - 1);
2627
if (!nodeBefore || nodeBefore.type !== bqType(state.schema)) return false;
27-
const beforePos = $cursor.before();
28-
dispatch?.(
29-
state.tr.setSelection(NodeSelection.create(state.doc, beforePos - nodeBefore.nodeSize)),
30-
);
31-
return true;
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;
3279
};
3380

3481
export const toggleQuote: Command = (state, dispatch) => {

src/extensions/markdown/Blockquote/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type {NodeType} from 'prosemirror-model';
33
import {wrappingInputRule} from 'prosemirror-inputrules';
44
import {hasParentNodeOfType} from 'prosemirror-utils';
55
import type {Action, ExtensionAuto} from '../../../core';
6-
import {selectQuoteBeforeCursor, liftFromQuote, toggleQuote} from './commands';
6+
import {liftFromQuote, toggleQuote, joinPrevQuote} from './commands';
77
import {BlockquoteSpecs, blockquoteType} from './BlockquoteSpecs';
88

99
export {blockquote, blockquoteNodeName, blockquoteType} from './const';
@@ -22,7 +22,7 @@ export const Blockquote: ExtensionAuto<BlockquoteOptions> = (builder, opts) => {
2222
}
2323

2424
builder.addKeymap(() => ({
25-
Backspace: chainCommands(liftFromQuote, selectQuoteBeforeCursor),
25+
Backspace: chainCommands(liftFromQuote, joinPrevQuote),
2626
}));
2727

2828
builder.addInputRules(({schema}) => ({rules: [blockQuoteRule(blockquoteType(schema))]}));

src/extensions/markdown/Html/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {fromYfm} from './fromYfm';
55
import {spec} from './spec';
66
import {toYfm} from './toYfm';
77

8-
export {HtmlNode} from './const';
8+
export {HtmlAttr, HtmlNode} from './const';
99

1010
export const Html: ExtensionAuto = (builder) => {
1111
if (builder.context.has('html') && builder.context.get('html') === false) {

src/extensions/yfm/YfmTable/paste.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import {Fragment, Node, Schema, Slice} from 'prosemirror-model';
2-
import {getContentAsArray, yfmTableBodyType, yfmTableType} from './utils';
2+
import {getChildrenOfNode} from '../../../utils/nodes';
3+
import {yfmTableBodyType, yfmTableType} from './utils';
34

45
export function fixPastedTableBodies(slice: Slice, schema: Schema): Slice {
56
if (slice.content.firstChild?.type === yfmTableBodyType(schema)) {
67
const tRows: Node[] = [];
78
let bodiesSize = 0;
8-
for (const {node, offset} of getContentAsArray(slice.content)) {
9+
for (const {node, offset} of getChildrenOfNode(slice.content)) {
910
if (node.type !== yfmTableBodyType(schema)) break;
10-
tRows.push(...getContentAsArray(node).map((item) => item.node));
11+
tRows.push(...getChildrenOfNode(node).map((item) => item.node));
1112
bodiesSize = offset + node.nodeSize;
1213
}
1314
const tableNode = yfmTableType(schema).create(
Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1 @@
1-
import type {Fragment, Node} from 'prosemirror-model';
2-
31
export * from './YfmTableSpecs/utils';
4-
5-
export function getContentAsArray(node: Node | Fragment) {
6-
const content: {node: Node; offset: number}[] = [];
7-
node.forEach((node, offset) => {
8-
content.push({node, offset});
9-
});
10-
return content;
11-
}

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@ export {
2222
findSelectedNodeOfType,
2323
} from './utils/selection';
2424
export * from './table-utils';
25+
26+
export type {NodeChild} from './utils/nodes';
27+
export {getChildrenOfNode, getLastChildOfNode} from './utils/nodes';

src/utils/nodes.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@ export function findFirstTextblockChild(
66
): {node: Node; offset: number} | null {
77
if (parent instanceof Node && (!parent.isBlock || parent.isAtom)) return null;
88

9-
const children: {node: Node; offset: number}[] = [];
10-
parent.forEach((node, offset) => children.push({node, offset}));
11-
12-
for (const child of children) {
9+
for (const child of getChildrenOfNode(parent)) {
1310
if (child.node.isTextblock) return child;
1411
const nestedTextBlockChild = findFirstTextblockChild(child.node);
1512
if (nestedTextBlockChild) {
@@ -48,3 +45,15 @@ export const isSelectableNode = (node: Node) => {
4845
export function isCodeBlock(node: Node): boolean {
4946
return node.isTextblock && Boolean(node.type.spec.code);
5047
}
48+
49+
export type NodeChild = {node: Node; offset: number; index: number};
50+
export function getChildrenOfNode(node: Node | Fragment): NodeChild[] {
51+
const children: NodeChild[] = [];
52+
node.forEach((node, offset, index) => children.push({node, offset, index}));
53+
return children;
54+
}
55+
56+
export function getLastChildOfNode(node: Node | Fragment): NodeChild {
57+
const children = getChildrenOfNode(node);
58+
return children[children.length - 1];
59+
}

0 commit comments

Comments
 (0)