|
| 1 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 2 | +import { Schema, Fragment } from 'prosemirror-model'; |
| 3 | +import { EditorState, TextSelection } from 'prosemirror-state'; |
| 4 | + |
| 5 | +import { decreaseListIndent as mockDecreaseListIndent } from './decreaseListIndent.js'; |
| 6 | +import { handleBackspaceNextToList } from './backspaceNextToList.js'; |
| 7 | + |
| 8 | +vi.mock('./decreaseListIndent.js', () => ({ |
| 9 | + decreaseListIndent: vi.fn(() => { |
| 10 | + // default mock: command that returns false (no outdent) |
| 11 | + return () => false; |
| 12 | + }), |
| 13 | +})); |
| 14 | + |
| 15 | +function makeSchema() { |
| 16 | + const nodes = { |
| 17 | + doc: { content: 'block+' }, |
| 18 | + paragraph: { group: 'block', content: 'text*' }, |
| 19 | + text: { group: 'inline' }, |
| 20 | + |
| 21 | + orderedList: { |
| 22 | + group: 'block', |
| 23 | + content: 'listItem+', |
| 24 | + renderDOM: () => ['ol', 0], |
| 25 | + parseDOM: () => [{ tag: 'ol' }], |
| 26 | + }, |
| 27 | + bulletList: { |
| 28 | + group: 'block', |
| 29 | + content: 'listItem+', |
| 30 | + renderDOM: () => ['ul', 0], |
| 31 | + parseDOM: () => [{ tag: 'ul' }], |
| 32 | + }, |
| 33 | + listItem: { |
| 34 | + group: 'block', |
| 35 | + content: 'paragraph block*', |
| 36 | + defining: true, |
| 37 | + renderDOM: () => ['li', 0], |
| 38 | + parseDOM: () => [{ tag: 'li' }], |
| 39 | + }, |
| 40 | + }; |
| 41 | + return new Schema({ nodes }); |
| 42 | +} |
| 43 | + |
| 44 | +function findNodePos(doc, predicate) { |
| 45 | + let found = null; |
| 46 | + doc.descendants((node, pos) => { |
| 47 | + if (predicate(node)) { |
| 48 | + found = pos; |
| 49 | + return false; |
| 50 | + } |
| 51 | + return true; |
| 52 | + }); |
| 53 | + return found; |
| 54 | +} |
| 55 | + |
| 56 | +describe('handleBackspaceNextToList', () => { |
| 57 | + let schema; |
| 58 | + |
| 59 | + beforeEach(() => { |
| 60 | + vi.clearAllMocks(); |
| 61 | + schema = makeSchema(); |
| 62 | + }); |
| 63 | + |
| 64 | + it('returns false if selection is not empty', () => { |
| 65 | + const doc = schema.node('doc', null, [schema.node('paragraph', null, schema.text('hello'))]); |
| 66 | + const sel = TextSelection.create(doc, 2, 4); // non-empty |
| 67 | + const state = EditorState.create({ schema, doc, selection: sel }); |
| 68 | + |
| 69 | + const cmd = handleBackspaceNextToList(); |
| 70 | + const dispatch = vi.fn(); |
| 71 | + |
| 72 | + const res = cmd({ state, dispatch, editor: {} }); |
| 73 | + expect(res).toBe(false); |
| 74 | + expect(dispatch).not.toHaveBeenCalled(); |
| 75 | + }); |
| 76 | + |
| 77 | + it('returns false if not at start of a paragraph', () => { |
| 78 | + const doc = schema.node('doc', null, [schema.node('paragraph', null, schema.text('hello'))]); |
| 79 | + const sel = TextSelection.create(doc, 3, 3); // inside paragraph, not at start |
| 80 | + const state = EditorState.create({ schema, doc, selection: sel }); |
| 81 | + |
| 82 | + const cmd = handleBackspaceNextToList(); |
| 83 | + const dispatch = vi.fn(); |
| 84 | + |
| 85 | + const res = cmd({ state, dispatch, editor: {} }); |
| 86 | + expect(res).toBe(false); |
| 87 | + expect(dispatch).not.toHaveBeenCalled(); |
| 88 | + }); |
| 89 | + |
| 90 | + it('inside a list: delegates to decreaseListIndent when it returns true', () => { |
| 91 | + // Make decreaseListIndent() return a command that returns true |
| 92 | + mockDecreaseListIndent.mockImplementationOnce(() => () => true); |
| 93 | + |
| 94 | + const liPara = schema.node('paragraph', null, schema.text('item')); |
| 95 | + const list = schema.node('orderedList', null, [schema.node('listItem', null, [liPara])]); |
| 96 | + |
| 97 | + const doc = schema.node('doc', null, [list]); |
| 98 | + // caret at start of the paragraph inside the list |
| 99 | + const paraPos = findNodePos(doc, (n) => n === liPara); |
| 100 | + const sel = TextSelection.create(doc, paraPos + 1, paraPos + 1); |
| 101 | + const state = EditorState.create({ schema, doc, selection: sel }); |
| 102 | + |
| 103 | + const cmd = handleBackspaceNextToList(); |
| 104 | + const dispatch = vi.fn(); |
| 105 | + |
| 106 | + const ok = cmd({ state, dispatch, editor: {} }); |
| 107 | + expect(ok).toBe(true); |
| 108 | + // decreaseListIndent should have been called once (outer function) |
| 109 | + expect(mockDecreaseListIndent).toHaveBeenCalledTimes(1); |
| 110 | + // We don't assert doc shape here; this path is delegated. |
| 111 | + }); |
| 112 | + |
| 113 | + it('inside a list: unwraps list when decreaseListIndent returns false', () => { |
| 114 | + // default mock already returns false (no outdent) |
| 115 | + const liPara = schema.node('paragraph', null, schema.text('item')); |
| 116 | + const list = schema.node('orderedList', null, [schema.node('listItem', null, [liPara])]); |
| 117 | + const after = schema.node('paragraph', null, schema.text('after')); |
| 118 | + |
| 119 | + const doc = schema.node('doc', null, [list, after]); |
| 120 | + |
| 121 | + const paraPos = findNodePos(doc, (n) => n === liPara); |
| 122 | + const sel = TextSelection.create(doc, paraPos + 1, paraPos + 1); |
| 123 | + const state = EditorState.create({ schema, doc, selection: sel }); |
| 124 | + |
| 125 | + const cmd = handleBackspaceNextToList(); |
| 126 | + let dispatched = null; |
| 127 | + const dispatch = (tr) => (dispatched = tr); |
| 128 | + |
| 129 | + const ok = cmd({ state, dispatch, editor: {} }); |
| 130 | + expect(ok).toBe(true); |
| 131 | + expect(mockDecreaseListIndent).toHaveBeenCalledTimes(1); |
| 132 | + |
| 133 | + // The list should be replaced by its listItem content ("item"), followed by "after" |
| 134 | + const outText = dispatched.doc.textBetween(0, dispatched.doc.content.size, ' '); |
| 135 | + expect(outText).toContain('item'); |
| 136 | + expect(outText).toContain('after'); |
| 137 | + |
| 138 | + // Selection should be at the start of the first inserted paragraph (near posBeforeList + 1) |
| 139 | + const selPos = dispatched.selection.from; |
| 140 | + // That should resolve to a paragraph |
| 141 | + const $pos = dispatched.doc.resolve(selPos); |
| 142 | + expect($pos.parent.type.name).toBe('paragraph'); |
| 143 | + expect($pos.parentOffset).toBe(0); |
| 144 | + }); |
| 145 | + |
| 146 | + it('outside a list with a previous sibling list: merges paragraph into last list item', () => { |
| 147 | + const li1 = schema.node('paragraph', null, schema.text('alpha')); |
| 148 | + const li2 = schema.node('paragraph', null, schema.text('beta')); |
| 149 | + const list = schema.node('bulletList', null, [ |
| 150 | + schema.node('listItem', null, [li1]), |
| 151 | + schema.node('listItem', null, [li2]), |
| 152 | + ]); |
| 153 | + |
| 154 | + const followingPara = schema.node('paragraph', null, schema.text(' tail')); |
| 155 | + const doc = schema.node('doc', null, [list, followingPara]); |
| 156 | + |
| 157 | + // caret at start of the following paragraph |
| 158 | + const paraPos = findNodePos(doc, (n) => n === followingPara); |
| 159 | + const sel = TextSelection.create(doc, paraPos + 1, paraPos + 1); |
| 160 | + const state = EditorState.create({ schema, doc, selection: sel }); |
| 161 | + |
| 162 | + const cmd = handleBackspaceNextToList(); |
| 163 | + let dispatched = null; |
| 164 | + const dispatch = (tr) => (dispatched = tr); |
| 165 | + |
| 166 | + const ok = cmd({ state, dispatch, editor: {} }); |
| 167 | + expect(ok).toBe(true); |
| 168 | + |
| 169 | + // Should have set meta updateListSync = true |
| 170 | + expect(dispatched.getMeta('updateListSync')).toBe(true); |
| 171 | + |
| 172 | + // The following paragraph is removed, its content appended to last list item's paragraph |
| 173 | + const outText = dispatched.doc.textBetween(0, dispatched.doc.content.size, ' '); |
| 174 | + // alpha (first li) |
| 175 | + expect(outText).toContain('alpha'); |
| 176 | + // beta + tail merged |
| 177 | + expect(outText).toContain('beta tail'); |
| 178 | + |
| 179 | + // Selection placed near the end of the inserted content in the last list paragraph |
| 180 | + const selParent = dispatched.selection.$from.parent; |
| 181 | + expect(selParent.type.name).toBe('paragraph'); |
| 182 | + // It should be the last paragraph inside the last list item |
| 183 | + const lastList = dispatched.doc.child(0); // first block is the list |
| 184 | + const lastItem = lastList.lastChild; |
| 185 | + const lastPara = lastItem.lastChild; |
| 186 | + expect(selParent).toBe(lastPara); |
| 187 | + }); |
| 188 | + |
| 189 | + it('returns false when parent is not a paragraph', () => { |
| 190 | + // caret at start of listItem (not paragraph) |
| 191 | + const liPara = schema.node('paragraph', null, schema.text('x')); |
| 192 | + const list = schema.node('orderedList', null, [schema.node('listItem', null, [liPara])]); |
| 193 | + const doc = schema.node('doc', null, [list]); |
| 194 | + |
| 195 | + // Place cursor at the very start of the list node (not valid paragraph start case) |
| 196 | + const listPos = findNodePos(doc, (n) => n === list); |
| 197 | + // Resolve to pos inside the list node (1 step in) |
| 198 | + const sel = TextSelection.create(doc, listPos + 1, listPos + 1); |
| 199 | + const state = EditorState.create({ schema, doc, selection: sel }); |
| 200 | + |
| 201 | + const cmd = handleBackspaceNextToList(); |
| 202 | + const dispatch = vi.fn(); |
| 203 | + |
| 204 | + const res = cmd({ state, dispatch, editor: {} }); |
| 205 | + expect(res).toBe(false); |
| 206 | + expect(dispatch).not.toHaveBeenCalled(); |
| 207 | + }); |
| 208 | +}); |
0 commit comments