Skip to content

Commit ee57ffa

Browse files
committed
fix(lists): allow to toggle between all types of lists
Signed-off-by: Jonas <jonas@freesources.org>
1 parent 9bad17b commit ee57ffa

12 files changed

Lines changed: 328 additions & 53 deletions

File tree

cypress/e2e/nodes/ListItem.spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
import { ListItem, OrderedList } from '@tiptap/extension-list'
77
/* eslint-enable import/no-named-as-default */
88
import Markdown from './../../../src/extensions/Markdown.js'
9-
import BulletList from './../../../src/nodes/BulletList.js'
9+
import BulletList from './../../../src/nodes/BulletList.ts'
1010
import TaskItem from './../../../src/nodes/TaskItem.js'
11-
import TaskList from './../../../src/nodes/TaskList.js'
11+
import TaskList from './../../../src/nodes/TaskList.ts'
1212
import { createCustomEditor } from './../../support/components.js'
1313
import { expectMarkdown, loadMarkdown, runCommands } from './helpers.js'
1414

cypress/fixtures/ListItem.md

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,26 @@
4343
## bullet list to task list
4444

4545
* toggleTaskList
46-
* keep
46+
* two
47+
* three
4748

4849
---
4950

50-
- [ ] did toggleTaskList
51+
* [ ] did toggleTaskList
52+
* [ ] two
53+
* [ ] three
5154

52-
* keep
55+
## numbered list to task list
56+
57+
1. toggleTaskList
58+
2. two
59+
3. three
60+
61+
---
62+
63+
- [ ] did toggleTaskList
64+
- [ ] two
65+
- [ ] three
5366

5467
## bullet list to ordered list
5568

@@ -61,6 +74,17 @@
6174
1. did toggleOrderedList
6275
2. keep
6376

77+
## task list to bullet list
78+
79+
* [ ] one
80+
* [ ] toggleBulletList
81+
* [ ] three
82+
83+
---
84+
85+
* one
86+
* did toggleBulletList
87+
* three
6488

6589
## removes the list when toggling task off
6690

@@ -78,31 +102,6 @@ toggleBulletList
78102

79103
- did toggleBulletList
80104

81-
## Splits bullet list when turning one item into task
82-
83-
* toggleTaskList
84-
* not todo
85-
86-
---
87-
88-
- [ ] did toggleTaskList
89-
90-
* not todo
91-
92-
## toggles two list items separately
93-
94-
* toggleTaskList
95-
* not todo
96-
* toggleTaskList
97-
98-
---
99-
100-
- [ ] did toggleTaskList
101-
102-
* not todo
103-
104-
- [ ] did toggleTaskList
105-
106105
## toggle off task list item should turn it into normal list item
107106

108107
* not todo
@@ -113,9 +112,3 @@ toggleBulletList
113112
* not todo
114113

115114
did toggleTaskList
116-
117-
---
118-
119-
* not todo
120-
* toggleTaskList
121-

