Skip to content

Commit 4fcb771

Browse files
committed
fix: toggleList creates new items instead of toggling off with trailing paragraph
Fixes #7398 When using Cmd+A (AllSelection) to select all content, the selection includes the trailing empty paragraph added by ProseMirror. Previously, toggleBulletList/toggleOrderedList would wrap this trailing paragraph into the list instead of toggling the list off. This fix detects when AllSelection includes a trailing empty paragraph after the list and adjusts the selection to exclude it before executing the liftListItem command. Changes: - Import originalLiftListItem from @tiptap/pm/schema-list - Add AllSelection detection in toggleList command - Adjust selection to exclude trailing empty paragraph - Use chain() to apply adjusted selection before lifting list items - Add test cases for the fix
1 parent 3e446fb commit 4fcb771

File tree

3 files changed

+214
-3
lines changed

3 files changed

+214
-3
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@tiptap/core": patch
3+
"@tiptap/extension-list": patch
4+
---
5+
6+
Fix toggleBulletList/toggleOrderedList not toggling off when selection includes trailing paragraph
7+
8+
When using Cmd+A to select all content, the selection includes the trailing empty paragraph. Previously, toggleBulletList() and toggleOrderedList() would wrap this trailing paragraph into the list instead of toggling the list off, creating new list items.
9+
10+
This fix detects when the selection includes a trailing empty paragraph after the list and adjusts the selection to exclude it before executing the lift command, allowing the list to be properly toggled off.

packages/core/src/commands/toggleList.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { NodeType } from '@tiptap/pm/model'
2+
import { liftListItem as originalLiftListItem } from '@tiptap/pm/schema-list'
23
import type { Transaction } from '@tiptap/pm/state'
4+
import { AllSelection, TextSelection } from '@tiptap/pm/state'
35
import { canJoin } from '@tiptap/pm/transform'
46

57
import { findParentNode } from '../helpers/findParentNode.js'
@@ -84,8 +86,67 @@ export const toggleList: RawCommands['toggleList'] =
8486
const { extensions, splittableMarks } = editor.extensionManager
8587
const listType = getNodeType(listTypeOrName, state.schema)
8688
const itemType = getNodeType(itemTypeOrName, state.schema)
87-
const { selection, storedMarks } = state
88-
const { $from, $to } = selection
89+
const { storedMarks } = state
90+
let { selection } = state
91+
let { $from, $to } = selection
92+
93+
// Special handling for AllSelection (Cmd+A) which includes trailing nodes
94+
// When AllSelection is used and we're toggling off a list, adjust selection to exclude trailing paragraph
95+
if (selection instanceof AllSelection) {
96+
const firstChild = state.doc.firstChild
97+
const lastChild = state.doc.lastChild
98+
99+
// Check if first child is a list of the same type we're toggling
100+
if (firstChild && firstChild.type === listType) {
101+
// Check if there's a trailing empty paragraph after the list
102+
const hasTrailingEmptyParagraph =
103+
lastChild && lastChild !== firstChild && lastChild.type.name === 'paragraph' && lastChild.content.size === 0
104+
105+
// If there's a trailing paragraph, adjust selection to only cover the list
106+
if (hasTrailingEmptyParagraph) {
107+
// Find the first text position inside the list
108+
const listStart = 1
109+
const listEnd = firstChild.nodeSize
110+
111+
// Find first valid text position inside the list
112+
let firstTextPos = listStart + 1
113+
let foundTextPos = false
114+
115+
// Traverse to find the first position with inline content
116+
state.doc.nodesBetween(listStart, listEnd, (node, pos) => {
117+
if (!foundTextPos && node.isTextblock) {
118+
firstTextPos = pos + 1
119+
foundTextPos = true
120+
return false
121+
}
122+
})
123+
124+
if (foundTextPos) {
125+
// Find the last valid text position inside the list
126+
let lastTextPos = firstTextPos
127+
128+
state.doc.nodesBetween(listStart, listEnd, (node, pos) => {
129+
if (node.isTextblock) {
130+
lastTextPos = pos + node.nodeSize - 1
131+
}
132+
})
133+
134+
// Create a new selection from the start to the end of list content
135+
try {
136+
const newSelection = TextSelection.create(state.doc, firstTextPos, lastTextPos)
137+
138+
// Update the selection variables that will be used in the rest of the function
139+
selection = newSelection
140+
$from = newSelection.$from
141+
$to = newSelection.$to
142+
} catch {
143+
// If selection creation fails, continue with original selection
144+
}
145+
}
146+
}
147+
}
148+
}
149+
89150
const range = $from.blockRange($to)
90151

