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..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 @@ -37,6 +37,144 @@ test('Converts an unordered list to an ordered list', async () => { expect(listItems[1]).toHaveTextContent('Second item'); }); +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 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); + 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 () => { + 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`, + }); + 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'); + const items = within(list).getAllByRole('listitem'); + expect(items).toHaveLength(2); + expect(items[0]).toHaveTextContent('Second item'); + expect(items[1]).toHaveTextContent('Third item'); + + // 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`, + }); + + 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).getAllByRole('paragraph'); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]).toHaveTextContent('1 2'); + expect(paragraphs[1]).toHaveTextContent('3'); +}); + 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 19eae8282..5eb632e95 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -2,9 +2,12 @@ import { useEffect } from 'react'; import { $createParagraphNode, $createTextNode, + $findMatchingParent, $getRoot, + $getSelection, $isBlockElementNode, $isParagraphNode, + $isRangeSelection, $isRootNode, $isTextNode, CONTROLLED_TEXT_INSERTION_COMMAND, @@ -13,9 +16,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, + ListNode, } from '@lexical/list'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'; @@ -26,8 +31,15 @@ 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 listTypeMap = { + checkbox: 'check', + ordered: 'number', + unordered: 'bullet', +}; + /** * Plugin to handle editor panel actions about formatting and nodes insertion */ @@ -88,11 +100,39 @@ 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 + 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 isAlreadyRequestedType = + listNodes.size > 0 && + Array.from(listNodes).every( + (list) => list.getListType() === listTypeMap[type], + ); + + if (!isAlreadyRequestedType) return; + + listNodes.forEach($convertListToParagraphs); + didConvert = true; + }); + + if (didConvert) return; + + // Ensure the editor is not empty editor.update(() => { const target = $getCursorNode(); if (!target) return; - if ($isRootNode(target)) { + if ($isRootNode(target) && target.getChildren().length === 0) { const paragraph = $createParagraphNode(); target.append(paragraph); paragraph.select(); 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..ffa1528dc --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/$convertListToParagraphs.ts @@ -0,0 +1,34 @@ +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(); +};