Skip to content

Commit 12a51e6

Browse files
committed
feat: list fixes, toggle list fix, prep for run node
1 parent a5ed828 commit 12a51e6

22 files changed

+2276
-485
lines changed

package-lock.json

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
],
99
"scripts": {
1010
"test": "vitest ./packages/superdoc/ ./packages/super-editor/",
11+
"test:cov": "vitest --root ./packages/super-editor --coverage",
1112
"unzip": "bash packages/super-editor/src/tests/helpers/unzip.sh",
1213
"dev": "npm --workspace=@harbour-enterprises/superdoc run dev",
1314
"dev:superdoc": "npm run dev --workspace=packages/superdoc",

packages/super-editor/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"@vue/test-utils": "^2.4.6",
8787
"postcss-nested": "^6.0.1",
8888
"postcss-nested-import": "^1.3.0",
89+
"prosemirror-test-builder": "^1.1.1",
8990
"tippy.js": "^6.3.7",
9091
"typescript": "^5.7.3",
9192
"vite": "^6.3.5",
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// @ts-check
2+
import { Fragment } from 'prosemirror-model';
3+
import { TextSelection } from 'prosemirror-state';
4+
import { decreaseListIndent } from './decreaseListIndent.js';
5+
import { isList, findNodePosition } from './list-helpers';
6+
7+
/**
8+
* Handle backspace key behavior when the caret is next to a list.
9+
* @returns {Function} A command function to be used in the editor.
10+
*/
11+
export const handleBackspaceNextToList =
12+
() =>
13+
({ state, dispatch, editor }) => {
14+
const { selection, doc } = state;
15+
const { $from } = selection;
16+
17+
if (!selection.empty) return false;
18+
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+
}
32+
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+
});
45+
if (didOutdent) return true;
46+
}
47+
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);
53+
54+
const tr = state.tr;
55+
// If the listItem has paragraphs/content, use that; otherwise drop an empty paragraph.
56+
const replacement =
57+
li && li.content && li.content.size > 0 ? li.content : Fragment.from(state.schema.nodes.paragraph.create());
58+
59+
tr.replaceWith(posBeforeList, posBeforeList + listNode.nodeSize, replacement);
60+
61+
// Put the caret at the start of the first inserted paragraph
62+
const newPos = posBeforeList + 1; // into first block node
63+
tr.setSelection(TextSelection.near(tr.doc.resolve(newPos), 1)).scrollIntoView();
64+
65+
dispatch(tr);
66+
return true;
67+
}
68+
69+
/* Case B: caret OUTSIDE a list; previous sibling is a list */
70+
const parentDepth = $from.depth - 1;
71+
if (parentDepth < 0) return false;
72+
73+
const container = $from.node(parentDepth);
74+
const idx = $from.index(parentDepth);
75+
76+
// Must have a node before us
77+
if (idx === 0) return false;
78+
79+
const beforeNode = container.child(idx - 1);
80+
if (!beforeNode || !isList(beforeNode)) return false;
81+
82+
const listItem = beforeNode.lastChild;
83+
if (!listItem || listItem.type.name !== 'listItem') return false;
84+
85+
// Merge into the last paragraph of the previous list
86+
const targetPara = listItem.lastChild;
87+
if (!targetPara || targetPara.type.name !== 'paragraph') return false;
88+
89+
const paraStartPos = findNodePosition(doc, targetPara);
90+
if (paraStartPos == null) return false;
91+
92+
const inlineContent = Fragment.from($from.parent.content);
93+
const tr = state.tr;
94+
tr.setMeta('updateListSync', true);
95+
96+
const oldParaPos = $from.before(); // safe: parentDepth >= 0 and parent is paragraph
97+
tr.delete(oldParaPos, oldParaPos + $from.parent.nodeSize);
98+
99+
const insertPos = paraStartPos + 1 + targetPara.content.size;
100+
tr.insert(insertPos, inlineContent);
101+
102+
tr.setSelection(TextSelection.near(tr.doc.resolve(insertPos), 1));
103+
104+
dispatch(tr);
105+
return true;
106+
};
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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+
});

packages/super-editor/src/core/commands/deleteListItem.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const deleteListItem = () => (props) => {
4141
return true;
4242
}
4343

44-
// no full blocks found let other commands handle it
44+
// no full blocks found: let other commands handle it
4545
return false;
4646
}
4747

@@ -65,13 +65,13 @@ export const deleteListItem = () => (props) => {
6565
const listFrom = parentList.pos;
6666
const listTo = listFrom + parentList.node.nodeSize;
6767

68-
// Case 1: empty list item remove whole list
68+
// Case 1: empty list item: remove whole list
6969
if (currentListItem.node.content.size === 0) {
7070
tr.delete(listFrom, listTo);
7171
return true;
7272
}
7373

74-
// Case 2: non‐empty list item replace list with all content from the list item
74+
// Case 2: non‐empty list item: replace list with all content from the list item
7575
const listItemContent = currentListItem.node.content;
7676

7777
// Create nodes from the list item content

0 commit comments

Comments
 (0)