1- import { EditorState , TextSelection } from 'prosemirror-state' ;
1+ import { type Node , Slice } from 'prosemirror-model' ;
2+ import { AllSelection , EditorState , TextSelection } from 'prosemirror-state' ;
23import { builders } from 'prosemirror-test-builder' ;
34import { EditorView } from 'prosemirror-view' ;
45
@@ -7,9 +8,10 @@ import {BaseNode, BaseSchemaSpecs} from '../../../base/BaseSchema/BaseSchemaSpec
78import { ListsSpecs } from '../ListsSpecs' ;
89import { ListNode } from '../const' ;
910
10- import { collapseListsPlugin } from './CollapseListsPlugin' ;
11+ import { collapseAllNestedListItems , collapseListsPlugin } from './CollapseListsPlugin' ;
12+ import { mergeListsPlugin } from './MergeListsPlugin' ;
1113
12- const { schema} = new ExtensionsManager ( {
14+ const { schema, markupParser : parser } = new ExtensionsManager ( {
1315 extensions : ( builder ) => builder . use ( BaseSchemaSpecs , { } ) . use ( ListsSpecs ) ,
1416} ) . buildDeps ( ) ;
1517
@@ -21,7 +23,7 @@ const {doc, p, li, ul} = builders<'doc' | 'p' | 'li' | 'ul'>(schema, {
2123} ) ;
2224
2325describe ( 'CollapseListsPlugin' , ( ) => {
24- it ( 'should collapse nested bullet list without remaining content and move selection to the end of the first text node ' , ( ) => {
26+ it ( 'should collapse nested bullet list without remaining content and place cursor on text' , ( ) => {
2527 const view = new EditorView ( null , {
2628 state : EditorState . create ( { schema, plugins : [ collapseListsPlugin ( ) ] } ) ,
2729 } ) ;
@@ -34,10 +36,9 @@ describe('CollapseListsPlugin', () => {
3436
3537 expect ( view . state . doc ) . toMatchNode ( doc ( ul ( li ( p ( 'Nested item' ) ) ) ) ) ;
3638
37- const textStartPos = view . state . doc . resolve ( 3 ) ;
38- const textEndPos = textStartPos . pos + textStartPos . nodeAfter ! . nodeSize ;
39-
40- expect ( view . state . selection . from ) . toBe ( textEndPos ) ;
39+ const sel = view . state . selection ;
40+ const $from = view . state . doc . resolve ( sel . from ) ;
41+ expect ( $from . parent . type . name ) . toBe ( 'paragraph' ) ;
4142 } ) ;
4243
4344 it ( 'should collapse nested bullet list with remaining content' , ( ) => {
@@ -115,8 +116,8 @@ describe('CollapseListsPlugin', () => {
115116 ) ,
116117 ) ;
117118
118- const textStartPos = view . state . doc . resolve ( 38 ) ;
119- expect ( view . state . selection . from ) . toBe ( textStartPos . pos ) ;
119+ const $from = view . state . doc . resolve ( view . state . selection . from ) ;
120+ expect ( $from . parent . type . name ) . toBe ( 'paragraph' ) ;
120121 } ) ;
121122
122123 it ( 'should not collapse list item without nested bullet list and not change selection if no collapse happened' , ( ) => {
@@ -139,4 +140,193 @@ describe('CollapseListsPlugin', () => {
139140
140141 expect ( view . state . selection . from ) . toBe ( selectionPos . pos ) ;
141142 } ) ;
143+
144+ it ( 'should not crash on a large bullet list with 3-level nesting from "N. M." items' , ( ) => {
145+ const markdown = [
146+ '- 1. Replied in the original ticket.' ,
147+ '- 2. Fixed the macro processing for table of contents.' ,
148+ '- 3. The heading is already H2 after import, everything is correct.' ,
149+ '- 4. Added support for this macro.' ,
150+ '- 5. Fixed processing of such quotes.' ,
151+ '- 6. Fixed processing of em dashes.' ,
152+ '- 7. 8. Replied in the original ticket.' ,
153+ '- 9. Could not reproduce, apparently the screenshot shows a placeholder for a magic link logo.' ,
154+ '- 10. In the source data items are presented as text not a list. In markdown this is considered a numbered list and indentation is automatically added. The inner list is also formatted as a first-level indented list because it is not a second-level list at the markup level. Accordingly the indentation is the same. In this case this is expected behavior.' ,
155+ '- 11. Email highlighting is standard editor behavior.' ,
156+ '- 12. Added escaping of backslashes.' ,
157+ '- 13. The code contains links as anchor tags and such links are not mapped. Only internal Confluence links are mapped.' ,
158+ '- 14. 15. Replied in the original ticket.' ,
159+ '- 16. This is a feature of how the editor works. Possibly a bug, will discuss with the team.' ,
160+ '- 17. Duplicates of previous errors.' ,
161+ '- 18. The image was inserted by link and either was not found or is not accessible.' ,
162+ '- 19. 20. Expected behavior and link highlighting.' ,
163+ ] . join ( '\n' ) ;
164+
165+ const parsedDoc = parser . parse ( markdown ) ;
166+
167+ const view = new EditorView ( null , {
168+ state : EditorState . create ( { schema, plugins : [ collapseListsPlugin ( ) ] } ) ,
169+ } ) ;
170+
171+ expect ( ( ) => {
172+ view . dispatch (
173+ view . state . tr
174+ . setSelection ( new AllSelection ( view . state . doc ) )
175+ . replaceSelection ( new Slice ( parsedDoc . content , 0 , 0 ) ) ,
176+ ) ;
177+ } ) . not . toThrow ( ) ;
178+
179+ const resultDoc = view . state . doc ;
180+ expect ( hasRedundantNesting ( resultDoc ) ) . toBe ( false ) ;
181+
182+ let listItemCount = 0 ;
183+ resultDoc . descendants ( ( node ) => {
184+ if ( node . type . name === ListNode . ListItem ) listItemCount ++ ;
185+ return true ;
186+ } ) ;
187+ expect ( listItemCount ) . toBe ( 17 ) ;
188+ } ) ;
189+
190+ it ( 'collapseAllNestedListItems should return null when no collapsible items exist' , ( ) => {
191+ const view = new EditorView ( null , {
192+ state : EditorState . create ( { schema, plugins : [ collapseListsPlugin ( ) ] } ) ,
193+ } ) ;
194+
195+ const initialDoc = doc ( ul ( li ( p ( 'Plain item' ) ) , li ( p ( 'Another item' ) ) ) ) ;
196+ view . dispatch (
197+ view . state . tr . replaceWith ( 0 , view . state . doc . nodeSize - 2 , initialDoc . content ) ,
198+ ) ;
199+
200+ const { tr} = view . state ;
201+ expect ( collapseAllNestedListItems ( tr ) ) . toBeNull ( ) ;
202+ expect ( tr . docChanged ) . toBe ( false ) ;
203+ } ) ;
204+
205+ it ( 'should correctly collapse adjacent wrapped siblings' , ( ) => {
206+ const view = new EditorView ( null , {
207+ state : EditorState . create ( { schema, plugins : [ collapseListsPlugin ( ) ] } ) ,
208+ } ) ;
209+
210+ const initialDoc = doc (
211+ ul (
212+ li ( ul ( li ( p ( 'First wrapped' ) ) ) ) ,
213+ li ( ul ( li ( p ( 'Second wrapped' ) ) ) ) ,
214+ li ( ul ( li ( p ( 'Third wrapped' ) ) ) ) ,
215+ ) ,
216+ ) ;
217+
218+ view . dispatch (
219+ view . state . tr . replaceWith ( 0 , view . state . doc . nodeSize - 2 , initialDoc . content ) ,
220+ ) ;
221+
222+ expect ( view . state . doc ) . toMatchNode (
223+ doc ( ul ( li ( p ( 'First wrapped' ) ) , li ( p ( 'Second wrapped' ) ) , li ( p ( 'Third wrapped' ) ) ) ) ,
224+ ) ;
225+ } ) ;
226+
227+ it ( 'should correctly collapse adjacent wrapped items with remaining content' , ( ) => {
228+ const view = new EditorView ( null , {
229+ state : EditorState . create ( { schema, plugins : [ collapseListsPlugin ( ) ] } ) ,
230+ } ) ;
231+
232+ const initialDoc = doc (
233+ ul ( li ( ul ( li ( p ( 'A' ) ) ) , p ( 'A-extra' ) ) , li ( ul ( li ( p ( 'B' ) ) ) , p ( 'B-extra' ) ) ) ,
234+ ) ;
235+
236+ view . dispatch (
237+ view . state . tr . replaceWith ( 0 , view . state . doc . nodeSize - 2 , initialDoc . content ) ,
238+ ) ;
239+
240+ expect ( view . state . doc ) . toMatchNode (
241+ doc ( ul ( li ( p ( 'A' ) ) , li ( p ( 'A-extra' ) ) , li ( p ( 'B' ) ) , li ( p ( 'B-extra' ) ) ) ) ,
242+ ) ;
243+ } ) ;
142244} ) ;
245+
246+ describe ( 'CollapseListsPlugin + MergeListsPlugin integration' , ( ) => {
247+ it ( 'should collapse wrapped nesting and merge resulting adjacent lists' , ( ) => {
248+ const markdown = [
249+ '- 1. First item' ,
250+ '- 2. Second item' ,
251+ '' ,
252+ 'Some text between' ,
253+ '' ,
254+ '- 3. Third item' ,
255+ ] . join ( '\n' ) ;
256+
257+ const parsedDoc = parser . parse ( markdown ) ;
258+
259+ // register merge BEFORE collapse — same order as production (index.ts)
260+ const view = new EditorView ( null , {
261+ state : EditorState . create ( {
262+ schema,
263+ plugins : [ mergeListsPlugin ( ) , collapseListsPlugin ( ) ] ,
264+ } ) ,
265+ } ) ;
266+
267+ view . dispatch (
268+ view . state . tr
269+ . setSelection ( new AllSelection ( view . state . doc ) )
270+ . replaceSelection ( new Slice ( parsedDoc . content , 0 , 0 ) ) ,
271+ ) ;
272+
273+ const resultDoc = view . state . doc ;
274+
275+ expect ( hasRedundantNesting ( resultDoc ) ) . toBe ( false ) ;
276+
277+ // count top-level bullet_list nodes — the first two items should
278+ // be in one list (merged), the third after the paragraph in another
279+ let bulletListCount = 0 ;
280+ resultDoc . forEach ( ( child ) => {
281+ if ( child . type . name === ListNode . BulletList ) bulletListCount ++ ;
282+ } ) ;
283+ expect ( bulletListCount ) . toBe ( 2 ) ;
284+ } ) ;
285+
286+ it ( 'should merge adjacent same-type lists produced by collapse' , ( ) => {
287+ // two separate bullet lists that each contain redundant nesting
288+ // after collapse, the lists are adjacent and should be merged
289+ const view = new EditorView ( null , {
290+ state : EditorState . create ( {
291+ schema,
292+ plugins : [ mergeListsPlugin ( ) , collapseListsPlugin ( ) ] ,
293+ } ) ,
294+ } ) ;
295+
296+ // two separate top-level bullet lists, each with redundant nesting
297+ const initialDoc = doc ( ul ( li ( ul ( li ( p ( 'A' ) ) ) ) ) , ul ( li ( ul ( li ( p ( 'B' ) ) ) ) ) ) ;
298+
299+ view . dispatch (
300+ view . state . tr . replaceWith ( 0 , view . state . doc . nodeSize - 2 , initialDoc . content ) ,
301+ ) ;
302+
303+ const resultDoc = view . state . doc ;
304+ expect ( hasRedundantNesting ( resultDoc ) ) . toBe ( false ) ;
305+
306+ // after collapse both items are flat, and merge should
307+ // combine the two adjacent bullet_lists into one
308+ let bulletListCount = 0 ;
309+ resultDoc . forEach ( ( child ) => {
310+ if ( child . type . name === ListNode . BulletList ) bulletListCount ++ ;
311+ } ) ;
312+ expect ( bulletListCount ) . toBe ( 1 ) ;
313+
314+ expect ( resultDoc ) . toMatchNode ( doc ( ul ( li ( p ( 'A' ) ) , li ( p ( 'B' ) ) ) ) ) ;
315+ } ) ;
316+ } ) ;
317+
318+ function hasRedundantNesting ( node : Node ) : boolean {
319+ let found = false ;
320+ node . descendants ( ( child ) => {
321+ if ( found ) return false ;
322+ if ( child . type . name === ListNode . ListItem && child . firstChild ) {
323+ const fc = child . firstChild ;
324+ if ( fc . type . name === ListNode . BulletList || fc . type . name === ListNode . OrderedList ) {
325+ found = true ;
326+ return false ;
327+ }
328+ }
329+ return true ;
330+ } ) ;
331+ return found ;
332+ }
0 commit comments