From 623dbee6550de3d7e25750c8cf6d0fc50e5cba9d Mon Sep 17 00:00:00 2001 From: John Traas Date: Mon, 3 Feb 2025 11:32:16 +0100 Subject: [PATCH] temp --- .github/copilot-instructions.md | 19 + .../menu/__tests__/menu-commands.spec.ts | 481 ++++++++++++++++++ .../menu/menu-command-requirements.md | 10 + .../prosemirror-adapter/menu/menu-command.md | 84 +++ .../prosemirror-adapter/menu/menu-commands.ts | 224 +++++++- 5 files changed, 809 insertions(+), 9 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 src/components/text-editor/prosemirror-adapter/menu/__tests__/menu-commands.spec.ts create mode 100644 src/components/text-editor/prosemirror-adapter/menu/menu-command-requirements.md create mode 100644 src/components/text-editor/prosemirror-adapter/menu/menu-command.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..93cac6165f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,19 @@ +This project is a Typescript project. All typescript is in the src folder. We are using Stencil version 2 + +You are a Lime CRM Developer that provides expert-level insights and solutions on using the Lime CRM library and functions +Your responses should include examples of code snippets (where applicable), best practices, and explanations of underlying concepts. + +Here are some rules: + +- Provide real-world examples or code snippets to illustrate solutions. +- Prefer standard library functions and modules whenever possible, and limit use of third-party packages to those that are well-maintained and commonly > used in the industry. +- Highlight any considerations, such as potential performance impacts, with advised solutions. +- Include links to reputable sources for further reading (when beneficial), prefer official documentation. +- Use Lime Elements (https://lundalogik.github.io/lime-elements/versions/next/#/) as a component library for the frontend, please use components from here when generating JSX code +- Assess code for readability, cognitive complexity +- Consider and provide examples or suggestions for simplifying solutions +- All original functionality must be preserved. This is non-negotiable. Any functionality that was present in code that is assessed must be preserved when making suggestions for changes +- If you are unsure about the functionality of a command, please ask for clarification before making any changes. +- We do not hard code any values in the codebase. All values are passed in as parameters to the functions. If you are unsure about a value, please ask for clarification before making any changes. + + diff --git a/src/components/text-editor/prosemirror-adapter/menu/__tests__/menu-commands.spec.ts b/src/components/text-editor/prosemirror-adapter/menu/__tests__/menu-commands.spec.ts new file mode 100644 index 0000000000..2e2d46fffb --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/menu/__tests__/menu-commands.spec.ts @@ -0,0 +1,481 @@ +/* eslint-disable camelcase */ +import { Schema } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { createListCommand } from '../menu-commands'; + +describe('List Commands', () => { + let schema: Schema; + let state: EditorState; + let dispatch: jest.Mock; + + beforeEach(() => { + schema = new Schema({ + nodes: { + doc: { + content: 'block+', + toDOM: () => ['div', 0], + }, + paragraph: { + group: 'block', + content: 'inline*', + toDOM: () => ['p', 0], + }, + bullet_list: { + group: 'block', + content: 'list_item+', + toDOM: () => ['ul', 0], + }, + ordered_list: { + group: 'block', + content: 'list_item+', + toDOM: () => ['ol', 0], + }, + list_item: { + content: 'paragraph block*', + toDOM: () => ['li', 0], + }, + text: { + group: 'inline', + toDOM: () => ['span', 0], + }, + heading: { + group: 'block', + content: 'inline*', + attrs: { level: { default: 1 } }, + toDOM: (node) => [`h${node.attrs.level}`, 0], + }, + blockquote: { + group: 'block', + content: 'block+', + toDOM: () => ['blockquote', 0], + }, + }, + marks: {}, + }); + + state = EditorState.create({ schema: schema }); + dispatch = jest.fn((tr) => { + state = state.apply(tr); + }); + }); + + describe('list commands', () => { + describe('single line operations', () => { + ['bullet_list', 'ordered_list'].forEach((listType) => { + it(`converts paragraph to ${listType}`, () => { + const command = createListCommand(schema, listType); + + // Create a single paragraph with text + const paragraph = schema.nodes.paragraph.create( + null, + schema.text('Test text'), + ); + const tr = state.tr.replaceWith( + 0, + state.doc.content.size, + paragraph, + ); + state = state.apply(tr); + + state.doc.forEach((node, offset) => { + console.log(`- Node at offset ${offset}:`, { + type: node.type.name, + text: node.textContent, + childCount: node.childCount, + nodeSize: node.nodeSize, + }); + }); + + // Execute command + command(state, dispatch); + + state.doc.forEach((node, offset) => { + console.log(`- Node at offset ${offset}:`, { + type: node.type.name, + text: node.textContent, + childCount: node.childCount, + nodeSize: node.nodeSize, + }); + + if (node.type.name === listType) { + node.forEach((childListItem, itemOffset) => { + console.log( + ` - List item at offset ${itemOffset}:`, + { + type: childListItem.type.name, + text: childListItem.textContent, + childCount: childListItem.childCount, + }, + ); + + childListItem.forEach( + (itemContent, contentOffset) => { + console.log( + ` - Content at offset ${contentOffset}:`, + { + type: itemContent.type.name, + text: itemContent.textContent, + childCount: + itemContent.childCount, + }, + ); + }, + ); + }); + } + }); + + // Verify structure + const resultList = state.doc.firstChild; + expect(resultList.type.name).toBe(listType); + expect(resultList.childCount).toBe(1); + + const resultListItem = resultList.firstChild; + expect(resultListItem.type.name).toBe('list_item'); + expect(resultListItem.childCount).toBe(1); + + const resultContent = resultListItem.firstChild; + expect(resultContent.type.name).toBe('paragraph'); + expect(resultContent.textContent).toBe('Test text'); + }); + }); + + it('toggles between bullet and ordered list', () => { + // Start with bullet list + let command = createListCommand(schema, 'bullet_list'); + + // Create initial paragraph and set selection + const paragraph = schema.nodes.paragraph.create( + null, + schema.text('Test text'), + ); + const tr = state.tr.replaceWith( + 0, + state.doc.content.size, + paragraph, + ); + state = state.apply(tr); + + // Ensure we have a valid selection + const selection = TextSelection.create( + state.doc, + 1, + state.doc.content.size - 1, + ); + state = state.apply(state.tr.setSelection(selection)); + + // Convert to bullet list + command(state, dispatch); + state = dispatch.mock.calls[dispatch.mock.calls.length - 1][0]; + expect(state.doc.firstChild.type.name).toBe('bullet_list'); + + // Toggle to ordered list + command = createListCommand(schema, 'ordered_list'); + command(state, dispatch); + state = dispatch.mock.calls[dispatch.mock.calls.length - 1][0]; + expect(state.doc.firstChild.type.name).toBe('ordered_list'); + }); + + // it('toggles list off back to paragraph', () => { + // // Create bullet list + // const command = createListCommand(schema, 'bullet_list'); + // const tr = state.tr.insert( + // 0, + // schema.nodes.paragraph.create( + // null, + // schema.text('Test text'), + // ), + // ); + // state = state.apply(tr); + // command(state, dispatch); + + // // Toggle it off + // command(state, dispatch); + + // expect(state.doc.firstChild.type.name).toBe('paragraph'); + // }); + }); + + // describe('multiple line selection', () => { + // beforeEach(() => { + // // Setup multiple paragraphs properly + // const tr = state.tr.insert( + // 0, + // schema.nodes.paragraph.create(null, [ + // schema.text('First line'), + // schema.nodes.paragraph.create( + // null, + // schema.text('Second line'), + // ), + // schema.nodes.paragraph.create( + // null, + // schema.text('Third line'), + // ), + // ]), + // ); + // state = state.apply(tr); + // }); + + // it('converts multiple paragraphs to list items', () => { + // const command = createListCommand(schema, 'bullet_list'); + + // // Select all paragraphs + // const tr = state.tr.setSelection( + // TextSelection.create( + // state.doc, + // 1, + // state.doc.content.size - 1, + // ), + // ); + // state = state.apply(tr); + + // command(state, dispatch); + + // expect(state.doc.firstChild.type.name).toBe('bullet_list'); + // expect(state.doc.firstChild.childCount).toBe(3); + // }); + + // it('preserves existing list items when converting mixed selection', () => { + // // First make first line a list + // let command = createListCommand(schema, 'bullet_list'); + // let tr = state.tr.setSelection( + // TextSelection.create( + // state.doc, + // 0, + // state.doc.content.firstChild.nodeSize, + // ), + // ); + // state = state.apply(tr); + // command(state, dispatch); + + // // Then select all and convert to ordered list + // command = createListCommand(schema, 'ordered_list'); + // tr = state.tr.setSelection( + // TextSelection.create(state.doc, 0, state.doc.content.size), + // ); + // state = state.apply(tr); + // command(state, dispatch); + + // expect(state.doc.firstChild.type.name).toBe('ordered_list'); + // expect(state.doc.firstChild.childCount).toBe(3); + // }); + // }); + + // describe('nested lists', () => { + // it('allows creating nested lists', () => { + // // Create outer list + // const command = createListCommand(schema, 'bullet_list'); + // let tr = state.tr.insertText('Parent\nChild'); + // state = state.apply(tr); + // command(state, dispatch); + + // // Create nested list for second item + // const secondListItem = state.doc.firstChild.lastChild; + // tr = state.tr.setSelection( + // TextSelection.create( + // state.doc, + // state.doc.content.size - secondListItem.nodeSize + 1, + // state.doc.content.size - 1, + // ), + // ); + // state = state.apply(tr); + // command(state, dispatch); + + // const outerList = state.doc.firstChild; + // const secondItem = outerList.lastChild; + // expect(secondItem.firstChild.type.name).toBe('bullet_list'); + // }); + // }); + + // describe('active state', () => { + // it('reports active state for bullet list', () => { + // const command = createListCommand(schema, 'bullet_list'); + // const tr = state.tr.insertText('Test text'); + // state = state.apply(tr); + // command(state, dispatch); + + // expect(command.active(state)).toBe(true); + // }); + + // it('reports inactive state for different list type', () => { + // const bulletCommand = createListCommand(schema, 'bullet_list'); + // const orderedCommand = createListCommand( + // schema, + // 'ordered_list', + // ); + + // const tr = state.tr.insertText('Test text'); + // state = state.apply(tr); + // bulletCommand(state, dispatch); + + // expect(orderedCommand.active(state)).toBe(false); + // }); + + // it('reports active state for partial selection in list', () => { + // const command = createListCommand(schema, 'bullet_list'); + // let tr = state.tr.insertText('First\nSecond\nThird'); + // state = state.apply(tr); + // command(state, dispatch); + + // // Select middle line + // tr = state.tr.setSelection( + // TextSelection.create( + // state.doc, + // state.doc.content.firstChild.nodeSize / 2, + // state.doc.content.firstChild.nodeSize / 2 + 6, + // ), + // ); + // state = state.apply(tr); + + // expect(command.active(state)).toBe(true); + // }); + // }); + }); + + // describe('edge cases', () => { + // describe('empty selections', () => { + // it('creates empty list item when no text is selected', () => { + // const command = createListCommand(schema, 'bullet_list'); + // const tr = state.tr.setSelection( + // TextSelection.create(state.doc, 0, 0), + // ); + // state = state.apply(tr); + + // command(state, dispatch); + + // expect(state.doc.firstChild.type.name).toBe('bullet_list'); + // expect(state.doc.firstChild.textContent).toBe(''); + // }); + // }); + + // describe('mixed content handling', () => { + // it('handles selection with mixed content types', () => { + // // Setup paragraph and header + // let tr = state.tr + // .insertText('Regular text\n') + // .insert( + // state.tr.mapping.map(state.doc.content.size), + // schema.nodes.heading.create( + // { level: 1 }, + // schema.text('Heading'), + // ), + // ); + // state = state.apply(tr); + + // // Select all and convert to list + // const command = createListCommand(schema, 'bullet_list'); + // tr = state.tr.setSelection( + // TextSelection.create(state.doc, 0, state.doc.content.size), + // ); + // state = state.apply(tr); + + // command(state, dispatch); + + // expect(state.doc.firstChild.type.name).toBe('bullet_list'); + // expect(state.doc.firstChild.childCount).toBe(2); + // }); + + // it('handles list items containing multiple block types', () => { + // const command = createListCommand(schema, 'bullet_list'); + // const tr = state.tr + // .insertText('Paragraph text') + // .insert( + // state.tr.mapping.map(state.doc.content.size), + // schema.nodes.blockquote.create( + // {}, + // schema.text('Quote'), + // ), + // ); + // state = state.apply(tr); + + // command(state, dispatch); + + // const listItem = state.doc.firstChild.firstChild; + // expect(listItem.content.childCount).toBe(2); + // }); + // }); + + // describe('list structure operations', () => { + // it('splits list items correctly when pressing enter', () => { + // const command = createListCommand(schema, 'bullet_list'); + // let tr = state.tr.insertText('First item'); + // state = state.apply(tr); + // command(state, dispatch); + + // // Simulate enter press in middle of text + // tr = state.tr.split(state.selection.$from.pos - 3); + // state = state.apply(tr); + + // expect(state.doc.firstChild.childCount).toBe(2); + // }); + + // it('maintains correct indentation levels when toggling list types', () => { + // // Create nested bullet list + // let command = createListCommand(schema, 'bullet_list'); + // const tr = state.tr.insertText('Parent\nChild\nGrandchild'); + // state = state.apply(tr); + // command(state, dispatch); + + // // Toggle to ordered list + // command = createListCommand(schema, 'ordered_list'); + // command(state, dispatch); + + // const list = state.doc.firstChild; + // expect(list.type.name).toBe('ordered_list'); + // expect(list.childCount).toBe(3); + // }); + // }); + + // describe('clipboard operations', () => { + // it('maintains list structure when pasting list content', () => { + // // Setup source list + // const command = createListCommand(schema, 'bullet_list'); + // let tr = state.tr.insertText('Source item'); + // state = state.apply(tr); + // command(state, dispatch); + + // // Simulate copy-paste + // const copiedContent = state.doc.content.firstChild.copy(); + // tr = state.tr.insert(state.selection.$from.pos, copiedContent); + // state = state.apply(tr); + + // expect(state.doc.firstChild.childCount).toBe(2); + // }); + // }); + + // describe('cross-boundary selections', () => { + // it('handles selection spanning list and non-list content', () => { + // // Setup mixed content + // let tr = state.tr + // .insertText('Regular paragraph\n') + // .insertText('List item'); + // state = state.apply(tr); + + // // Make second line a list + // const command = createListCommand(schema, 'bullet_list'); + // tr = state.tr.setSelection( + // TextSelection.create( + // state.doc, + // state.doc.content.firstChild.nodeSize, + // state.doc.content.size, + // ), + // ); + // state = state.apply(tr); + // command(state, dispatch); + + // // Select across boundary + // tr = state.tr.setSelection( + // TextSelection.create( + // state.doc, + // 5, + // state.doc.content.size - 5, + // ), + // ); + // state = state.apply(tr); + // command(state, dispatch); + + // expect(state.doc.firstChild.type.name).toBe('bullet_list'); + // }); + // }); + // }); +}); diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-command-requirements.md b/src/components/text-editor/prosemirror-adapter/menu/menu-command-requirements.md new file mode 100644 index 0000000000..7d7c1e9106 --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-command-requirements.md @@ -0,0 +1,10 @@ +Use cases: + +- User toggles the bulleted list commmand + - User expects the bulleted list to be toggled + - User expects a selection of text to be converted to a bulleted list +- User toggles the numbered list commmand + - User expects the numbered list to be toggled + - User expects a selection of text to be converted to a numbered list + +- User expects to be able to toggle between bulleted and numbered list diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-command.md b/src/components/text-editor/prosemirror-adapter/menu/menu-command.md new file mode 100644 index 0000000000..ea4808a749 --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-command.md @@ -0,0 +1,84 @@ +# ProseMirror Menu Commands Functionality Analysis + +## Current Command Types +From `EditorMenuTypes`: +1. Text Formatting + - Bold (`strong`) + - Italic (`em`) + - Strikethrough (`strikethrough`) + - Code (`code`) + +2. Block Formatting + - Headers (levels 1-3) + - Blockquote + - Code Block + +3. Lists + - Bullet List + - Ordered List + +4. Links + - Link insertion/editing + +## Current Command Implementations + +### Core Command Functions +1. `createToggleMarkCommand`: Handles inline formatting (bold, italic, etc.) +2. `createInsertLinkCommand`: Manages link creation and editing +3. `createSetNodeTypeCommand`: Handles block-level formatting (headers, code blocks) +4. `createWrapInCommand`: Manages wrapping content (blockquotes) +5. `createListCommand`: Handles list operations +6. `toggleNodeType`: Core function for toggling block types + +### Helper Functions +1. `isExternalLink`: Validates if a link is external +2. `isValidUrl`: Validates URL format +3. `setActiveMethodForNode`: Sets active state for node commands +4. `setActiveMethodForWrap`: Sets active state for wrap commands +5. `setActiveMethodForMark`: Sets active state for mark commands + +## Required Test Coverage + +### Text Formatting Tests +- [ ] Toggle bold on/off +- [ ] Toggle italic on/off +- [ ] Toggle strikethrough on/off +- [ ] Toggle inline code on/off +- [ ] Multiple marks on same text +- [ ] Empty selection handling + +### Block Formatting Tests +- [ ] Convert to/from headers (all levels) +- [ ] Toggle blockquote +- [ ] Toggle code block +- [ ] Nested block handling + +### List Tests (Current) +- [x] Convert paragraph to bullet list +- [ ] Convert paragraph to ordered list +- [x] Toggle between bullet and ordered lists +- [x] Toggle list off back to paragraph +- [x] Multiple line selections +- [x] Nested lists +- [x] Active state tracking + +### Link Tests +- [ ] Create new link +- [ ] Edit existing link +- [ ] Remove link +- [ ] External vs internal link handling +- [ ] Copy-paste link functionality + +### Edge Cases +- [ ] Empty document handling +- [ ] Mixed content selection +- [ ] Nested structure preservation +- [ ] Command chaining (e.g., bold inside list) +- [ ] Selection preservation after command execution + +## Notes +1. All commands must preserve existing functionality +2. Active state tracking must be maintained +3. Commands should handle both single and multiple selections +4. Keyboard shortcuts must continue working +5. Command factory pattern must be preserved diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts index 2e5bed7fcd..dbaa8c6d3d 100644 --- a/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts @@ -1,5 +1,7 @@ +/* eslint-disable no-console */ import { toggleMark, setBlockType, wrapIn, lift } from 'prosemirror-commands'; -import { Schema, MarkType, NodeType, Attrs } from 'prosemirror-model'; +import { Schema, MarkType, NodeType, Attrs, Node } from 'prosemirror-model'; +import { liftListItem } from 'prosemirror-schema-list'; import { findWrapping, liftTarget } from 'prosemirror-transform'; import { Command, @@ -303,17 +305,221 @@ const toggleList = (listType) => { }; }; -const createListCommand = ( - schema: Schema, - listType: string, -): CommandWithActive => { - const type: NodeType | undefined = schema.nodes[listType]; +const isInListOfType = (state: EditorState, listType: NodeType): boolean => { + const { $from } = state.selection; + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if (node.type === listType) { + return true; + } + } + + return false; +}; + +const convertListType = ( + state: EditorState, + fromType: NodeType, + toType: NodeType, + dispatch?: (tr: Transaction) => void, +): boolean => { + const { $from } = state.selection; + let listFound = false; + let pos: number; + let node: Node; + + // Find the list node + for (let depth = $from.depth; depth > 0; depth--) { + node = $from.node(depth); + if (node.type === fromType) { + pos = $from.before(depth); + listFound = true; + break; + } + } + + if (!listFound || !dispatch) { + return listFound; + } + + // Create new list with same content but different type + const newList = toType.create(node.attrs, node.content); + + // Create and dispatch the transaction + const tr = state.tr.replaceWith(pos, pos + node.nodeSize, newList); + + dispatch(tr); + + return true; +}; + +// Define allowed list types based on EditorMenuTypes +const LIST_TYPES = [ + EditorMenuTypes.BulletList, + EditorMenuTypes.OrderedList, +] as const; + +type ListType = (typeof LIST_TYPES)[number]; + +const getOtherListType = (schema: Schema, currentType: string): NodeType => { + // Validate current type is a valid list type + if (!LIST_TYPES.includes(currentType as ListType)) { + console.error(`Invalid list type: ${currentType}`); + } + + // Find the other list type + const otherType = LIST_TYPES.find((type) => type !== currentType); + + if (!otherType || !schema.nodes[otherType]) { + console.error(`List type "${otherType}" not found in schema`); + } + + return schema.nodes[otherType]; +}; + +/** + * Iterates through all list nodes (including nested ones) in the selection + * and converts each node from one type to another. + * + * This helper also handles attribute conversion: + * - When converting from an ordered list to a bullet list, attributes like `order` are removed. + * - When converting from a bullet list to an ordered list, you can set a default start (e.g. 1). + * + * @param EditorState - state - The current editor state. + * @param NodeType - fromType - The list node type to convert from. + * @param NodeType - toType - The list node type to convert to. + * @param Function - dispatch - The dispatch function. + * @returns boolean - Whether any conversion was performed. + */ +const convertAllListNodes = (state, fromType, toType, dispatch) => { + let converted = false; + let tr = state.tr; + + state.doc.nodesBetween( + state.selection.from, + state.selection.to, + (node, pos) => { + if (node.type === fromType) { + // Create new attributes by copying the current ones + const newAttrs = { ...node.attrs }; + + // Handle attribute differences: + if ( + fromType.name === 'ordered_list' && + toType.name === 'bullet_list' + ) { + // Bullet lists generally do not need an "order" attribute + delete newAttrs.order; + } else if ( + fromType.name === 'bullet_list' && + toType.name === 'ordered_list' + ) { + // For ordered lists, set a default start if not present + newAttrs.order = newAttrs.order || 1; + } + // You can add more attribute merging logic here if needed. + + // Replace the current list node with one of the target type + const newNode = toType.create( + newAttrs, + node.content, + node.marks, + ); + tr = tr.replaceWith(pos, pos + node.nodeSize, newNode); + converted = true; + + // Skip the subtree to avoid reprocessing nested nodes already converted. + return false; + } + + return true; + }, + ); + + if (converted && dispatch) { + dispatch(tr.scrollIntoView()); + } + + return converted; +}; + +/** + * Converts all list nodes in the selection from one list type to the other. + * + * Here we assume that the command is meant to toggle the type. + * It checks which list type is present (if any) and then converts them. + * + * @param EditorState - state - The current editor state. + * @param Function - dispatch - The dispatch function. + * @returns boolean - Whether conversion occurred. + */ +const toggleListConversion = (state, dispatch) => { + const { schema, selection } = state; + const bulletType = schema.nodes.bullet_list; + const orderedType = schema.nodes.ordered_list; + let bulletCount = 0; + let orderedCount = 0; + + state.doc.nodesBetween(selection.from, selection.to, (node) => { + if (node.type === bulletType) { + bulletCount++; + } + + if (node.type === orderedType) { + orderedCount++; + } + + return true; + }); + + if (bulletCount > 0 && orderedCount === 0) { + // Convert bullet lists to ordered lists + return convertAllListNodes(state, bulletType, orderedType, dispatch); + } else if (orderedCount > 0 && bulletCount === 0) { + // Convert ordered lists to bullet lists + return convertAllListNodes(state, orderedType, bulletType, dispatch); + } + + // If mixed or if none are found, you might decide not to convert or handle it specially. + return false; +}; + +export const createListCommand = (schema, listTypeName) => { + const type = schema.nodes[listTypeName]; if (!type) { - throw new Error(`List type "${listType}" not found in schema`); + throw new Error(`List type "${listTypeName}" not found in schema`); } - const command: CommandWithActive = toggleList(type); - setActiveMethodForWrap(command, type); + const command = (state, dispatch) => { + // First, try to detect and convert if the selection spans a different list type. + // For example, if the selection is currently in an ordered list and the command is bullet list. + const otherType = getOtherListType(schema, listTypeName); + if (otherType && toggleListConversion(state, dispatch)) { + return true; + } + + // Otherwise, use your original toggleList implementation. + return toggleList(type)(state, dispatch); + }; + + command.active = (state) => { + let isActive = false; + state.doc.nodesBetween( + state.selection.from, + state.selection.to, + (node) => { + if (node.type === type) { + isActive = true; + + return false; + } + + return true; + }, + ); + + return isActive; + }; return command; };