91152
const marks = storedMarks || (selection.$to.parentOffset && selection.$from.marks())
@@ -99,7 +160,18 @@ export const toggleList: RawCommands['toggleList'] =
99160
if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) {
100161
// remove list
101162
if (parentList.node.type === listType) {
102-
return commands.liftListItem(itemType)
163+
// Use chain to set selection and then lift
164+
return chain()
165+
.command(({ tr: chainTr, dispatch: chainDispatch }) => {
166+
if (chainDispatch) {
167+
chainTr.setSelection(selection)
168+
}
169+
return true
170+
})
171+
.command(({ state: chainState, dispatch: chainDispatch }) => {
172+
return originalLiftListItem(itemType)(chainState, chainDispatch)
173+
})
174+
.run()
103175
}
104176

105177
// change list type
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { Editor } from '@tiptap/core'
2+
import Document from '@tiptap/extension-document'
3+
import Paragraph from '@tiptap/extension-paragraph'
4+
import Text from '@tiptap/extension-text'
5+
import { TrailingNode } from '@tiptap/extensions'
6+
import { afterEach, describe, expect, it } from 'vitest'
7+
8+
import { BulletList, ListItem, OrderedList } from '../src/index.js'
9+
10+
describe('toggleList with trailing paragraph', () => {
11+
let editor: Editor
12+
13+
afterEach(() => {
14+
editor?.destroy()
15+
})
16+
17+
it('should toggle off bullet list when selecting all content including trailing paragraph', () => {
18+
editor = new Editor({
19+
extensions: [Document, Paragraph, Text, BulletList, OrderedList, ListItem, TrailingNode],
20+
content: '<ul><li><p>hello</p></li></ul>',
21+
})
22+
23+
// Select all content (Cmd+A) - this includes the trailing empty paragraph
24+
editor.commands.selectAll()
25+
26+
// Toggle bullet list should remove the list, not wrap trailing paragraph
27+
editor.commands.toggleBulletList()
28+
29+
// The content should be a plain paragraph, not a list
30+
const json = editor.getJSON()
31+
expect(json.content?.[0]?.type).toBe('paragraph')
32+
expect(json.content?.[0]?.content?.[0]?.text).toBe('hello')
33+
34+
// Should not have bulletList in the document
35+
expect(json.content?.some(node => node.type === 'bulletList')).toBe(false)
36+
})
37+
38+
it('should toggle off ordered list when selecting all content including trailing paragraph', () => {
39+
editor = new Editor({
40+
extensions: [Document, Paragraph, Text, BulletList, OrderedList, ListItem, TrailingNode],
41+
content: '<ol><li><p>hello</p></li></ol>',
42+
})
43+
44+
// Select all content (Cmd+A)
45+
editor.commands.selectAll()
46+
47+
// Toggle ordered list should remove the list
48+
editor.commands.toggleOrderedList()
49+
50+
// The content should be a plain paragraph, not a list
51+
const json = editor.getJSON()
52+
expect(json.content?.[0]?.type).toBe('paragraph')
53+
expect(json.content?.[0]?.content?.[0]?.text).toBe('hello')
54+
55+
// Should not have orderedList in the document
56+
expect(json.content?.some(node => node.type === 'orderedList')).toBe(false)
57+
})
58+
59+
it('should toggle off list with multiple items when selecting all', () => {
60+
editor = new Editor({
61+
extensions: [Document, Paragraph, Text, BulletList, OrderedList, ListItem, TrailingNode],
62+
content: '<ul><li><p>item 1</p></li><li><p>item 2</p></li><li><p>item 3</p></li></ul>',
63+
})
64+
65+
// Select all content
66+
editor.commands.selectAll()
67+
68+
// Toggle bullet list should convert all items to paragraphs
69+
editor.commands.toggleBulletList()
70+
71+
const json = editor.getJSON()
72+
73+
// Should have 3 paragraphs
74+
expect(json.content?.length).toBeGreaterThanOrEqual(3)
75+
expect(json.content?.[0]?.type).toBe('paragraph')
76+
expect(json.content?.[0]?.content?.[0]?.text).toBe('item 1')
77+
expect(json.content?.[1]?.type).toBe('paragraph')
78+
expect(json.content?.[1]?.content?.[0]?.text).toBe('item 2')
79+
expect(json.content?.[2]?.type).toBe('paragraph')
80+
expect(json.content?.[2]?.content?.[0]?.text).toBe('item 3')
81+
82+
// Should not have bulletList in the document
83+
expect(json.content?.some(node => node.type === 'bulletList')).toBe(false)
84+
})
85+
86+
it('should work correctly when manually selecting only list content (without trailing paragraph)', () => {
87+
editor = new Editor({
88+
extensions: [Document, Paragraph, Text, BulletList, OrderedList, ListItem],
89+
content: '<ul><li><p>hello</p></li></ul>',
90+
})
91+
92+
// Manually select only the list content (not including trailing paragraph)
93+
// Position 0 is start of doc, list starts at 1, content is at position 2-7
94+
editor.commands.setTextSelection({ from: 1, to: 9 })
95+
96+
// Toggle bullet list should remove the list
97+
editor.commands.toggleBulletList()
98+
99+
const json = editor.getJSON()
100+
expect(json.content?.[0]?.type).toBe('paragraph')
101+
expect(json.content?.[0]?.content?.[0]?.text).toBe('hello')
102+
})
103+
104+
it('should handle list with non-empty trailing paragraph correctly', () => {
105+
editor = new Editor({
106+
extensions: [Document, Paragraph, Text, BulletList, OrderedList, ListItem, TrailingNode],
107+
content: '<ul><li><p>list item</p></li></ul><p>after list</p>',
108+
})
109+
110+
// Select all content - this includes the non-empty paragraph after the list
111+
editor.commands.selectAll()
112+
113+
// Toggle bullet list - this should wrap "after list" into the list
114+
// because it's not an empty trailing paragraph (it has content)
115+
editor.commands.toggleBulletList()
116+
117+
const json = editor.getJSON()
118+
119+
// The behavior with non-empty trailing content is different:
120+
// It should wrap the trailing paragraph into the list
121+
expect(json.content?.[0]?.type).toBe('bulletList')
122+
123+
// Should have 2 list items now
124+
const bulletList = json.content?.[0]
125+
expect(bulletList?.content?.length).toBe(2)
126+
expect(bulletList?.content?.[0]?.content?.[0]?.content?.[0]?.text).toBe('list item')
127+
expect(bulletList?.content?.[1]?.content?.[0]?.content?.[0]?.text).toBe('after list')
128+
})
129+
})

0 commit comments

Comments
 (0)