src/commands/convertList.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { CommandProps } from '@tiptap/core'
7+
import type { Node, NodeType } from '@tiptap/pm/model'
8+
import type { Transaction } from '@tiptap/pm/state'
9+
10+
import { findParentNode, isList } from '@tiptap/core'
11+
import { TextSelection } from '@tiptap/pm/state'
12+
13+
type ParentList = {
14+
pos: number
15+
node: Node
16+
}
17+
18+
/**
19+
* Rebuild a list node with a new list type and item type, preserving all item content.
20+
*
21+
* Used for cross-silo list conversion (bulletList/orderedList ↔ taskList) where a simple
22+
* setNodeMarkup is not sufficient because the child item types also differ
23+
* (listItem vs taskItem).
24+
*
25+
* Only the direct children of the list are retyped; nested sub-lists are preserved as-is.
26+
* When converting to taskItem the item receives checked: false.
27+
* When converting away from taskItem the checked attribute is simply dropped.
28+
*
29+
* @param parentList - the parent list node
30+
* @param targetListType - the target list type
31+
* @param targetItemType - the target item type
32+
* @param tr - the ProseMirror transaction
33+
*/
34+
function convertListType(
35+
parentList: ParentList,
36+
targetListType: NodeType,
37+
targetItemType: NodeType,
38+
tr: Transaction,
39+
): void {
40+
const newItems: Node[] = []
41+
parentList.node.forEach((item) => {
42+
const attrs = targetItemType.name === 'taskItem' ? { checked: false } : {}
43+
newItems.push(targetItemType.create(attrs, item.content))
44+
})
45+
const newList = targetListType.create(parentList.node.attrs, newItems)
46+
tr.replaceWith(
47+
parentList.pos,
48+
parentList.pos + parentList.node.nodeSize,
49+
newList,
50+
)
51+
}
52+
53+
/**
54+
* Returns a Tiptap command that toggles a given list type.
55+
* To be used by BulletList, OrderedList and TaskList nodes.
56+
*
57+
* Handles cross-type conversion (e.g. bulletList → taskList) that
58+
* `toggleList` cannot handle due to incompatible item types.
59+
*
60+
* @param listTypeName - the target list type
61+
* @param itemTypeName - the target item type
62+
*/
63+
export const toggleListCommand =
64+
(listTypeName: string, itemTypeName: string) =>
65+
() =>
66+
({ editor, state, tr, dispatch, commands }: CommandProps): boolean => {
67+
const { extensions } = editor.extensionManager
68+
69+
const parentList = findParentNode((node) =>
70+
isList(node.type.name, extensions),
71+
)(state.selection)
72+
73+
const listType = state.schema.nodes[listTypeName]!
74+
const itemType = state.schema.nodes[itemTypeName]!
75+
76+
if (
77+
parentList
78+
&& parentList.node.type !== listType
79+
&& !listType.validContent(parentList.node.content)
80+
) {
81+
if (!dispatch) {
82+
return true
83+
}
84+
const { from, to } = state.selection
85+
convertListType(parentList, listType, itemType, tr)
86+
tr.setSelection(TextSelection.create(tr.doc, from, to))
87+
dispatch(tr)
88+
return true
89+
}
90+
91+
return commands.toggleList(listType, itemType)
92+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6+
import { toggleListCommand } from './convertList'
67
import listInputRule from './listInputRule.js'
78

8-
export { listInputRule }
9+
export { listInputRule, toggleListCommand }

src/commands/listInputRule.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { InputRule, wrappingInputRule } from '@tiptap/core'
1212
* @param {object} type Node Type object
1313
* @param {*} getAttributes handler to get the attributes
1414
*/
15-
export default function (find, type, getAttributes) {
15+
export default function (find, type, getAttributes = null) {
1616
const handler = ({ state, range, match }) => {
1717
const wrap = wrappingInputRule({ find, type, getAttributes })
1818
wrap.handler({ state, range, match })

src/extensions/RichText.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
Strong,
3535
Underline,
3636
} from './../marks/index.js'
37-
import BulletList from './../nodes/BulletList.js'
37+
import BulletList from './../nodes/BulletList.ts'
3838
import Callouts from './../nodes/Callouts.js'
3939
import CodeBlock from './../nodes/CodeBlock.js'
4040
import Details from './../nodes/Details.js'
@@ -44,12 +44,12 @@ import HardBreak from './../nodes/HardBreak.js'
4444
import Image from './../nodes/Image.js'
4545
import ImageInline from './../nodes/ImageInline.js'
4646
import { MathBlock, MathInline } from './../nodes/Mathematics.js'
47-
import OrderedList from './../nodes/OrderedList.js'
47+
import OrderedList from './../nodes/OrderedList.ts'
4848
import Paragraph from './../nodes/Paragraph.js'
4949
import Preview from './../nodes/Preview.js'
5050
import Table from './../nodes/Table.js'
5151
import TaskItem from './../nodes/TaskItem.js'
52-
import TaskList from './../nodes/TaskList.js'
52+
import TaskList from './../nodes/TaskList.ts'
5353
import TrailingNode from './../nodes/TrailingNode.js'
5454
import Emoji from './Emoji.js'
5555
import KeepSyntax from './KeepSyntax.js'
Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import { BulletList as TiptapBulletList } from '@tiptap/extension-list'
7-
import { listInputRule } from '../commands/index.js'
7+
import { listInputRule, toggleListCommand } from '../commands'
88

99
/* We want to allow for `* [ ]` as an input rule for bullet lists.
1010
* Therefore the list input rules need to check the input
@@ -13,7 +13,7 @@ import { listInputRule } from '../commands/index.js'
1313
*/
1414
const BulletList = TiptapBulletList.extend({
1515
parseHTML() {
16-
return this.parent().map((rule) =>
16+
return this.parent?.()?.map((rule) =>
1717
Object.assign(rule, { preserveWhitespace: true }),
1818
)
1919
},
@@ -36,6 +36,13 @@ const BulletList = TiptapBulletList.extend({
3636
addInputRules() {
3737
return [listInputRule(/^\s*([-+*])\s([^\s[]+)$/, this.type)]
3838
},
39+
40+
addCommands() {
41+
return {
42+
...this.parent?.(),
43+
toggleBulletList: toggleListCommand('bulletList', 'listItem'),
44+
}
45+
},
3946
})
4047

4148
export default BulletList
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import { OrderedList as TiptapOrderedList } from '@tiptap/extension-list'
7+
import { toggleListCommand } from '../commands'
78

89
const OrderedList = TiptapOrderedList.extend({
910
addAttributes() {
@@ -15,6 +16,13 @@ const OrderedList = TiptapOrderedList.extend({
1516
},
1617
}
1718
},
19+
20+
addCommands() {
21+
return {
22+
...this.parent?.(),
23+
toggleOrderedList: toggleListCommand('orderedList', 'listItem'),
24+
}
25+
},
1826
})
1927

2028
export default OrderedList
Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,22 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6+
import type { Node } from '@tiptap/pm/model'
7+
import type { MarkdownSerializerState } from 'prosemirror-markdown'
8+
69
import { mergeAttributes } from '@tiptap/core'
710
import { TaskList as TiptapTaskList } from '@tiptap/extension-list'
11+
import { toggleListCommand } from '../commands'
812

913
const TaskList = TiptapTaskList.extend({
10-
parseHTML: [
11-
{
12-
priority: 100,
13-
tag: 'ul.contains-task-list',
14-
},
15-
],
14+
parseHTML() {
15+
return [
16+
{
17+
priority: 100,
18+
tag: 'ul.contains-task-list',
19+
},
20+
]
21+
},
1622

1723
renderHTML({ HTMLAttributes }) {
1824
return [
@@ -39,9 +45,17 @@ const TaskList = TiptapTaskList.extend({
3945
}
4046
},
4147

42-
toMarkdown: (state, node) => {
48+
// @ts-expect-error - toMarkdown is a custom field not part of the official Tiptap API
49+
toMarkdown: (state: MarkdownSerializerState, node: Node) => {
4350
state.renderList(node, ' ', () => `${node.attrs.bullet} `)
4451
},
52+
53+
addCommands() {
54+
return {
55+
...this.parent?.(),
56+
toggleTaskList: toggleListCommand('taskList', 'taskItem'),
57+
}
58+
},
4559
})
4660

4761
export default TaskList

src/tests/extensions/Markdown.spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import { Markdown } from '../../extensions/index.js'
1212
import { createMarkdownSerializer } from '../../extensions/Markdown.js'
1313
import { Italic, Link, Strong, Underline } from '../../marks/index.js'
1414
import Image from '../../nodes/Image.js'
15-
import OrderedList from '../../nodes/OrderedList.js'
15+
import OrderedList from '../../nodes/OrderedList.ts'
1616
import Table from '../../nodes/Table.js'
1717
import TaskItem from '../../nodes/TaskItem.js'
18-
import TaskList from '../../nodes/TaskList.js'
18+
import TaskList from '../../nodes/TaskList.ts'
1919
import createCustomEditor from '../testHelpers/createCustomEditor.ts'
2020
import ImageInline from './../../nodes/ImageInline.js'
2121

0 commit comments

Comments
 (0)