Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { useEffect } from 'react';
import {
$createParagraphNode,
$createTextNode,
$findMatchingParent,
$getRoot,
$getSelection,
$isBlockElementNode,
$isParagraphNode,
$isRangeSelection,
$isRootNode,
$isTextNode,
CONTROLLED_TEXT_INSERTION_COMMAND,
Expand All @@ -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';
Expand All @@ -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
*/
Expand Down Expand Up @@ -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<ListNode>();
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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
};