Skip to content

Commit 5ba0562

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

10 files changed

Lines changed: 244 additions & 16 deletions

File tree

src/commands/convertList.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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'
43+
? { checked: false }
44+
: {}
45+
newItems.push(targetItemType.create(attrs, item.content))
46+
})
47+
const newList = targetListType.create(parentList.node.attrs, newItems)
48+
tr.replaceWith(
49+
parentList.pos,
50+
parentList.pos + parentList.node.nodeSize,
51+
newList,
52+
)
53+
}
54+
55+
export const toggleListCommand = (listTypeName: string, itemTypeName: string) => () => ({ editor, state, tr, dispatch, commands}: CommandProps): boolean => {
56+
const { extensions } = editor.extensionManager
57+
58+
const parentList = findParentNode(
59+
(node) => isList(node.type.name, extensions)
60+
)(state.selection)
61+
62+
const listType = state.schema.nodes[listTypeName]
63+
const itemType = state.schema.nodes[itemTypeName]
64+
65+
if (parentList && parentList.node.type !== listType && !listType.validContent(parentList.node.content)) {
66+
if (!dispatch) {
67+
return true
68+
}
69+
const { from, to } = state.selection
70+
convertListType(parentList, listType, itemType, tr)
71+
tr.setSelection(TextSelection.create(tr.doc, from, to))
72+
dispatch(tr)
73+
return true
74+
}
75+
76+
return commands.toggleList(listType, itemType)
77+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
*/
55

66
import listInputRule from './listInputRule.js'
7+
import { toggleListCommand } from './convertList'
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: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@
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-
{
14+
parseHTML() {
15+
return [{
1216
priority: 100,
13-
tag: 'ul.contains-task-list',
14-
},
15-
],
17+
tag: 'ul.contains-task-list',
18+
}]
19+
},
1620

