Skip to content

Commit 6f54ee7

Browse files
authored
feat(Lists): added sinkOnlySelectedListItem (#687)
1 parent 138120a commit 6f54ee7

File tree

6 files changed

+348
-10
lines changed

6 files changed

+348
-10
lines changed

package-lock.json

+7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@
266266
"gulp-sass": "6.0.0",
267267
"gulp-sourcemaps": "3.0.0",
268268
"identity-obj-proxy": "^3.0.0",
269+
"ist": "1.1.7",
269270
"jest": "^29.7.0",
270271
"jest-css-modules": "^2.1.0",
271272
"jest-environment-jsdom": "^29.7.0",

src/extensions/markdown/Lists/actions.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import {liftListItem, sinkListItem} from 'prosemirror-schema-list';
1+
import {liftListItem} from 'prosemirror-schema-list';
22

33
import type {ActionSpec, ExtensionDeps} from '../../../core';
44

5-
import {toList} from './commands';
5+
import {sinkOnlySelectedListItem, toList} from './commands';
66
import {ListNode} from './const';
77
import {blType, isIntoListOfType, liType, olType} from './utils';
88

@@ -34,8 +34,8 @@ export const actions = {
3434

3535
sinkListItem: ({schema}: ExtensionDeps): ActionSpec => {
3636
return {
37-
isEnable: sinkListItem(liType(schema)),
38-
run: sinkListItem(liType(schema)),
37+
isEnable: sinkOnlySelectedListItem(liType(schema)),
38+
run: sinkOnlySelectedListItem(liType(schema)),
3939
};
4040
},
4141
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// eslint-disable-next-line import/no-extraneous-dependencies
2+
import ist from 'ist';
3+
import type {Node} from 'prosemirror-model';
4+
import {
5+
type Command,
6+
EditorState,
7+
NodeSelection,
8+
Selection,
9+
TextSelection,
10+
} from 'prosemirror-state';
11+
import {doc, eq, li, p, schema, ul} from 'prosemirror-test-builder';
12+
13+
import {sinkOnlySelectedListItem} from 'src/extensions/markdown/Lists/commands';
14+
15+
function selFor(doc: Node) {
16+
const a = (doc as any).tag.a,
17+
b = (doc as any).tag.b;
18+
if (a !== null) {
19+
const $a = doc.resolve(a);
20+
if ($a.parent.inlineContent)
21+
return new TextSelection($a, b !== null ? doc.resolve(b) : undefined);
22+
else return new NodeSelection($a);
23+
}
24+
return Selection.atStart(doc);
25+
}
26+
27+
function apply(doc: Node, command: Command, result: Node | null) {
28+
let state = EditorState.create({doc, selection: selFor(doc)});
29+
// eslint-disable-next-line no-return-assign
30+
command(state, (tr) => (state = state.apply(tr)));
31+
ist(state.doc, result || doc, eq);
32+
// eslint-disable-next-line no-eq-null
33+
if (result && (result as any).tag.a != null) ist(state.selection, selFor(result), eq);
34+
}
35+
36+
describe('sinkOnlySelectedListItem', () => {
37+
const sink = sinkOnlySelectedListItem(schema.nodes.list_item);
38+
39+
it('can wrap a simple item in a list', () =>
40+
apply(
41+
doc(ul(li(p('one')), li(p('t<a><b>wo')), li(p('three')))),
42+
sink,
43+
doc(ul(li(p('one'), ul(li(p('two')))), li(p('three')))),
44+
));
45+
46+
it("won't wrap the first item in a sublist", () =>
47+
apply(doc(ul(li(p('o<a><b>ne')), li(p('two')), li(p('three')))), sink, null));
48+
49+
it("will move an item's content into the item above", () =>
50+
apply(
51+
doc(ul(li(p('one')), li(p('...'), ul(li(p('two')))), li(p('t<a><b>hree')))),
52+
sink,
53+
doc(ul(li(p('one')), li(p('...'), ul(li(p('two')), li(p('three')))))),
54+
));
55+
56+
it('transforms a complex nested list with selection markers', () =>
57+
apply(
58+
doc(
59+
ul(
60+
li(p('aa')),
61+
li(
62+
p('b<a>b'),
63+
ul(
64+
li(p('c<b>c')),
65+
li(p('dd'), ul(li(p('ee')), li(p('ss')))),
66+
li(p('zz')),
67+
li(p('ww')),
68+
),
69+
),
70+
li(p('pp')),
71+
li(p('hh')),
72+
),
73+
),
74+
sink,
75+
doc(
76+
ul(
77+
li(
78+
p('aa'),
79+
ul(
80+
li(p('bb'), ul(li(p('cc')))),
81+
li(p('dd'), ul(li(p('ee')), li(p('ss')))),
82+
li(p('zz')),
83+
li(p('ww')),
84+
),
85+
),
86+
li(p('pp')),
87+
li(p('hh')),
88+
),
89+
),
90+
));
91+
92+
it('sinks a top-level list item with double selection markers into the previous item', () =>
93+
apply(
94+
doc(
95+
ul(
96+
li(p('aa')),
97+
li(
98+
p('b<a><b>b'),
99+
ul(
100+
li(p('cc')),
101+
li(p('dd'), ul(li(p('ee')), li(p('ss')))),
102+
li(p('zz')),
103+
li(p('ww')),
104+
),
105+
),
106+
li(p('pp')),
107+
li(p('hh')),
108+
),
109+
),
110+
sink,
111+
doc(
112+
ul(
113+
li(
114+
p('aa'),
115+
ul(
116+
li(p('bb')),
117+
li(p('cc')),
118+
li(p('dd'), ul(li(p('ee')), li(p('ss')))),
119+
li(p('zz')),
120+
li(p('ww')),
121+
),
122+
),
123+
li(p('pp')),
124+
li(p('hh')),
125+
),
126+
),
127+
));
128+
129+
it('sinks nested list items into a deeper hierarchy when selection spans multiple items', () =>
130+
apply(
131+
doc(
132+
ul(
133+
li(p('aa')),
134+
li(
135+
p('bb'),
136+
ul(
137+
li(p('cc')),
138+
li(p('d<a>d'), ul(li(p('ee')), li(p('ss')))),
139+
li(p('z<b>z')),
140+
li(p('ww')),
141+
),
142+
),
143+
li(p('pp')),
144+
li(p('hh')),
145+
),
146+
),
147+
sink,
148+
doc(
149+
ul(
150+
li(p('aa')),
151+
li(
152+
p('bb'),
153+
ul(
154+
li(p('cc'), ul(li(p('dd'), ul(li(p('ee')), li(p('ss')))), li(p('zz')))),
155+
li(p('ww')),
156+
),
157+
),
158+
li(p('pp')),
159+
li(p('hh')),
160+
),
161+
),
162+
));
163+
164+
// expected result should be the same as
165+
// sinks nested list items into a deeper hierarchy when selection spans multiple items
166+
it('sinks nested list items with an upward staircase selection', () =>
167+
apply(
168+
doc(
169+
ul(
170+
li(p('aa')),
171+
li(
172+
p('bb'),
173+
ul(
174+
li(p('cc')),
175+
li(p('dd'), ul(li(p('ee')), li(p('s<a>s')))),
176+
li(p('z<b>z')),
177+
li(p('ww')),
178+
),
179+
),
180+
li(p('pp')),
181+
li(p('hh')),
182+
),
183+
),
184+
sink,
185+
doc(
186+
ul(
187+
li(p('aa')),
188+
li(
189+
p('bb'),
190+
ul(
191+
li(p('cc'), ul(li(p('dd'), ul(li(p('ee')), li(p('ss')))), li(p('zz')))),
192+
li(p('ww')),
193+
),
194+
),
195+
li(p('pp')),
196+
li(p('hh')),
197+
),
198+
),
199+
));
200+
201+
it('sinks a top-level list item with mixed selection markers from both levels', () =>
202+
apply(
203+
doc(
204+
ul(
205+
li(p('aa')),
206+
li(
207+
p('b<a>b'),
208+
ul(
209+
li(p('cc')),
210+
li(p('dd'), ul(li(p('ee')), li(p('s<b>s')))),
211+
li(p('zz')),
212+
li(p('ww')),
213+
),
214+
),
215+
li(p('pp')),
216+
li(p('hh')),
217+
),
218+
),
219+
sink,
220+
doc(
221+
ul(
222+
li(
223+
p('aa'),
224+
ul(
225+
li(p('bb'), ul(li(p('cc')), li(p('dd'), ul(li(p('ee')), li(p('ss')))))),
226+
li(p('zz')),
227+
li(p('ww')),
228+
),
229+
),
230+
li(p('pp')),
231+
li(p('hh')),
232+
),
233+
),
234+
));
235+
});

src/extensions/markdown/Lists/commands.ts

+97-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type {NodeType} from 'prosemirror-model';
1+
import {Fragment, type NodeRange, type NodeType, Slice} from 'prosemirror-model';
22
import {wrapInList} from 'prosemirror-schema-list';
3-
import type {Command} from 'prosemirror-state';
3+
import type {Command, Transaction} from 'prosemirror-state';
4+
import {ReplaceAroundStep, liftTarget} from 'prosemirror-transform';
45

56
import {joinPreviousBlock} from '../../../commands/join';
67

@@ -23,3 +24,97 @@ export const joinPrevList = joinPreviousBlock({
2324
checkPrevNode: isListNode,
2425
skipNode: isListOrItemNode,
2526
});
27+
28+
/*
29+
Simplified `sinkListItem` from `prosemirror-schema-list` without `state`/`dispatch`,
30+
sinks list items deeper.
31+
*/
32+
const sink = (tr: Transaction, range: NodeRange, itemType: NodeType) => {
33+
const before = tr.mapping.map(range.start);
34+
const after = tr.mapping.map(range.end);
35+
const startIndex = tr.mapping.map(range.startIndex);
36+
37+
const parent = range.parent;
38+
const nodeBefore = parent.child(startIndex - 1);
39+
40+
const nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type === parent.type;
41+
const inner = Fragment.from(nestedBefore ? itemType.create() : null);
42+
const slice = new Slice(
43+
Fragment.from(itemType.create(null, Fragment.from(parent.type.create(null, inner)))),
44+
nestedBefore ? 3 : 1,
45+
0,
46+
);
47+
48+
tr.step(
49+
new ReplaceAroundStep(
50+
before - (nestedBefore ? 3 : 1),
51+
after,
52+
before,
53+
after,
54+
slice,
55+
1,
56+
true,
57+
),
58+
);
59+
return true;
60+
};
61+
62+
export function sinkOnlySelectedListItem(itemType: NodeType): Command {
63+
return ({tr, selection}, dispatch) => {
64+
const {$from, $to} = selection;
65+
const selectionRange = $from.blockRange(
66+
$to,
67+
(node) => node.childCount > 0 && node.firstChild!.type === itemType,
68+
);
69+
if (!selectionRange) {
70+
return false;
71+
}
72+
73+
const {startIndex, parent, start, end} = selectionRange;
74+
if (startIndex === 0) {
75+
return false;
76+
}
77+
78+
const nodeBefore = parent.child(startIndex - 1);
79+
if (nodeBefore.type !== itemType) {
80+
return false;
81+
}
82+
83+
if (dispatch) {
84+
// lifts following list items sequentially to prepare correct nesting structure
85+
let currentEnd = end - 1;
86+
while (currentEnd > start) {
87+
const selectionEnd = tr.mapping.map($to.pos);
88+
89+
const $candidateBlockEnd = tr.doc.resolve(currentEnd);
90+
const candidateBlockStartPos = $candidateBlockEnd.before($candidateBlockEnd.depth);
91+
const $candidateBlockStart = tr.doc.resolve(candidateBlockStartPos);
92+
const candidateBlockRange = $candidateBlockStart.blockRange($candidateBlockEnd);
93+
94+
if (candidateBlockRange?.start) {
95+
const $rangeStart = tr.doc.resolve(candidateBlockRange.start);
96+
const shouldLift =
97+
candidateBlockRange.start > selectionEnd && isListNode($rangeStart.parent);
98+
99+
if (shouldLift) {
100+
currentEnd = candidateBlockRange.start;
101+
102+
const targetDepth = liftTarget(candidateBlockRange);
103+
if (targetDepth !== null) {
104+
tr.lift(candidateBlockRange, targetDepth);
105+
}
106+
}
107+
}
108+
109+
currentEnd--;
110+
}
111+
112+
// sinks the selected list item deeper into the list hierarchy
113+
sink(tr, selectionRange, itemType);
114+
115+
dispatch(tr.scrollIntoView());
116+
return true;
117+
}
118+
return false;
119+
};
120+
}

0 commit comments

Comments
 (0)