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