From 568d6c3a7e1f0a0e0d3f699b2ab615d411080a43 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 29 Aug 2026 23:39:43 +0300 Subject: [PATCH 1/9] feat: convert list to text --- .../EditorPanelPlugin/EditorPanelPlugin.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx index 19eae8282..f6400d7d8 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { $createParagraphNode, $createTextNode, + $findMatchingParent, $getRoot, $isBlockElementNode, $isParagraphNode, @@ -13,9 +14,11 @@ import { import { $createCodeNode } from '@lexical/code-core'; import { TOGGLE_LINK_COMMAND } from '@lexical/link'; import { + $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, + REMOVE_LIST_COMMAND, } from '@lexical/list'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'; @@ -88,6 +91,12 @@ export const EditorPanelPlugin = () => { }); }, list({ type }) { + const listTypeMap = { + checkbox: 'check', + ordered: 'number', + unordered: 'bullet', + }; + editor.update(() => { const target = $getCursorNode(); if (!target) return; @@ -99,6 +108,21 @@ export const EditorPanelPlugin = () => { } }); + let isAlreadyRequestedType = false; + editor.read(() => { + const target = $getCursorNode(); + if (!target) return; + + const listParent = $findMatchingParent(target, $isListNode); + isAlreadyRequestedType = + listParent?.getListType() === listTypeMap[type]; + }); + + if (isAlreadyRequestedType) { + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + return; + } + switch (type) { case 'checkbox': editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined); From 39b4cc99fb9d7a33d11f5b528ae9fa202e78f869 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 31 Aug 2026 16:45:38 +0300 Subject: [PATCH 2/9] chore: add test --- .../__tests__/spec/list.dom.test.ts | 19 +++++++++++++++++++ .../EditorPanelPlugin/EditorPanelPlugin.tsx | 19 +++++++++---------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts index 5ef632627..e63cbd671 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts @@ -37,6 +37,25 @@ test('Converts an unordered list to an ordered list', async () => { expect(listItems[1]).toHaveTextContent('Second item'); }); +test('Converts a list to plain paragraphs', async () => { + const richEditor = await renderRichEditor({ + value: `- First item +- Second item +- Third item`, + }); + const editor = screen.getByRole('textbox'); + expect(within(editor).getAllByRole('list')).toHaveLength(1); + expect(within(editor).getAllByRole('listitem')).toHaveLength(3); + + // Select all list and convert to plain text + selectContent(editor, 'First item', 'Second item'); + await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); + + expect(within(editor).getAllByRole('paragraph')).toHaveLength(3); + expect(within(editor).queryAllByRole('list')).toHaveLength(0); + expect(within(editor).queryAllByRole('listitem')).toHaveLength(0); +}); + test('Pressing Enter adds a new item to the list', async () => { const user = userEvent.setup(); await renderRichEditor({ value: '- First item' }); diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx index f6400d7d8..a3363a0b2 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -97,10 +97,19 @@ export const EditorPanelPlugin = () => { unordered: 'bullet', }; + // Check if the list is already of the requested type + // If so, convert the list to a plain paragraph + let isAlreadyRequestedType = false; editor.update(() => { const target = $getCursorNode(); if (!target) return; + const listParent = $findMatchingParent(target, $isListNode); + isAlreadyRequestedType = + listParent?.getListType() === listTypeMap[type]; + + if (isAlreadyRequestedType) return; + if ($isRootNode(target)) { const paragraph = $createParagraphNode(); target.append(paragraph); @@ -108,16 +117,6 @@ export const EditorPanelPlugin = () => { } }); - let isAlreadyRequestedType = false; - editor.read(() => { - const target = $getCursorNode(); - if (!target) return; - - const listParent = $findMatchingParent(target, $isListNode); - isAlreadyRequestedType = - listParent?.getListType() === listTypeMap[type]; - }); - if (isAlreadyRequestedType) { editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); return; From 8fe4a8bca9966647c795d9880131bcf9c5e7df7e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 2 Sep 2026 23:40:53 +0300 Subject: [PATCH 3/9] test: add --- .../__tests__/spec/list.dom.test.ts | 80 ++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts index e63cbd671..9b18d325a 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts @@ -37,18 +37,19 @@ test('Converts an unordered list to an ordered list', async () => { expect(listItems[1]).toHaveTextContent('Second item'); }); -test('Converts a list to plain paragraphs', async () => { +test('Converts a list to paragraphs', async () => { const richEditor = await renderRichEditor({ value: `- First item - Second item - Third item`, }); + const editor = screen.getByRole('textbox'); expect(within(editor).getAllByRole('list')).toHaveLength(1); expect(within(editor).getAllByRole('listitem')).toHaveLength(3); - // Select all list and convert to plain text - selectContent(editor, 'First item', 'Second item'); + // Select the list and convert it to plain text + selectContent(editor, 'First item', 'Third item'); await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); expect(within(editor).getAllByRole('paragraph')).toHaveLength(3); @@ -56,6 +57,79 @@ test('Converts a list to plain paragraphs', async () => { expect(within(editor).queryAllByRole('listitem')).toHaveLength(0); }); +test('Converts paragraphs to a list', async () => { + const richEditor = await renderRichEditor({ + value: `First item \n\n Second item \n\n Third item`, + }); + + const editor = screen.getByRole('textbox'); + expect(within(editor).getAllByRole('paragraph')).toHaveLength(3); + expect(within(editor).queryAllByRole('list')).toHaveLength(0); + + selectContent(editor, 'First item', 'Third item'); + await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); + + expect(within(editor).queryAllByRole('list')).toHaveLength(1); + const items = within(editor).queryAllByRole('listitem'); + expect(items).toHaveLength(3); + + // List has the correct item order + expect(items[0]).toHaveTextContent('First item'); + expect(items[1]).toHaveTextContent('Second item'); + expect(items[2]).toHaveTextContent('Third item'); +}); + +test('Converts paragraphs to a list and back to paragraphs', async () => { + const richEditor = await renderRichEditor({ + value: `1 \n 2 \n\n 3`, + }); + + const editor = screen.getByRole('textbox'); + expect(within(editor).getAllByRole('paragraph')).toHaveLength(2); + expect(within(editor).queryAllByRole('list')).toHaveLength(0); + + // Convert text to a list + selectContent(editor, '1 2', '3'); + await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); + + expect(within(editor).getAllByRole('list')).toHaveLength(1); + const items = within(editor).getAllByRole('listitem'); + expect(items).toHaveLength(2); + expect(items[0]).toHaveTextContent('1 2'); + expect(items[1]).toHaveTextContent('3'); + + // Convert the list back to plain text + selectContent(editor, '1 2', '3'); + await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); + + expect(within(editor).queryAllByRole('list')).toHaveLength(0); + const paragraphs = within(editor).queryAllByRole('paragraph'); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]).toHaveTextContent('1 2'); + expect(paragraphs[1]).toHaveTextContent('3'); +}); + +test('Converts selected paragraphs to a list', async () => { + const richEditor = await renderRichEditor({ + value: `First separate text \n\n Second item \n\n Third item`, + }); + + const editor = screen.getByRole('textbox'); + expect(within(editor).getAllByRole('paragraph')).toHaveLength(3); + + selectContent(editor, 'Second item', 'Third item'); + await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); + + const list = within(editor).getAllByRole('list'); + expect(list).toHaveLength(1); + expect(within(list[0]).getAllByRole('listitem')).toHaveLength(2); + + // The first paragraph remains a plain paragraph + const paragraphs = within(editor).queryAllByRole('paragraph'); + expect(paragraphs[0]).toHaveTextContent('First separate text'); + expect(list[0]).not.toContainElement(paragraphs[0]); +}); + test('Pressing Enter adds a new item to the list', async () => { const user = userEvent.setup(); await renderRichEditor({ value: '- First item' }); From e3a4663e7ad098fef69a2ff3cceea0cc3ce79ba0 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 6 Sep 2026 01:36:57 +0300 Subject: [PATCH 4/9] fix: first unified list --- .../__tests__/spec/list.dom.test.ts | 21 ++++++++++++++ .../EditorPanelPlugin/EditorPanelPlugin.tsx | 28 +++++++++++-------- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts index 9b18d325a..45eff8993 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts @@ -57,6 +57,27 @@ test('Converts a list to paragraphs', async () => { expect(within(editor).queryAllByRole('listitem')).toHaveLength(0); }); +test('Convert mixed list', async () => { + const richEditor = await renderRichEditor({ + value: `- First item + - [ ] Second item +- Third item`, + }); + + const editor = screen.getByRole('textbox'); + expect(within(editor).getAllByRole('list')).toHaveLength(2); + expect(within(editor).getAllByRole('listitem')).toHaveLength(2); + expect(within(editor).getAllByRole('checkbox')).toHaveLength(1); + + // Select the list and convert it to unordered list + selectContent(editor, 'First item', 'Third item'); + await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); + + // First conversation for mixed list - must unified list to one type + expect(within(editor).getAllByRole('listitem')).toHaveLength(3); + expect(within(editor).queryByRole('checkbox')).not.toBeInTheDocument(); +}); + test('Converts paragraphs to a list', async () => { const richEditor = await renderRichEditor({ value: `First item \n\n Second item \n\n Third item`, diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx index a3363a0b2..5ade22ab9 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -4,8 +4,10 @@ import { $createTextNode, $findMatchingParent, $getRoot, + $getSelection, $isBlockElementNode, $isParagraphNode, + $isRangeSelection, $isRootNode, $isTextNode, CONTROLLED_TEXT_INSERTION_COMMAND, @@ -18,6 +20,7 @@ import { INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, + ListNode, REMOVE_LIST_COMMAND, } from '@lexical/list'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; @@ -101,20 +104,23 @@ export const EditorPanelPlugin = () => { // If so, convert the list to a plain paragraph let isAlreadyRequestedType = false; editor.update(() => { - const target = $getCursorNode(); - if (!target) return; + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return; - const listParent = $findMatchingParent(target, $isListNode); - isAlreadyRequestedType = - listParent?.getListType() === listTypeMap[type]; + const nodes = selection.getNodes(); + const listNodesInSelection = new Set(); - if (isAlreadyRequestedType) return; + nodes.forEach((node) => { + const listParent = $findMatchingParent(node, $isListNode); + if (listParent) listNodesInSelection.add(listParent); + }); - if ($isRootNode(target)) { - const paragraph = $createParagraphNode(); - target.append(paragraph); - paragraph.select(); - } + // Only if all items in list is the same type + isAlreadyRequestedType = + listNodesInSelection.size > 0 && + Array.from(listNodesInSelection).every( + (list) => list.getListType() === listTypeMap[type], + ); }); if (isAlreadyRequestedType) { From 5cc9f54dd76164dc34bc3de3c24cc3e2ca9b4dc8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 6 Sep 2026 02:01:00 +0300 Subject: [PATCH 5/9] fix: handle nested items --- .../__tests__/spec/list.dom.test.ts | 34 ++++++++-- .../EditorPanelPlugin/EditorPanelPlugin.tsx | 62 ++++++++++++++----- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts index 45eff8993..9dd74ff57 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts @@ -57,10 +57,10 @@ test('Converts a list to paragraphs', async () => { expect(within(editor).queryAllByRole('listitem')).toHaveLength(0); }); -test('Convert mixed list', async () => { +test('Convert a mixed list', async () => { const richEditor = await renderRichEditor({ value: `- First item - - [ ] Second item + - [ ] Second item - Third item`, }); @@ -69,13 +69,39 @@ test('Convert mixed list', async () => { expect(within(editor).getAllByRole('listitem')).toHaveLength(2); expect(within(editor).getAllByRole('checkbox')).toHaveLength(1); - // Select the list and convert it to unordered list + // Select the list and convert it to an unordered list selectContent(editor, 'First item', 'Third item'); await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); - // First conversation for mixed list - must unified list to one type + // The first conversion of the mixed list should unify it into a single list type expect(within(editor).getAllByRole('listitem')).toHaveLength(3); expect(within(editor).queryByRole('checkbox')).not.toBeInTheDocument(); + + // The second conversion should convert the list to plain text + selectContent(editor, 'First item', 'Third item'); + await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); + + expect(within(editor).queryByRole('list')).not.toBeInTheDocument(); + expect(within(editor).getAllByRole('paragraph')).toHaveLength(3); +}); + +test('Convert a mixed list', async () => { + const richEditor = await renderRichEditor({ + value: `- First item + - Second item +- Third item`, + }); + + const editor = screen.getByRole('textbox'); + expect(within(editor).getAllByRole('list')).toHaveLength(2); + expect(within(editor).getAllByRole('listitem')).toHaveLength(3); + + // Select the list and convert it to plain text + selectContent(editor, 'First item', 'Third item'); + await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); + + expect(within(editor).queryByRole('list')).not.toBeInTheDocument(); + expect(within(editor).getAllByRole('paragraph')).toHaveLength(3); }); test('Converts paragraphs to a list', async () => { diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx index 5ade22ab9..d5bb7ecdc 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -12,16 +12,18 @@ import { $isTextNode, CONTROLLED_TEXT_INSERTION_COMMAND, FORMAT_TEXT_COMMAND, + LexicalNode, + ParagraphNode, } from 'lexical'; import { $createCodeNode } from '@lexical/code-core'; import { TOGGLE_LINK_COMMAND } from '@lexical/link'; import { + $isListItemNode, $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, ListNode, - REMOVE_LIST_COMMAND, } from '@lexical/list'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'; @@ -34,6 +36,41 @@ import { INSERT_FILES_COMMAND } from '../Files/FilesPlugin'; import { $createImageNode } from '../Image/ImageNode'; import { $canInsertElementsToNode, $getNearestSibling, $wrapNodes } from './utils/tree'; +const $convertListToParagraphs = (listNode: ListNode) => { + const paragraphs: ParagraphNode[] = []; + + const collectItems = (node: LexicalNode) => { + if ($isListNode(node)) { + node.getChildren().forEach(collectItems); + return; + } + + if ($isListItemNode(node)) { + const paragraph = $createParagraphNode(); + + node.getChildren().forEach((child) => { + if ($isListNode(child)) { + collectItems(child); + } else { + paragraph.append(child); + } + }); + paragraphs.push(paragraph); + } + }; + collectItems(listNode); + + if (paragraphs.length === 0) { + listNode.remove(); + return; + } + + paragraphs.toReversed().forEach((paragraph) => { + listNode.insertAfter(paragraph); + }); + listNode.remove(); +}; + /** * Plugin to handle editor panel actions about formatting and nodes insertion */ @@ -107,26 +144,23 @@ export const EditorPanelPlugin = () => { const selection = $getSelection(); if (!$isRangeSelection(selection)) return; - const nodes = selection.getNodes(); - const listNodesInSelection = new Set(); - - nodes.forEach((node) => { + const listNodes = new Set(); + selection.getNodes().forEach((node) => { const listParent = $findMatchingParent(node, $isListNode); - if (listParent) listNodesInSelection.add(listParent); + if (listParent) listNodes.add(listParent); }); - // Only if all items in list is the same type + // If the selection contains nested lists of different types, unify them into one type isAlreadyRequestedType = - listNodesInSelection.size > 0 && - Array.from(listNodesInSelection).every( + listNodes.size > 0 && + Array.from(listNodes).every( (list) => list.getListType() === listTypeMap[type], ); - }); - if (isAlreadyRequestedType) { - editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); - return; - } + if (isAlreadyRequestedType) + listNodes.forEach($convertListToParagraphs); + }); + if (isAlreadyRequestedType) return; switch (type) { case 'checkbox': From d5b4d800d5702ae723ba5a0b776a79a8eda89f4e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 6 Sep 2026 17:36:27 +0300 Subject: [PATCH 6/9] fix: incorrect item order --- .../__tests__/spec/list.dom.test.ts | 69 +++++++++++-------- .../EditorPanelPlugin/EditorPanelPlugin.tsx | 42 +++++------ 2 files changed, 58 insertions(+), 53 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts index 9dd74ff57..46e09c986 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts @@ -39,7 +39,7 @@ test('Converts an unordered list to an ordered list', async () => { test('Converts a list to paragraphs', async () => { const richEditor = await renderRichEditor({ - value: `- First item + value: `- First item - Second item - Third item`, }); @@ -57,73 +57,82 @@ test('Converts a list to paragraphs', async () => { expect(within(editor).queryAllByRole('listitem')).toHaveLength(0); }); -test('Convert a mixed list', async () => { +test('Converts list with nested item to paragraphs', async () => { const richEditor = await renderRichEditor({ value: `- First item - - [ ] Second item + - Second item - Third item`, }); const editor = screen.getByRole('textbox'); expect(within(editor).getAllByRole('list')).toHaveLength(2); - expect(within(editor).getAllByRole('listitem')).toHaveLength(2); - expect(within(editor).getAllByRole('checkbox')).toHaveLength(1); - - // Select the list and convert it to an unordered list - selectContent(editor, 'First item', 'Third item'); - await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); - - // The first conversion of the mixed list should unify it into a single list type expect(within(editor).getAllByRole('listitem')).toHaveLength(3); - expect(within(editor).queryByRole('checkbox')).not.toBeInTheDocument(); - // The second conversion should convert the list to plain text + // Select the list and convert it to plain text selectContent(editor, 'First item', 'Third item'); await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); expect(within(editor).queryByRole('list')).not.toBeInTheDocument(); - expect(within(editor).getAllByRole('paragraph')).toHaveLength(3); + const paragraphs = within(editor).getAllByRole('paragraph'); + expect(paragraphs).toHaveLength(3); + + expect(paragraphs[0]).toHaveTextContent('First item'); + expect(paragraphs[1]).toHaveTextContent('Second item'); + expect(paragraphs[2]).toHaveTextContent('Third item'); }); -test('Convert a mixed list', async () => { +test('Converts a mixed list to paragraphs', async () => { const richEditor = await renderRichEditor({ value: `- First item - - Second item + - [ ] Second item - Third item`, }); const editor = screen.getByRole('textbox'); expect(within(editor).getAllByRole('list')).toHaveLength(2); + expect(within(editor).getAllByRole('listitem')).toHaveLength(2); + expect(within(editor).getAllByRole('checkbox')).toHaveLength(1); + + // Select the list and convert it to an unordered list + selectContent(editor, 'First item', 'Third item'); + await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); + + // The first conversion of the mixed list should unify it into a single list type expect(within(editor).getAllByRole('listitem')).toHaveLength(3); + expect(within(editor).queryByRole('checkbox')).not.toBeInTheDocument(); - // Select the list and convert it to plain text + // The second conversion should convert the list to plain text selectContent(editor, 'First item', 'Third item'); await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); expect(within(editor).queryByRole('list')).not.toBeInTheDocument(); - expect(within(editor).getAllByRole('paragraph')).toHaveLength(3); + const paragraphs = within(editor).getAllByRole('paragraph'); + expect(paragraphs).toHaveLength(3); + expect(paragraphs[0]).toHaveTextContent('First item'); + expect(paragraphs[1]).toHaveTextContent('Second item'); + expect(paragraphs[2]).toHaveTextContent('Third item'); }); test('Converts paragraphs to a list', async () => { const richEditor = await renderRichEditor({ - value: `First item \n\n Second item \n\n Third item`, + value: `First paragraph \n\n Second paragraph \n\n Third paragraph`, }); const editor = screen.getByRole('textbox'); expect(within(editor).getAllByRole('paragraph')).toHaveLength(3); expect(within(editor).queryAllByRole('list')).toHaveLength(0); - selectContent(editor, 'First item', 'Third item'); + selectContent(editor, 'First paragraph', 'Third paragraph'); await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); expect(within(editor).queryAllByRole('list')).toHaveLength(1); - const items = within(editor).queryAllByRole('listitem'); + const items = within(editor).getAllByRole('listitem'); expect(items).toHaveLength(3); // List has the correct item order - expect(items[0]).toHaveTextContent('First item'); - expect(items[1]).toHaveTextContent('Second item'); - expect(items[2]).toHaveTextContent('Third item'); + expect(items[0]).toHaveTextContent('First paragraph'); + expect(items[1]).toHaveTextContent('Second paragraph'); + expect(items[2]).toHaveTextContent('Third paragraph'); }); test('Converts paragraphs to a list and back to paragraphs', async () => { @@ -150,7 +159,7 @@ test('Converts paragraphs to a list and back to paragraphs', async () => { await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); expect(within(editor).queryAllByRole('list')).toHaveLength(0); - const paragraphs = within(editor).queryAllByRole('paragraph'); + const paragraphs = within(editor).getAllByRole('paragraph'); expect(paragraphs).toHaveLength(2); expect(paragraphs[0]).toHaveTextContent('1 2'); expect(paragraphs[1]).toHaveTextContent('3'); @@ -160,21 +169,21 @@ test('Converts selected paragraphs to a list', async () => { const richEditor = await renderRichEditor({ value: `First separate text \n\n Second item \n\n Third item`, }); - const editor = screen.getByRole('textbox'); expect(within(editor).getAllByRole('paragraph')).toHaveLength(3); + expect(within(editor).queryAllByRole('list')).toHaveLength(0); + // Select only two paragraphs selectContent(editor, 'Second item', 'Third item'); await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); - const list = within(editor).getAllByRole('list'); - expect(list).toHaveLength(1); - expect(within(list[0]).getAllByRole('listitem')).toHaveLength(2); + const list = within(editor).getByRole('list'); + expect(within(list).getAllByRole('listitem')).toHaveLength(2); // The first paragraph remains a plain paragraph const paragraphs = within(editor).queryAllByRole('paragraph'); expect(paragraphs[0]).toHaveTextContent('First separate text'); - expect(list[0]).not.toContainElement(paragraphs[0]); + expect(list).not.toContainElement(paragraphs[0]); }); test('Pressing Enter adds a new item to the list', async () => { diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx index d5bb7ecdc..e7c5fccd7 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -13,7 +13,6 @@ import { CONTROLLED_TEXT_INSERTION_COMMAND, FORMAT_TEXT_COMMAND, LexicalNode, - ParagraphNode, } from 'lexical'; import { $createCodeNode } from '@lexical/code-core'; import { TOGGLE_LINK_COMMAND } from '@lexical/link'; @@ -36,39 +35,32 @@ import { INSERT_FILES_COMMAND } from '../Files/FilesPlugin'; import { $createImageNode } from '../Image/ImageNode'; import { $canInsertElementsToNode, $getNearestSibling, $wrapNodes } from './utils/tree'; -const $convertListToParagraphs = (listNode: ListNode) => { - const paragraphs: ParagraphNode[] = []; - - const collectItems = (node: LexicalNode) => { +const $convertListToParagraphs = (list: ListNode) => { + const insertParagraphs = (node: LexicalNode) => { if ($isListNode(node)) { - node.getChildren().forEach(collectItems); + node.getChildren().forEach(insertParagraphs); return; } if ($isListItemNode(node)) { const paragraph = $createParagraphNode(); + const nestedLists: ListNode[] = []; node.getChildren().forEach((child) => { if ($isListNode(child)) { - collectItems(child); + nestedLists.push(child); } else { paragraph.append(child); } }); - paragraphs.push(paragraph); + + list.insertBefore(paragraph); + nestedLists.forEach(insertParagraphs); } }; - collectItems(listNode); - - if (paragraphs.length === 0) { - listNode.remove(); - return; - } - paragraphs.toReversed().forEach((paragraph) => { - listNode.insertAfter(paragraph); - }); - listNode.remove(); + insertParagraphs(list); + list.remove(); }; /** @@ -139,7 +131,8 @@ export const EditorPanelPlugin = () => { // Check if the list is already of the requested type // If so, convert the list to a plain paragraph - let isAlreadyRequestedType = false; + let didConvert = false; + editor.update(() => { const selection = $getSelection(); if (!$isRangeSelection(selection)) return; @@ -151,16 +144,19 @@ export const EditorPanelPlugin = () => { }); // If the selection contains nested lists of different types, unify them into one type - isAlreadyRequestedType = + const isAlreadyRequestedType = listNodes.size > 0 && Array.from(listNodes).every( (list) => list.getListType() === listTypeMap[type], ); - if (isAlreadyRequestedType) - listNodes.forEach($convertListToParagraphs); + if (!isAlreadyRequestedType) return; + + listNodes.forEach($convertListToParagraphs); + didConvert = true; }); - if (isAlreadyRequestedType) return; + + if (didConvert) return; switch (type) { case 'checkbox': From 218e8449972dc828bccd395a81922c05ed31b97c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 7 Sep 2026 00:27:43 +0300 Subject: [PATCH 7/9] refactor: return guard for empty editor --- .../EditorPanelPlugin/EditorPanelPlugin.tsx | 51 ++++++------------- .../utils/$convertListToParagraphs.ts | 33 ++++++++++++ 2 files changed, 49 insertions(+), 35 deletions(-) create mode 100644 packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToParagraphs.ts diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx index e7c5fccd7..f820ec92a 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -12,12 +12,10 @@ import { $isTextNode, CONTROLLED_TEXT_INSERTION_COMMAND, FORMAT_TEXT_COMMAND, - LexicalNode, } from 'lexical'; import { $createCodeNode } from '@lexical/code-core'; import { TOGGLE_LINK_COMMAND } from '@lexical/link'; import { - $isListItemNode, $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, @@ -33,34 +31,13 @@ import { $getCursorNode } from '../../utils/selection'; import { INSERT_FILES_COMMAND } from '../Files/FilesPlugin'; import { $createImageNode } from '../Image/ImageNode'; +import { $convertListToParagraphs } from './utils/$convertListToParagraphs'; import { $canInsertElementsToNode, $getNearestSibling, $wrapNodes } from './utils/tree'; -const $convertListToParagraphs = (list: ListNode) => { - const insertParagraphs = (node: LexicalNode) => { - if ($isListNode(node)) { - node.getChildren().forEach(insertParagraphs); - return; - } - - if ($isListItemNode(node)) { - const paragraph = $createParagraphNode(); - const nestedLists: ListNode[] = []; - - node.getChildren().forEach((child) => { - if ($isListNode(child)) { - nestedLists.push(child); - } else { - paragraph.append(child); - } - }); - - list.insertBefore(paragraph); - nestedLists.forEach(insertParagraphs); - } - }; - - insertParagraphs(list); - list.remove(); +const listTypeMap = { + checkbox: 'check', + ordered: 'number', + unordered: 'bullet', }; /** @@ -123,16 +100,9 @@ export const EditorPanelPlugin = () => { }); }, list({ type }) { - const listTypeMap = { - checkbox: 'check', - ordered: 'number', - unordered: 'bullet', - }; - // Check if the list is already of the requested type // If so, convert the list to a plain paragraph let didConvert = false; - editor.update(() => { const selection = $getSelection(); if (!$isRangeSelection(selection)) return; @@ -158,6 +128,17 @@ export const EditorPanelPlugin = () => { if (didConvert) return; + editor.update(() => { + const target = $getCursorNode(); + if (!target) return; + + if ($isRootNode(target) && target.getChildren().length === 0) { + const paragraph = $createParagraphNode(); + target.append(paragraph); + paragraph.select(); + } + }); + switch (type) { case 'checkbox': editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined); diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToParagraphs.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToParagraphs.ts new file mode 100644 index 000000000..f848619a5 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToParagraphs.ts @@ -0,0 +1,33 @@ +import { $createParagraphNode, LexicalNode } from 'lexical'; +import { $isListItemNode, $isListNode, ListNode } from '@lexical/list'; + +/** + * Convert the list (including nested items) into a sequence of plain paragraphs + */ +export const $convertListToParagraphs = (list: ListNode) => { + const insertParagraphs = (node: LexicalNode) => { + if ($isListNode(node)) { + node.getChildren().forEach(insertParagraphs); + return; + } + + if ($isListItemNode(node)) { + const paragraph = $createParagraphNode(); + const nestedLists: ListNode[] = []; + + node.getChildren().forEach((child) => { + if ($isListNode(child)) { + nestedLists.push(child); + } else { + paragraph.append(child); + } + }); + + list.insertBefore(paragraph); + nestedLists.forEach(insertParagraphs); + } + }; + + insertParagraphs(list); + list.remove(); +}; From a0f6fa2096c7ea858e97902cf1642d3e3dec3e10 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 7 Sep 2026 22:00:13 +0300 Subject: [PATCH 8/9] test: improve --- .../__tests__/spec/list.dom.test.ts | 63 ++++++++----------- 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts index 46e09c986..9b05cad46 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts @@ -52,9 +52,14 @@ test('Converts a list to paragraphs', async () => { selectContent(editor, 'First item', 'Third item'); await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); - expect(within(editor).getAllByRole('paragraph')).toHaveLength(3); expect(within(editor).queryAllByRole('list')).toHaveLength(0); expect(within(editor).queryAllByRole('listitem')).toHaveLength(0); + + const paragraphs = within(editor).getAllByRole('paragraph'); + expect(paragraphs).toHaveLength(3); + expect(paragraphs[0]).toHaveTextContent('First item'); + expect(paragraphs[1]).toHaveTextContent('Second item'); + expect(paragraphs[2]).toHaveTextContent('Third item'); }); test('Converts list with nested item to paragraphs', async () => { @@ -72,10 +77,10 @@ test('Converts list with nested item to paragraphs', async () => { selectContent(editor, 'First item', 'Third item'); await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); - expect(within(editor).queryByRole('list')).not.toBeInTheDocument(); + expect(within(editor).queryAllByRole('list')).toHaveLength(0); + const paragraphs = within(editor).getAllByRole('paragraph'); expect(paragraphs).toHaveLength(3); - expect(paragraphs[0]).toHaveTextContent('First item'); expect(paragraphs[1]).toHaveTextContent('Second item'); expect(paragraphs[2]).toHaveTextContent('Third item'); @@ -99,13 +104,14 @@ test('Converts a mixed list to paragraphs', async () => { // The first conversion of the mixed list should unify it into a single list type expect(within(editor).getAllByRole('listitem')).toHaveLength(3); - expect(within(editor).queryByRole('checkbox')).not.toBeInTheDocument(); + expect(within(editor).queryAllByRole('checkbox')).toHaveLength(0); // The second conversion should convert the list to plain text selectContent(editor, 'First item', 'Third item'); await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); - expect(within(editor).queryByRole('list')).not.toBeInTheDocument(); + expect(within(editor).queryAllByRole('list')).toHaveLength(0); + const paragraphs = within(editor).getAllByRole('paragraph'); expect(paragraphs).toHaveLength(3); expect(paragraphs[0]).toHaveTextContent('First item'); @@ -113,29 +119,33 @@ test('Converts a mixed list to paragraphs', async () => { expect(paragraphs[2]).toHaveTextContent('Third item'); }); -test('Converts paragraphs to a list', async () => { +test('Converts selected paragraphs to a list', async () => { const richEditor = await renderRichEditor({ - value: `First paragraph \n\n Second paragraph \n\n Third paragraph`, + value: `First separate text \n\n Second item \n\n Third item`, }); - const editor = screen.getByRole('textbox'); expect(within(editor).getAllByRole('paragraph')).toHaveLength(3); expect(within(editor).queryAllByRole('list')).toHaveLength(0); - selectContent(editor, 'First paragraph', 'Third paragraph'); + // Select only two paragraphs + selectContent(editor, 'Second item', 'Third item'); await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); - expect(within(editor).queryAllByRole('list')).toHaveLength(1); - const items = within(editor).getAllByRole('listitem'); - expect(items).toHaveLength(3); + const list = within(editor).getByRole('list'); + const items = within(list).getAllByRole('listitem'); + expect(items).toHaveLength(2); + expect(items[0]).toHaveTextContent('Second item'); + expect(items[1]).toHaveTextContent('Third item'); - // List has the correct item order - expect(items[0]).toHaveTextContent('First paragraph'); - expect(items[1]).toHaveTextContent('Second paragraph'); - expect(items[2]).toHaveTextContent('Third paragraph'); + // The first paragraph remains a plain paragraph, untouched by the conversion + const paragraphs = within(editor).queryAllByRole('paragraph'); + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0]).toHaveTextContent('First separate text'); + expect(list).not.toContainElement(paragraphs[0]); }); test('Converts paragraphs to a list and back to paragraphs', async () => { + // `1 \n 2` is a single paragraph with a soft line break const richEditor = await renderRichEditor({ value: `1 \n 2 \n\n 3`, }); @@ -165,27 +175,6 @@ test('Converts paragraphs to a list and back to paragraphs', async () => { expect(paragraphs[1]).toHaveTextContent('3'); }); -test('Converts selected paragraphs to a list', async () => { - const richEditor = await renderRichEditor({ - value: `First separate text \n\n Second item \n\n Third item`, - }); - const editor = screen.getByRole('textbox'); - expect(within(editor).getAllByRole('paragraph')).toHaveLength(3); - expect(within(editor).queryAllByRole('list')).toHaveLength(0); - - // Select only two paragraphs - selectContent(editor, 'Second item', 'Third item'); - await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); - - const list = within(editor).getByRole('list'); - expect(within(list).getAllByRole('listitem')).toHaveLength(2); - - // The first paragraph remains a plain paragraph - const paragraphs = within(editor).queryAllByRole('paragraph'); - expect(paragraphs[0]).toHaveTextContent('First separate text'); - expect(list).not.toContainElement(paragraphs[0]); -}); - test('Pressing Enter adds a new item to the list', async () => { const user = userEvent.setup(); await renderRichEditor({ value: '- First item' }); From 1d788ae2b8c97416f3a47958e2c6e07c1e6f1431 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 7 Sep 2026 22:48:13 +0300 Subject: [PATCH 9/9] chore: fix typo --- .../plugins/EditorPanelPlugin/EditorPanelPlugin.tsx | 6 +++--- .../EditorPanelPlugin/utils/$convertListToParagraphs.ts | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx index f820ec92a..5eb632e95 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -100,8 +100,8 @@ export const EditorPanelPlugin = () => { }); }, list({ type }) { - // Check if the list is already of the requested type - // If so, convert the list to a plain paragraph + // Check whether all selected lists already have the requested type + // If they do, convert them to paragraphs instead of applying the same list type let didConvert = false; editor.update(() => { const selection = $getSelection(); @@ -113,7 +113,6 @@ export const EditorPanelPlugin = () => { if (listParent) listNodes.add(listParent); }); - // If the selection contains nested lists of different types, unify them into one type const isAlreadyRequestedType = listNodes.size > 0 && Array.from(listNodes).every( @@ -128,6 +127,7 @@ export const EditorPanelPlugin = () => { if (didConvert) return; + // Ensure the editor is not empty editor.update(() => { const target = $getCursorNode(); if (!target) return; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToParagraphs.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToParagraphs.ts index f848619a5..ffa1528dc 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToParagraphs.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToParagraphs.ts @@ -2,7 +2,7 @@ import { $createParagraphNode, LexicalNode } from 'lexical'; import { $isListItemNode, $isListNode, ListNode } from '@lexical/list'; /** - * Convert the list (including nested items) into a sequence of plain paragraphs + * Convert the list, including nested items, into a sequence of plain paragraphs */ export const $convertListToParagraphs = (list: ListNode) => { const insertParagraphs = (node: LexicalNode) => { @@ -23,6 +23,7 @@ export const $convertListToParagraphs = (list: ListNode) => { } }); + // The nested list must be handled after the parent to preserve the correct order list.insertBefore(paragraph); nestedLists.forEach(insertParagraphs); }