@@ -4,99 +4,130 @@ import { TextSelection } from 'prosemirror-state';
44import { decreaseListIndent } from './decreaseListIndent.js' ;
55import { isList , findNodePosition } from './list-helpers' ;
66
7+ /**
8+ * Return nearest list container and its single listItem for our MS-Word model.
9+ * @param {import("prosemirror-state").EditorState } state
10+ * @returns {{ listDepth: number, listPos: number, listNode: import("prosemirror-model").Node, liNode: import("prosemirror-model").Node } | null }
11+ */
12+ function getListContext ( state ) {
13+ const { $from } = state . selection ;
14+ for ( let d = $from . depth ; d > 0 ; d -- ) {
15+ const node = $from . node ( d - 1 ) ;
16+ if ( isList ( node ) ) {
17+ const listDepth = d - 1 ;
18+ const listPos = $from . before ( listDepth ) ;
19+ const listNode = node ;
20+ const liNode = listNode . firstChild || null ;
21+ if ( ! liNode || liNode . type . name !== 'listItem' ) return null ;
22+ return { listDepth, listPos, listNode, liNode } ;
23+ }
24+ }
25+ return null ;
26+ }
27+
728/**
829 * Handle backspace key behavior when the caret is next to a list.
9- * @returns {Function } A command function to be used in the editor.
30+ * - If inside a list item at start or the item is empty:
31+ * level > 0 → outdent
32+ * level = 0 → unwrap to paragraph(s)
33+ * - If outside a list and previous sibling is a list → merge with its last paragraph
1034 */
1135export const handleBackspaceNextToList =
1236 ( ) =>
1337 ( { state, dispatch, editor } ) => {
14- const { selection, doc } = state ;
38+ const { selection, doc, schema } = state ;
1539 const { $from } = selection ;
1640
1741 if ( ! selection . empty ) return false ;
1842 if ( $from . parent . type . name !== 'paragraph' ) return false ;
19- if ( $from . parentOffset !== 0 ) return false ; // Only at start of paragraph
20-
21- /* Case A: caret INSIDE a list */
22- let depth = $from . depth ;
23- let listDepth = - 1 ;
24- while ( depth > 0 ) {
25- const n = $from . node ( depth - 1 ) ;
26- if ( isList ( n ) ) {
27- listDepth = depth - 1 ;
28- break ;
29- }
30- depth -- ;
31- }
3243
33- if ( listDepth !== - 1 ) {
34- // We are inside a list’s single listItem (MS Word model).
35- // 1) Try to decrease indent
36- // Note: provide a fresh tr to allow the command to operate.
37- const tr1 = state . tr ;
38- if ( decreaseListIndent && typeof decreaseListIndent === 'function' ) {
39- const didOutdent = decreaseListIndent ( ) ( {
40- editor,
41- state,
42- tr : tr1 ,
43- dispatch : ( t ) => t && dispatch && dispatch ( t ) ,
44- } ) ;
44+ // --- Case A: caret INSIDE a list item (our MS-Word model: list -> listItem -> paragraph)
45+ const ctx = getListContext ( state ) ;
46+ if ( ctx ) {
47+ const { listDepth, listPos, listNode, liNode } = ctx ;
48+
49+ // Only trigger at the start of the item's current paragraph OR if the item is empty
50+ const atStartOfParagraph = $from . parentOffset === 0 ;
51+ const itemIsEmpty = liNode . childCount > 0 ? liNode . firstChild ?. content . size === 0 : true ;
52+ if ( ! atStartOfParagraph && ! itemIsEmpty ) return false ;
53+
54+ const level = Number ( liNode . attrs ?. level ?? 0 ) ;
55+
56+ // 1) Try to OUTDENT if we have room
57+ if ( level > 0 ) {
58+ const tr1 = state . tr . setMeta ( 'updateListSync' , true ) ;
59+ const didOutdent =
60+ typeof decreaseListIndent === 'function' &&
61+ decreaseListIndent ( ) ( {
62+ editor,
63+ state,
64+ tr : tr1 ,
65+ dispatch : ( t ) => dispatch && t && dispatch ( t ) ,
66+ } ) ;
67+
4568 if ( didOutdent ) return true ;
46- }
4769
48- // 2) Already at minimum level: unwrap the list:
49- // Replace the WHOLE list block with its listItem content (paragraphs).
50- const listNode = $from . node ( listDepth ) ;
51- const li = listNode . firstChild ;
52- const posBeforeList = listDepth === 0 ? 0 : $from . before ( listDepth ) ;
70+ // Fallback (shouldn't be needed if command works): manually drop level
71+ const liPos = listPos + 1 ; // list + (open) => first child pos
72+ const newLevel = Math . max ( 0 , level - 1 ) ;
73+ const trFallback = state . tr . setMeta ( 'updateListSync' , true ) ;
74+ trFallback . setNodeMarkup ( liPos , null , { ...liNode . attrs , level : newLevel } ) ;
75+ dispatch ( trFallback ) ;
76+ return true ;
77+ }
5378
54- const tr = state . tr ;
55- // If the listItem has paragraphs/content, use that; otherwise drop an empty paragraph.
79+ // 2) Already at level 0 → unwrap the WHOLE list into its item content
5680 const replacement =
57- li && li . content && li . content . size > 0 ? li . content : Fragment . from ( state . schema . nodes . paragraph . create ( ) ) ;
81+ liNode && liNode . content && liNode . content . size > 0
82+ ? liNode . content
83+ : Fragment . from ( schema . nodes . paragraph . create ( ) ) ;
84+ const from = listPos ;
85+ const to = listPos + listNode . nodeSize ;
5886
59- tr . replaceWith ( posBeforeList , posBeforeList + listNode . nodeSize , replacement ) ;
87+ const tr = state . tr . setMeta ( 'updateListSync' , true ) ;
88+ tr . replaceWith ( from , to , replacement ) ;
6089
61- // Put the caret at the start of the first inserted paragraph
62- const newPos = posBeforeList + 1 ; // into first block node
90+ // Caret at start of the first inserted paragraph
91+ const newPos = from + 1 ;
6392 tr . setSelection ( TextSelection . near ( tr . doc . resolve ( newPos ) , 1 ) ) . scrollIntoView ( ) ;
6493
65- tr . setMeta ( 'updateListSync' , true ) ;
6694 dispatch ( tr ) ;
6795 return true ;
6896 }
6997
70- /* Case B: caret OUTSIDE a list; previous sibling is a list */
98+ // --- Case B: caret OUTSIDE a list; previous sibling is a list → merge
99+ // Only fire when caret is at start of a paragraph
100+ if ( $from . parentOffset !== 0 ) return false ;
101+
71102 const parentDepth = $from . depth - 1 ;
72103 if ( parentDepth < 0 ) return false ;
73104
74105 const container = $from . node ( parentDepth ) ;
75106 const idx = $from . index ( parentDepth ) ;
76-
77- // Must have a node before us
78- if ( idx === 0 ) return false ;
107+ if ( idx === 0 ) return false ; // nothing before
79108
80109 const beforeNode = container . child ( idx - 1 ) ;
81110 if ( ! beforeNode || ! isList ( beforeNode ) ) return false ;
82111
112+ // Previous is a list with single listItem per our model
83113 const listItem = beforeNode . lastChild ;
84114 if ( ! listItem || listItem . type . name !== 'listItem' ) return false ;
85115
86- // Merge into the last paragraph of the previous list
87116 const targetPara = listItem . lastChild ;
88117 if ( ! targetPara || targetPara . type . name !== 'paragraph' ) return false ;
89118
90119 const paraStartPos = findNodePosition ( doc , targetPara ) ;
91120 if ( paraStartPos == null ) return false ;
92121
93122 const inlineContent = Fragment . from ( $from . parent . content ) ;
94- const tr = state . tr ;
95- tr . setMeta ( 'updateListSync' , true ) ;
96123
97- const oldParaPos = $from . before ( ) ; // safe: parentDepth >= 0 and parent is paragraph
98- tr . delete ( oldParaPos , oldParaPos + $from . parent . nodeSize ) ;
124+ const tr = state . tr . setMeta ( 'updateListSync' , true ) ;
125+
126+ // Remove the current empty/at-start paragraph
127+ const thisParaStart = $from . before ( ) ;
128+ tr . delete ( thisParaStart , thisParaStart + $from . parent . nodeSize ) ;
99129
130+ // Append its inline content into the last paragraph of the previous list
100131 const insertPos = paraStartPos + 1 + targetPara . content . size ;
101132 tr . insert ( insertPos , inlineContent ) ;
102133
0 commit comments