From 96d6ad613f87df479f55d6353ae1e126a38e4ec6 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 29 Aug 2026 23:39:43 +0300 Subject: [PATCH 01/12] 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 43fa9480e..752ba6483 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, $getSelection, $insertNodes, @@ -16,9 +17,11 @@ import { import { $createCodeNode } from '@lexical/code-core'; import { $createLinkNode, 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'; @@ -95,6 +98,12 @@ export const EditorPanelPlugin = () => { }); }, list({ type }) { + const listTypeMap = { + checkbox: 'check', + ordered: 'number', + unordered: 'bullet', + }; + editor.update(() => { const target = $getCursorNode(); if (!target) return; @@ -106,6 +115,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 615a68358eae8cd916e93e61afb528311a355bba Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 31 Aug 2026 16:45:38 +0300 Subject: [PATCH 02/12] chore: add test --- .../__tests__/spec/list/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/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts index 7fa5f3fd6..77563929a 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/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 752ba6483..9c4d45a81 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -104,10 +104,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); @@ -115,16 +124,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 5e3673b61367ac827778c36291009a319ca45e08 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 2 Sep 2026 23:40:53 +0300 Subject: [PATCH 03/12] test: add --- .../__tests__/spec/list/list.dom.test.ts | 80 ++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts index 77563929a..8b835dd26 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/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 41c17b3bf2feb095236d5f2e1527580ec3cb53e3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 6 Sep 2026 01:36:57 +0300 Subject: [PATCH 04/12] fix: first unified list --- .../__tests__/spec/list/list.dom.test.ts | 21 +++++++++++++++ .../EditorPanelPlugin/EditorPanelPlugin.tsx | 26 +++++++++++-------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts index 8b835dd26..60643432a 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/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 9c4d45a81..0180e3c33 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -21,6 +21,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'; @@ -108,20 +109,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 56873e8e62a39067a670ca85fee82ca4fbdce8da Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 6 Sep 2026 02:01:00 +0300 Subject: [PATCH 05/12] fix: handle nested items --- .../__tests__/spec/list/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/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts index 60643432a..5ca67d5ab 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/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 0180e3c33..3ee909068 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -13,16 +13,18 @@ import { $isTextNode, CONTROLLED_TEXT_INSERTION_COMMAND, FORMAT_TEXT_COMMAND, + LexicalNode, + ParagraphNode, } from 'lexical'; import { $createCodeNode } from '@lexical/code-core'; import { $createLinkNode, 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'; @@ -39,6 +41,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 */ @@ -112,26 +149,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 48bafd69067acaca221bba65ec0ef95993e49d89 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 6 Sep 2026 17:36:27 +0300 Subject: [PATCH 06/12] fix: incorrect item order --- .../__tests__/spec/list/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/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts index 5ca67d5ab..78447edff 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/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 3ee909068..c2d504479 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -14,7 +14,6 @@ import { CONTROLLED_TEXT_INSERTION_COMMAND, FORMAT_TEXT_COMMAND, LexicalNode, - ParagraphNode, } from 'lexical'; import { $createCodeNode } from '@lexical/code-core'; import { $createLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'; @@ -41,39 +40,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(); }; /** @@ -144,7 +136,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; @@ -156,16 +149,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 d9d3a22339ec8aa9c8ecf4110a5eb6d42dd4a6c0 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 7 Sep 2026 00:27:43 +0300 Subject: [PATCH 07/12] 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 c2d504479..ff51f59f5 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -13,12 +13,10 @@ import { $isTextNode, CONTROLLED_TEXT_INSERTION_COMMAND, FORMAT_TEXT_COMMAND, - LexicalNode, } from 'lexical'; import { $createCodeNode } from '@lexical/code-core'; import { $createLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'; import { - $isListItemNode, $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, @@ -38,34 +36,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', }; /** @@ -128,16 +105,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; @@ -163,6 +133,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 caa7e51e9b70f1c165f6245c9053ba47f4691208 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 7 Sep 2026 22:00:13 +0300 Subject: [PATCH 08/12] test: improve --- .../__tests__/spec/list/list.dom.test.ts | 63 ++++++++----------- 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts index 78447edff..8823665c2 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/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 bda61b52050b1f4f8f65e3575376c40bbe07aa1f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 7 Sep 2026 22:48:13 +0300 Subject: [PATCH 09/12] 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 ff51f59f5..1df7f9274 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -105,8 +105,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(); @@ -118,7 +118,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( @@ -133,6 +132,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); } From 9c9845026eb44b9aa567d5fd6112262d367e4ad4 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Mon, 12 Oct 2026 23:32:09 +0300 Subject: [PATCH 10/12] refactor: unlist nested lists as single paragraph --- .../__tests__/spec/list.browser.test.ts | 82 +++++++++++++++++++ .../__tests__/spec/list/list.dom.test.ts | 57 ------------- .../EditorPanelPlugin/EditorPanelPlugin.tsx | 20 ++++- .../utils/$convertListToLines.ts | 70 ++++++++++++++++ .../utils/$convertListToParagraphs.ts | 34 -------- words.txt | 4 +- 6 files changed, 171 insertions(+), 96 deletions(-) create mode 100644 packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToLines.ts delete mode 100644 packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToParagraphs.ts diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.browser.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.browser.test.ts index 0f2a54f60..b70623d38 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.browser.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.browser.test.ts @@ -27,6 +27,88 @@ test('one list item may be nested', async () => { .toHaveBeenLastCalledWith('- [ ] 1\n - [ ] 2\n- [ ] 3\n'); }); +test('unlisting a top-level list creates one paragraph per item', async () => { + const richEditor = await renderRichEditorInDOM({ + value: '- First\n- Second\n- Third', + }); + const editor = page.getByRole('textbox'); + + await act(async () => { + await editor.getByText('Second', { exact: true }).click(); + await userEvent.keyboard('{End}'); + }); + await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); + + expect(editor.getByRole('list')).toHaveLength(0); + const paragraphs = editor.getByRole('paragraph'); + expect(paragraphs).toHaveLength(3); + expect(paragraphs.nth(0)).toHaveTextContent('First'); + expect(paragraphs.nth(1)).toHaveTextContent('Second'); + expect(paragraphs.nth(2)).toHaveTextContent('Third'); +}); + +test('unlisting a nested list turns its items into lines inside the parent item', async () => { + const richEditor = await renderRichEditorInDOM({ + value: ['- [ ] 1', ' - [ ] 2', ' - [ ] 3', '- [ ] 4', '- [ ] 5'].join('\n'), + }); + const editor = page.getByRole('textbox'); + + await act(async () => { + await editor.getByText('2', { exact: true }).click(); + await userEvent.keyboard('{End}'); + }); + await richEditor.insert({ type: 'list', data: { type: 'checkbox' } }); + + const items = editor.getByRole('checkbox'); + expect(items).toHaveLength(3); + expect(items.nth(0)).toHaveTextContent('1'); + expect(items.nth(1)).toHaveTextContent('4'); + expect(items.nth(2)).toHaveTextContent('5'); + + const parentItem = items.nth(0); + expect(parentItem.getByRole('list')).toHaveLength(0); + expect(parentItem.getByRole('paragraph')).toHaveLength(0); + expect(parentItem.element().querySelectorAll('br')).toHaveLength(2); + expect(parentItem).toHaveTextContent('123'); + + const serialized = richEditor.getMarkdown(); + expect(serialized.indexOf('1')).toBeLessThan(serialized.indexOf('2')); + expect(serialized.indexOf('2')).toBeLessThan(serialized.indexOf('3')); + await richEditor.render({ value: serialized }); + expect(richEditor.getMarkdown()).toBe(serialized); + expect(page.getByRole('checkbox')).toHaveLength(3); + expect(page.getByRole('list')).toHaveLength(1); + expect( + page.getByRole('checkbox').nth(0).element().querySelectorAll('br'), + ).toHaveLength(2); +}); + +test('a partial selection unlists the whole touched nested list', async () => { + const richEditor = await renderRichEditorInDOM({ + value: ['- Parent', ' - First child', ' - Second child', '- Next'].join('\n'), + }); + const editor = page.getByRole('textbox'); + + await act(async () => { + selectContent(editor.element() as HTMLElement, 'First child'); + }); + await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); + + expect(editor.getByRole('list')).toHaveLength(1); + expect(editor.getByRole('listitem')).toHaveLength(2); + expect( + editor.getByRole('listitem').nth(0).element().querySelectorAll('br'), + ).toHaveLength(2); + expect(editor.getByRole('listitem').nth(0)).toHaveTextContent( + 'ParentFirst childSecond child', + ); + const serialized = richEditor.getMarkdown(); + expect(serialized.indexOf('Parent')).toBeLessThan(serialized.indexOf('First child')); + expect(serialized.indexOf('First child')).toBeLessThan( + serialized.indexOf('Second child'), + ); +}); + test('list items may be deeply nested via keyboard shortcuts', async () => { const onChanged = vi.fn(); diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts index 8823665c2..ffde1ef3c 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list/list.dom.test.ts @@ -62,63 +62,6 @@ test('Converts a list to paragraphs', async () => { expect(paragraphs[2]).toHaveTextContent('Third item'); }); -test('Converts list with nested item 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(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).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'); -}); - -test('Converts a mixed 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(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).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).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'); -}); - test('Converts selected paragraphs to a list', async () => { const richEditor = await renderRichEditor({ value: `First separate text \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 1df7f9274..d129eb129 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -36,7 +36,7 @@ import { $getCursorNode } from '../../utils/selection'; import { INSERT_FILES_COMMAND } from '../Files/FilesPlugin'; import { $createImageNode } from '../Image/ImageNode'; -import { $convertListToParagraphs } from './utils/$convertListToParagraphs'; +import { $convertListToLines } from './utils/$convertListToLines'; import { $canInsertElementsToNode, $getNearestSibling, $wrapNodes } from './utils/tree'; const listTypeMap = { @@ -105,8 +105,8 @@ export const EditorPanelPlugin = () => { }); }, list({ type }) { - // Check whether all selected lists already have the requested type - // If they do, convert them to paragraphs instead of applying the same list type + // Check whether all selected lists already have the requested type. + // If so, convert their items back to lines instead of toggling the same type. let didConvert = false; editor.update(() => { const selection = $getSelection(); @@ -118,6 +118,18 @@ export const EditorPanelPlugin = () => { if (listParent) listNodes.add(listParent); }); + const targetLists = Array.from(listNodes).filter((list) => { + for ( + let ancestor = list.getParent(); + ancestor; + ancestor = ancestor.getParent() + ) { + if ($isListNode(ancestor) && listNodes.has(ancestor)) + return false; + } + return true; + }); + const isAlreadyRequestedType = listNodes.size > 0 && Array.from(listNodes).every( @@ -126,7 +138,7 @@ export const EditorPanelPlugin = () => { if (!isAlreadyRequestedType) return; - listNodes.forEach($convertListToParagraphs); + targetLists.forEach($convertListToLines); didConvert = true; }); diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToLines.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToLines.ts new file mode 100644 index 000000000..f9c34c57a --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToLines.ts @@ -0,0 +1,70 @@ +import { + $createLineBreakNode, + $createParagraphNode, + $isParagraphNode, + LexicalNode, +} from 'lexical'; +import { $isListItemNode, $isListNode, ListNode } from '@lexical/list'; + +const $getLines = (node: LexicalNode): LexicalNode[][] => { + if ($isListNode(node)) return node.getChildren().flatMap($getLines); + if (!$isListItemNode(node)) return []; + + const lines: LexicalNode[][] = []; + let currentLine: LexicalNode[] = []; + + node.getChildren().forEach((child) => { + if ($isListNode(child)) { + if (currentLine.length > 0 || lines.length === 0) { + lines.push(currentLine); + currentLine = []; + } + lines.push(...$getLines(child)); + } else if ($isParagraphNode(child)) { + currentLine.push(...child.getChildren()); + } else { + currentLine.push(child); + } + }); + + if (currentLine.length > 0 || lines.length === 0) lines.push(currentLine); + return lines; +}; + +/** + * Convert list items back into lines, placing nested-list lines in their parent item. + */ +export const $convertListToLines = (list: ListNode) => { + const parent = list.getParent(); + const lines = $getLines(list); + + if ($isListItemNode(parent)) { + // Keep converted lines in the item's existing paragraph when it has one. + const paragraph = parent.getChildren().find($isParagraphNode); + const append = (node: LexicalNode) => { + if (paragraph) paragraph.append(node); + else list.insertBefore(node); + }; + + lines.forEach((line) => { + append($createLineBreakNode()); + line.forEach(append); + }); + } else { + // A top-level list becomes one paragraph per top-level item. Descendants become + // line breaks within their top-level item's paragraph. + list.getChildren().forEach((node) => { + if (!$isListItemNode(node)) return; + + const itemLines = $getLines(node); + const paragraph = $createParagraphNode(); + itemLines.forEach((line, index) => { + if (index > 0) paragraph.append($createLineBreakNode()); + paragraph.append(...line); + }); + list.insertBefore(paragraph); + }); + } + + list.remove(); +}; 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 deleted file mode 100644 index ffa1528dc..000000000 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToParagraphs.ts +++ /dev/null @@ -1,34 +0,0 @@ -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); - } - }); - - // The nested list must be handled after the parent to preserve the correct order - list.insertBefore(paragraph); - nestedLists.forEach(insertParagraphs); - } - }; - - insertParagraphs(list); - list.remove(); -}; diff --git a/words.txt b/words.txt index 41e83a94d..1330bad90 100644 --- a/words.txt +++ b/words.txt @@ -148,4 +148,6 @@ contenteditable roledescription valuetext unnests -outdenting \ No newline at end of file +outdenting +unlisting +unlists \ No newline at end of file From b9cae16483f1448149764daa3c1ebf41364ed2f3 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 13 Oct 2026 00:49:48 +0300 Subject: [PATCH 11/12] refactor: move list formatting logic into plugin --- .../RichEditor/RichEditorContent.tsx | 2 + .../__tests__/spec/list.browser.test.ts | 18 +++ .../EditorPanelPlugin/EditorPanelPlugin.tsx | 61 ---------- .../$convertListToLines.ts | 0 .../ListFormattingPlugin.tsx | 105 ++++++++++++++++++ 5 files changed, 125 insertions(+), 61 deletions(-) rename packages/app/src/features/NoteEditor/RichEditor/plugins/{EditorPanelPlugin/utils => ListFormattingPlugin}/$convertListToLines.ts (100%) create mode 100644 packages/app/src/features/NoteEditor/RichEditor/plugins/ListFormattingPlugin/ListFormattingPlugin.tsx diff --git a/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx b/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx index f52e34bf3..35bd6cf8b 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx @@ -31,6 +31,7 @@ import { HighlightingPlugin } from './plugins/HighlightingPlugin/HighlightingPlu import ImagesPlugin from './plugins/Image/ImagesPlugin'; import { KeyboardControlsPlugin } from './plugins/KeyboardControlsPlugin/KeyboardControlsPlugin'; import { LinkClickHandlerPlugin } from './plugins/LinkClickHandlerPlugin'; +import { ListFormattingPlugin } from './plugins/ListFormattingPlugin/ListFormattingPlugin'; import { MarkdownSerializePlugin, MarkdownSerializePluginProps, @@ -136,6 +137,7 @@ export const RichEditorContent = ({ + diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.browser.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.browser.test.ts index b70623d38..94a481f70 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.browser.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.browser.test.ts @@ -47,6 +47,24 @@ test('unlisting a top-level list creates one paragraph per item', async () => { expect(paragraphs.nth(2)).toHaveTextContent('Third'); }); +test('list command falls through to Lexical when the requested type differs', async () => { + const richEditor = await renderRichEditorInDOM({ + value: '- [ ] First item\n- [ ] Second item', + }); + const editor = page.getByRole('textbox'); + + await act(async () => { + await editor.getByText('First item', { exact: true }).click(); + await userEvent.keyboard('{End}'); + }); + await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); + + expect(editor.getByRole('list')).toHaveLength(1); + expect(editor.getByRole('listitem')).toHaveLength(2); + expect(editor.getByRole('checkbox')).toHaveLength(0); + expect(editor.getByRole('list').element().tagName).toBe('UL'); +}); + test('unlisting a nested list turns its items into lines inside the parent item', async () => { const richEditor = await renderRichEditorInDOM({ value: ['- [ ] 1', ' - [ ] 2', ' - [ ] 3', '- [ ] 4', '- [ ] 5'].join('\n'), 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 d129eb129..3cb086910 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -2,7 +2,6 @@ import { useEffect } from 'react'; import { $createParagraphNode, $createTextNode, - $findMatchingParent, $getRoot, $getSelection, $insertNodes, @@ -17,11 +16,9 @@ import { import { $createCodeNode } from '@lexical/code-core'; import { $createLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'; import { - $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, - ListNode, } from '@lexical/list'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'; @@ -36,15 +33,8 @@ import { $getCursorNode } from '../../utils/selection'; import { INSERT_FILES_COMMAND } from '../Files/FilesPlugin'; import { $createImageNode } from '../Image/ImageNode'; -import { $convertListToLines } from './utils/$convertListToLines'; import { $canInsertElementsToNode, $getNearestSibling, $wrapNodes } from './utils/tree'; -const listTypeMap = { - checkbox: 'check', - ordered: 'number', - unordered: 'bullet', -}; - /** * Plugin to handle editor panel actions about formatting and nodes insertion */ @@ -105,57 +95,6 @@ export const EditorPanelPlugin = () => { }); }, list({ type }) { - // Check whether all selected lists already have the requested type. - // If so, convert their items back to lines instead of toggling the same type. - let didConvert = false; - editor.update(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) return; - - const listNodes = new Set(); - selection.getNodes().forEach((node) => { - const listParent = $findMatchingParent(node, $isListNode); - if (listParent) listNodes.add(listParent); - }); - - const targetLists = Array.from(listNodes).filter((list) => { - for ( - let ancestor = list.getParent(); - ancestor; - ancestor = ancestor.getParent() - ) { - if ($isListNode(ancestor) && listNodes.has(ancestor)) - return false; - } - return true; - }); - - const isAlreadyRequestedType = - listNodes.size > 0 && - Array.from(listNodes).every( - (list) => list.getListType() === listTypeMap[type], - ); - - if (!isAlreadyRequestedType) return; - - targetLists.forEach($convertListToLines); - didConvert = true; - }); - - if (didConvert) return; - - // Ensure the editor is not empty - 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/$convertListToLines.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/ListFormattingPlugin/$convertListToLines.ts similarity index 100% rename from packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToLines.ts rename to packages/app/src/features/NoteEditor/RichEditor/plugins/ListFormattingPlugin/$convertListToLines.ts diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/ListFormattingPlugin/ListFormattingPlugin.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/ListFormattingPlugin/ListFormattingPlugin.tsx new file mode 100644 index 000000000..d91d250c6 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/ListFormattingPlugin/ListFormattingPlugin.tsx @@ -0,0 +1,105 @@ +import { useEffect } from 'react'; +import { + $createParagraphNode, + $findMatchingParent, + $getSelection, + $isRangeSelection, + $isRootNode, + COMMAND_PRIORITY_BEFORE_LOW, +} from 'lexical'; +import { + $isListNode, + INSERT_CHECK_LIST_COMMAND, + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, + ListNode, +} from '@lexical/list'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { mergeRegister } from '@lexical/utils'; + +import { $getCursorNode } from '../../utils/selection'; + +import { $convertListToLines } from './$convertListToLines'; + +const listTypeMap = { + checkbox: 'check', + ordered: 'number', + unordered: 'bullet', +} as const; + +type ListType = (typeof listTypeMap)[keyof typeof listTypeMap]; + +const $tryConvertListToLines = (requestedType: ListType) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return false; + + const listNodes = new Set(); + selection.getNodes().forEach((node) => { + const list = $findMatchingParent(node, $isListNode); + if (list) listNodes.add(list); + }); + + if ( + listNodes.size === 0 || + !Array.from(listNodes).every((list) => list.getListType() === requestedType) + ) + return false; + + const targetLists = Array.from(listNodes).filter((list) => { + for (let ancestor = list.getParent(); ancestor; ancestor = ancestor.getParent()) { + if ($isListNode(ancestor) && listNodes.has(ancestor)) return false; + } + return true; + }); + + targetLists.forEach($convertListToLines); + return true; +}; + +const $ensureEditorNotEmpty = () => { + const target = $getCursorNode(); + if (!$isRootNode(target) || target.getChildrenSize() > 0) return; + + const paragraph = $createParagraphNode(); + target.append(paragraph); + paragraph.select(); +}; + +const $handleListCommand = (requestedType: ListType) => { + if ($tryConvertListToLines(requestedType)) return true; + + // Ensure the built-in list command has a block to transform in an empty editor. + $ensureEditorNotEmpty(); + return false; +}; + +/** + * Intercepts list insertion commands to toggle an already-matching list back into lines. + */ +export const ListFormattingPlugin = () => { + const [editor] = useLexicalComposerContext(); + + useEffect( + () => + mergeRegister( + editor.registerCommand( + INSERT_CHECK_LIST_COMMAND, + () => $handleListCommand(listTypeMap.checkbox), + COMMAND_PRIORITY_BEFORE_LOW, + ), + editor.registerCommand( + INSERT_ORDERED_LIST_COMMAND, + () => $handleListCommand(listTypeMap.ordered), + COMMAND_PRIORITY_BEFORE_LOW, + ), + editor.registerCommand( + INSERT_UNORDERED_LIST_COMMAND, + () => $handleListCommand(listTypeMap.unordered), + COMMAND_PRIORITY_BEFORE_LOW, + ), + ), + [editor], + ); + + return null; +}; From b618b905903553826ba18c317cbd54e5ad4d8d8d Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 13 Oct 2026 18:22:42 +0300 Subject: [PATCH 12/12] test: cover selection cases --- .../__tests__/spec/list.browser.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.browser.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.browser.test.ts index 94a481f70..50b3d01c3 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.browser.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.browser.test.ts @@ -280,6 +280,53 @@ describe('Regressions', () => { ); }); + test('unlists a keyboard-nested child into its parent item', async () => { + const richEditor = await renderRichEditorInDOM({ + value: '- Parent\n- Child', + }); + const editor = page.getByRole('textbox'); + + expect(editor.getByRole('list')).toHaveLength(1); + + await act(async () => { + await editor.getByText('Child', { exact: true }).click(); + await userEvent.keyboard('{End}{Tab}'); + }); + expect(editor.getByRole('list')).toHaveLength(2); + + await richEditor.insert({ + type: 'list', + data: { type: 'unordered' }, + }); + + expect(editor.getByRole('list')).toHaveLength(1); + const items = editor.getByRole('listitem'); + expect(items).toHaveLength(1); + expect(items.nth(0)).toHaveTextContent('ParentChild'); + expect(items.nth(0).element().querySelectorAll('br')).toHaveLength(1); + }); + + test('list-type conversion takes priority over unlisting a mixed-type selection', async () => { + const richEditor = await renderRichEditorInDOM({ + value: '- Parent\n 1. Child', + }); + const editor = page.getByRole('textbox'); + + expect(editor.getByRole('list')).toHaveLength(2); + expect(editor.getByRole('list').nth(0).element().tagName).toBe('UL'); + expect(editor.getByRole('list').nth(1).element().tagName).toBe('OL'); + + await act(async () => { + selectContent(editor.element() as HTMLElement, 'Parent', 'Child'); + }); + await richEditor.insert({ type: 'list', data: { type: 'unordered' } }); + + expect(editor.getByRole('list')).toHaveLength(2); + expect(editor.getByRole('list').nth(0).element().tagName).toBe('UL'); + expect(editor.getByRole('list').nth(1).element().tagName).toBe('UL'); + expect(richEditor.getMarkdown()).toBe('- Parent\n - Child\n'); + }); + test('3 empty lines splits the lists', async () => { const editor1 = await renderRichEditorInDOM({ value: '',