Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -136,6 +137,7 @@ export const RichEditorContent = ({
<DropFilesPlugin />
<EditorPanelPlugin />
<HistoryPlugin />
<ListFormattingPlugin />
<ListPlugin />
<CheckListPlugin />
<TablePlugin />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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: '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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();
};
Loading
Loading