1721
renderHTML({ HTMLAttributes }) {
1822
return [
@@ -39,9 +43,17 @@ const TaskList = TiptapTaskList.extend({
3943
}
4044
},
4145

42-
toMarkdown: (state, node) => {
46+
// @ts-expect-error - toMarkdown is a custom field not part of the official Tiptap API
47+
toMarkdown: (state: MarkdownSerializerState, node: Node) => {
4348
state.renderList(node, ' ', () => `${node.attrs.bullet} `)
4449
},
50+
51+
addCommands() {
52+
return {
53+
...this.parent?.(),
54+
toggleTaskList: toggleListCommand('taskList', 'taskItem'),
55+
}
56+
},
4557
})
4658

4759
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

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { createRichEditor } from '../../EditorFactory'
7+
import markdownit from '../../markdownit/index.js'
8+
9+
describe('List type conversion', () => {
10+
describe('editor.can() — menubar enabled state', () => {
11+
it('toggleTaskList() returns true when cursor is inside a bulletList', () => {
12+
const editor = createRichEditor()
13+
editor.commands.setContent(markdownit.render('- item\n'))
14+
editor.commands.focus('start')
15+
expect(editor.can().toggleTaskList()).to.equal(true)
16+
editor.destroy()
17+
})
18+
it('toggleTaskList() returns true when cursor is inside an orderedList', () => {
19+
const editor = createRichEditor()
20+
editor.commands.setContent(markdownit.render('1. item\n'))
21+
editor.commands.focus('start')
22+
expect(editor.can().toggleTaskList()).to.equal(true)
23+
editor.destroy()
24+
})
25+
it('toggleBulletList() returns true when cursor is inside a taskList', () => {
26+
const editor = createRichEditor()
27+
editor.commands.setContent(markdownit.render('- [ ] item\n'))
28+
editor.commands.focus('start')
29+
expect(editor.can().toggleBulletList()).to.equal(true)
30+
editor.destroy()
31+
})
32+
it('toggleOrderedList() returns true when cursor is inside a taskList', () => {
33+
const editor = createRichEditor()
34+
editor.commands.setContent(markdownit.render('- [ ] item\n'))
35+
editor.commands.focus('start')
36+
expect(editor.can().toggleOrderedList()).to.equal(true)
37+
editor.destroy()
38+
})
39+
})
40+
describe('conversion at top level', () => {
41+
it('converts all items when toggling bulletList → taskList', () => {
42+
const editor = createRichEditor()
43+
editor.commands.setContent(markdownit.render('- item 1\n- item 2\n'))
44+
editor.commands.focus('start')
45+
editor.commands.toggleTaskList()
46+
const list = editor.state.doc.firstChild!
47+
expect(list.type.name).to.equal('taskList')
48+
expect(list.childCount).to.equal(2)
49+
expect(list.child(0).type.name).to.equal('taskItem')
50+
expect(list.child(0).attrs.checked).to.equal(false)
51+
expect(list.child(0).firstChild!.textContent).to.equal('item 1')
52+
expect(list.child(1).firstChild!.textContent).to.equal('item 2')
53+
editor.destroy()
54+
})
55+
it('converts all items when toggling taskList → bulletList', () => {
56+
const editor = createRichEditor()
57+
editor.commands.setContent(markdownit.render('- [ ] item 1\n- [x] item 2\n'))
58+
editor.commands.focus('start')
59+
editor.commands.toggleBulletList()
60+
const list = editor.state.doc.firstChild!
61+
expect(list.type.name).to.equal('bulletList')
62+
expect(list.child(0).type.name).to.equal('listItem')
63+
expect(list.child(0).firstChild!.textContent).to.equal('item 1')
64+
expect(list.child(1).firstChild!.textContent).to.equal('item 2')
65+
editor.destroy()
66+
})
67+
it('converts all items when toggling orderedList → taskList', () => {
68+
const editor = createRichEditor()
69+
editor.commands.setContent(markdownit.render('1. item 1\n2. item 2\n'))
70+
editor.commands.focus('start')
71+
editor.commands.toggleTaskList()
72+
const list = editor.state.doc.firstChild!
73+
expect(list.type.name).to.equal('taskList')
74+
expect(list.child(0).type.name).to.equal('taskItem')
75+
editor.destroy()
76+
})
77+
})
78+
describe('conversion at nested level', () => {
79+
it('converts only the innermost list when cursor is in a nested item', () => {
80+
const editor = createRichEditor()
81+
editor.commands.setContent(markdownit.render('- outer\n - inner\n'))
82+
// Position cursor inside the nested "inner" paragraph
83+
let innerPos = -1
84+
editor.state.doc.descendants((node, pos) => {
85+
if (node.type.name === 'paragraph' && node.textContent === 'inner') {
86+
innerPos = pos + 1
87+
}
88+
})
89+
editor.commands.setTextSelection(innerPos)
90+
editor.commands.toggleTaskList()
91+
const outerList = editor.state.doc.firstChild!
92+
expect(outerList.type.name).to.equal('bulletList') // outer unchanged
93+
const outerItem = outerList.firstChild!
94+
const innerList = outerItem.lastChild!
95+
expect(innerList.type.name).to.equal('taskList') // inner converted
96+
expect(innerList.firstChild!.type.name).to.equal('taskItem')
97+
expect(innerList.firstChild!.firstChild!.textContent).to.equal('inner')
98+
editor.destroy()
99+
})
100+
it('can().toggleTaskList() returns true for a nested item inside a bulletList', () => {
101+
const editor = createRichEditor()
102+
editor.commands.setContent(markdownit.render('- outer\n - inner\n'))
103+
let innerPos = -1
104+
editor.state.doc.descendants((node, pos) => {
105+
if (node.type.name === 'paragraph' && node.textContent === 'inner') {
106+
innerPos = pos + 1
107+
}
108+
})
109+
editor.commands.setTextSelection(innerPos)
110+
expect(editor.can().toggleTaskList()).to.equal(true)
111+
editor.destroy()
112+
})
113+
})
114+
it('preserves cursor position when converting bulletList → taskList', () => {
115+
const editor = createRichEditor()
116+
editor.commands.setContent(markdownit.render('- item 1\n- item 2\n'))
117+
editor.commands.focus('start')
118+
const posBefore = editor.state.selection.from
119+
editor.commands.toggleTaskList()
120+
expect(editor.state.selection.from).to.equal(posBefore)
121+
editor.destroy()
122+
})
123+
})

src/tests/nodes/TaskItem.spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from '../testHelpers/markdown.js'
1212
import Markdown from './../../extensions/Markdown.js'
1313
import TaskItem from './../../nodes/TaskItem.js'
14-
import TaskList from './../../nodes/TaskList.js'
14+
import TaskList from './../../nodes/TaskList.ts'
1515

1616
describe('TaskItem extension', () => {
1717
it('exposes toMarkdown function', () => {

0 commit comments

Comments
 (0)