|
| 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 | + } |
0 commit comments