Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-toggle-list-nested-structure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tiptap/core": patch
---

Fix `toggleList` (and the `toggleBulletList`/`toggleOrderedList`/`toggleTaskList` commands built on it) only converting the outermost list node when changing list type on a selection that spans nested sublists. Previously `tr.setNodeMarkup` was called on the top-level list only, leaving nested sublists on their original type and breaking the visual nesting hierarchy (e.g. converting a bullet list to an ordered list left nested bullet sublists as bullets). Nested list nodes of the original type are now recursively converted along with the top-level list.
40 changes: 40 additions & 0 deletions packages/core/src/commands/toggleList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,43 @@ const joinListBackwards = (tr: Transaction, listType: NodeType): boolean => {
return true
}

/**
* `tr.setNodeMarkup` only changes the type of the single node at the given
* position. When a list contains nested sublists (e.g. a bulletList whose
* list items contain further bulletList nodes), only the outermost list node
* was being converted, leaving nested sublists on their original type and
* breaking the visual hierarchy. This walks the whole list subtree and
* converts every nested list node that still has the original type.
*/
function convertNestedLists(
tr: Transaction,
listPos: number,
oldListType: NodeType,
newListType: NodeType,
): void {
const listNode = tr.doc.nodeAt(listPos)

if (!listNode) {
return
}

const positions: number[] = []

listNode.descendants((node, relativePos) => {
if (node.type === oldListType) {
positions.push(listPos + 1 + relativePos)
}
return true
})

// Convert deepest nodes first so earlier position lookups in this pass
// (captured before any mutation) remain valid — setNodeMarkup does not
// change node sizes, only its type/attrs, so positions never shift.
for (const pos of positions) {
tr.setNodeMarkup(pos, newListType)
}
}

const joinListForwards = (tr: Transaction, listType: NodeType): boolean => {
const list = findParentNode(node => node.type === listType)(tr.selection)

Expand Down Expand Up @@ -205,9 +242,12 @@ export const toggleList: RawCommands['toggleList'] =
isList(currentList.node.type.name, extensions) &&
listType.validContent(currentList.node.content)
) {
const oldListType = currentList.node.type

return chain()
.command(() => {
tr.setNodeMarkup(currentList.pos, listType)
convertNestedLists(tr, currentList.pos, oldListType, listType)

return true
})
Expand Down
107 changes: 107 additions & 0 deletions packages/extension-list/__tests__/toggleListNestedStructure.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vitest'

import { BulletList, ListItem, OrderedList } from '../src/index.js'

describe('toggleList preserves nested list structure', () => {
let editor: Editor

afterEach(() => {
editor?.destroy()
})

it('converts nested bulletList to orderedList when toggling the whole selection', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, BulletList, OrderedList],
content: `
<ul>
<li><p>Item 1</p></li>
<li>
<p>Item 2</p>
<ul>
<li><p>Item 2-1</p></li>
<li><p>Item 2-2</p></li>
</ul>
</li>
<li><p>Item 3</p></li>
</ul>
`,
})

editor.commands.selectAll()
editor.commands.toggleOrderedList()

const json = editor.getJSON()

expect(json.content).toHaveLength(1)
expect(json.content![0].type).toBe('orderedList')

const outerItems = json.content![0].content!
expect(outerItems).toHaveLength(3)

// The nested list inside "Item 2" must also have been converted —
// previously it kept its original bulletList type.
const nestedListNode = outerItems[1].content!.find(node => node.type === 'bulletList')
expect(nestedListNode).toBeUndefined()

const nestedOrderedList = outerItems[1].content!.find(node => node.type === 'orderedList')
expect(nestedOrderedList).toBeDefined()
expect(nestedOrderedList!.content).toHaveLength(2)
})

it('converts a deeply nested (3-level) list hierarchy entirely', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, BulletList, OrderedList],
content: `
<ul>
<li>
<p>A</p>
<ul>
<li>
<p>A-1</p>
<ul>
<li><p>A-1-a</p></li>
</ul>
</li>
</ul>
</li>
</ul>
`,
})

editor.commands.selectAll()
editor.commands.toggleOrderedList()

const findAllListTypes = (node: any, acc: string[] = []): string[] => {
if (node.type === 'bulletList' || node.type === 'orderedList') {
acc.push(node.type)
}
;(node.content || []).forEach((child: any) => findAllListTypes(child, acc))
return acc
}

const json = editor.getJSON()
const listTypes = findAllListTypes({ type: 'doc', content: json.content })

expect(listTypes).toEqual(['orderedList', 'orderedList', 'orderedList'])
})

it('leaves a single-level list conversion working as before', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, BulletList, OrderedList],
content: '<ul><li><p>One</p></li><li><p>Two</p></li></ul>',
})

editor.commands.selectAll()
editor.commands.toggleOrderedList()

const json = editor.getJSON()

expect(json.content).toHaveLength(1)
expect(json.content![0].type).toBe('orderedList')
expect(json.content![0].content).toHaveLength(2)
})
})
Loading