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 0f2a54f60..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 @@ -27,6 +27,106 @@ 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('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'), + }); + 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(); @@ -180,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: '', 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..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 @@ -37,6 +37,87 @@ 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 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 43fa9480e..3cb086910 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -95,17 +95,6 @@ export const EditorPanelPlugin = () => { }); }, list({ type }) { - editor.update(() => { - const target = $getCursorNode(); - if (!target) return; - - if ($isRootNode(target)) { - 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/ListFormattingPlugin/$convertListToLines.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/ListFormattingPlugin/$convertListToLines.ts new file mode 100644 index 000000000..f9c34c57a --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/ListFormattingPlugin/$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/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; +}; 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