Skip to content

Commit 8b33258

Browse files
committed
fix: backspaceNextToList, toggleList and tests
1 parent 40d3b54 commit 8b33258

5 files changed

Lines changed: 277 additions & 138 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"shared/*"
88
],
99
"scripts": {
10-
"test": "vitest ./packages/superdoc/ ./packages/super-editor/",
10+
"test": "vitest --root ./packages/super-editor/",
1111
"test:cov": "vitest --root ./packages/super-editor --coverage",
1212
"unzip": "bash packages/super-editor/src/tests/helpers/unzip.sh",
1313
"dev": "npm --workspace=@harbour-enterprises/superdoc run dev",

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

Lines changed: 80 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,99 +4,130 @@ import { TextSelection } from 'prosemirror-state';
44
import { decreaseListIndent } from './decreaseListIndent.js';
55
import { 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
*/
1135
export 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

Comments
 (0)