Skip to content
Merged
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
957 changes: 481 additions & 476 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table';

import { ImageNode } from './plugins/Image/ImageNode';
import { FormattingNode } from './plugins/Markdown/nodes/FormattingNode';
import { RawNode } from './plugins/Markdown/nodes/RawNode';
import { RichEditorContent, RichEditorContentProps } from './RichEditorContent';
import { theme } from './theme/RichEditor';
Expand All @@ -29,7 +28,6 @@ export const RichEditor = memo((props: RichEditorContentProps) => {
nodes: [
// App specific nodes
RawNode,
FormattingNode,
ImageNode,

// Plugins
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ import { ContextMenuPlugin } from './plugins/ContextMenu/ContextMenuPlugin';
import { EditorPanelPlugin } from './plugins/EditorPanelPlugin/EditorPanelPlugin';
import { DropFilesPlugin } from './plugins/Files/DropFilesPlugin';
import { FilesPlugin } from './plugins/Files/FilesPlugin';
import { FormattingPlugin } from './plugins/Formatting/FormattingPlugin';
import { HighlightingPlugin } from './plugins/HighlightingPlugin/HighlightingPlugin';
import ImagesPlugin from './plugins/Image/ImagesPlugin';
import { KeyboardControlsPlugin } from './plugins/KeyboardControlsPlugin/KeyboardControlsPlugin';
import { LinkClickHandlerPlugin } from './plugins/LinkClickHandlerPlugin';
import {
MarkdownSerializePlugin,
Expand Down Expand Up @@ -113,7 +113,7 @@ export const RichEditorContent = ({
<RichTextContainer {...props} placeholder={placeholder} />
<MarkdownSerializePlugin value={value} onChanged={onChanged} />
<MarkdownShortcutPlugin />
<FormattingPlugin />
<KeyboardControlsPlugin />
<ImagesPlugin />
<CodeHighlightPlugin />
<LinkPlugin />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { renderRichEditor } from '../utils/renderRichEditor';

test('Editor is not editable in readonly mode', async () => {
const user = userEvent.setup();
await renderRichEditor({
value: '# Hello',
isReadOnly: true,
});

expect(screen.getByRole('textbox')).toHaveAttribute('contenteditable', 'false');

// Cannot enter text
await user.click(screen.getByRole('heading'));
await user.keyboard('Some text');

expect(screen.getByRole('textbox')).toHaveTextContent('Hello');
expect(screen.getByRole('textbox')).not.toHaveTextContent('Some text');
});

test('ReadOnly editor is not editable while the other editor remains editable', async () => {
const user = userEvent.setup();
await renderRichEditor({ value: 'Editable text' });
await renderRichEditor({ value: 'ReadOnly text', isReadOnly: true });

const textBoxes = screen.getAllByRole('textbox');
expect(textBoxes).toHaveLength(2);

expect(textBoxes[0]).toHaveAttribute('contenteditable', 'true');
expect(textBoxes[1]).toHaveAttribute('contenteditable', 'false');

// editorA is editable — editing works
await user.click(within(textBoxes[0]).getByText('Editable text'));
await user.keyboard('New text ');

expect(textBoxes[0]).toHaveTextContent('New text Editable text');

// editorB is readOnly — editing must be ignored
await user.click(within(textBoxes[1]).getByText('ReadOnly text'));
await user.keyboard('Some text');

expect(textBoxes[1]).toHaveTextContent('ReadOnly text');
expect(textBoxes[1]).not.toHaveTextContent('Some text');
expect(textBoxes[1]).toHaveAttribute('contenteditable', 'false');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { screen, within } from '@testing-library/react';

import { renderRichEditor } from './utils/renderRichEditor';
import { textFormatClasses } from './utils/richEditorFixtures';
import { selectContent } from './utils/utils';

test('Formatting text in one editor does not affect the other editor', async () => {
const editorA = await renderRichEditor({ value: 'Big text' });
const editorB = await renderRichEditor({ value: 'Small text' });

const editorContainers = screen.getAllByRole('textbox');
expect(editorContainers).toHaveLength(2);

// initial state
expect(editorContainers[0]).toHaveTextContent('Big text');
expect(editorContainers[1]).toHaveTextContent('Small text');

// apply italic in editorA
selectContent(editorContainers[0], 'Big text');
await editorA.format('italic');
expect(within(editorContainers[0]).getByRole('emphasis')).toHaveTextContent(
'Big text',
);
expect(within(editorContainers[1]).queryByRole('emphasis')).not.toBeInTheDocument();

// apply strikethrough in editorB
selectContent(editorContainers[1], 'Small text');
await editorB.format('strikethrough');
expect(within(editorContainers[1]).getByText('Small text')).toHaveClass(
textFormatClasses.strikethrough,
);
expect(within(editorContainers[1]).getByText('Small text')).not.toHaveClass(
textFormatClasses.italic,
);

// No changes in editorA
expect(within(editorContainers[0]).getByText('Big text')).toHaveClass(
textFormatClasses.italic,
);
expect(within(editorContainers[0]).getByText('Big text')).not.toHaveClass(
textFormatClasses.strikethrough,
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ import path from 'path';
import { screen, within } from '@testing-library/react';

import { renderRichEditor } from './utils/renderRichEditor';
import { textFormatClasses } from './utils/richEditorFixtures';

test('Editor updates when value changes', async () => {
const editor = await renderRichEditor({ value: `# Big text` });

expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Big text');

// Run component rerender with new value
await editor.rerender({ value: `### Not so big text` });

expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent(
'Not so big text',
);

// The old header was removed
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument();
});

test('Renders markdown correctly', async () => {
const markdown = readFileSync(
Expand All @@ -18,10 +35,13 @@ test('Renders markdown correctly', async () => {
expect(paragraph).toHaveTextContent(
'This is a regular paragraph with bold text, italic text, strikethrough text.',
);
expect(paragraph.querySelector('b')).toHaveTextContent('bold text');
expect(within(paragraph).getByRole('emphasis')).toHaveTextContent('italic text');
expect(within(paragraph).getByRole('deletion')).toHaveTextContent(
'strikethrough text',

expect(within(paragraph).getByText('bold text')).toHaveClass(textFormatClasses.bold);
expect(within(paragraph).getByText('italic text')).toHaveClass(
textFormatClasses.italic,
);
expect(within(paragraph).getByText('strikethrough text')).toHaveClass(
textFormatClasses.strikethrough,
);

// Horizontal rule
Expand Down Expand Up @@ -102,63 +122,3 @@ test('Renders markdown correctly', async () => {
expect(within(rows[1]).getByRole('cell', { name: 'Alice' })).toBeInTheDocument();
expect(within(rows[1]).getByRole('cell', { name: 'Admin' })).toBeInTheDocument();
});

test('Renders a checklist with checked and unchecked items', async () => {
await renderRichEditor({
value: `- [x] First item
- [ ] Nested item
- [x] Deep nested item
- [ ] Second item`,
});

const checkboxes = within(screen.getByRole('textbox')).getAllByRole('checkbox');
const [firstItem, nestedItem, deepNestedItem, secondItem] = checkboxes;
expect(checkboxes).toHaveLength(4);

// First item contains nested list
expect(firstItem).toHaveTextContent('First item');
expect(firstItem).toBeChecked();

const [firstItemNestedList] = within(firstItem).getAllByRole('list');
expect(firstItemNestedList).toContainElement(nestedItem);

// Nested item contains deep nested list
expect(nestedItem).toHaveTextContent('Nested item');
expect(nestedItem).not.toBeChecked();

const nestedItemList = within(nestedItem).getByRole('list');
expect(nestedItemList).toContainElement(deepNestedItem);

// Deep nested item
expect(deepNestedItem).toHaveTextContent('Deep nested item');
expect(deepNestedItem).toBeChecked();

// Second item
expect(secondItem).toHaveTextContent('Second item');
expect(secondItem).not.toBeChecked();
});

test('Renders a mixed list with regular and checkbox items correctly', async () => {
await renderRichEditor({
value: `- [x] First item
- Nested simple item
- [ ] Second item`,
});

const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(2);
const [first, second] = checkboxes;

expect(first).toHaveTextContent('First item');
expect(first).toBeChecked();

expect(second).toHaveTextContent('Second item');
expect(second).not.toBeChecked();

// Nested item - regular, not checkbox
const nestedList = within(first).getByRole('list');
const nestedItem = within(nestedList).getByRole('listitem');

expect(nestedItem).toHaveTextContent('Nested simple item');
expect(within(nestedItem).queryByRole('checkbox')).toBeNull();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { renderRichEditor } from '../utils/renderRichEditor';
import { textFormatClasses } from '../utils/richEditorFixtures';
import { selectContent, selectText } from '../utils/utils';

test('Text formatting can be toggled', async () => {
const content = 'Hello, my dear friends!';
const richEditor = await renderRichEditor({ value: content });

const editor = screen.getByRole('textbox');

// Apply strikethrough
selectContent(editor, content);
await richEditor.format('strikethrough');
expect(within(editor).getByText(content)).toHaveClass(
textFormatClasses.strikethrough,
);

// Remove strikethrough
selectContent(editor, content);
await richEditor.format('strikethrough');
expect(within(editor).getByText(content)).not.toHaveClass(
textFormatClasses.strikethrough,
);

// Apply italic
selectContent(editor, content);
await richEditor.format('italic');
expect(within(editor).getByText(content)).toHaveClass(textFormatClasses.italic);

// Remove italic
selectContent(editor, content);
await richEditor.format('italic');
expect(within(editor).getByText(content)).not.toHaveClass(textFormatClasses.italic);
});

test('One text node can have different formatting at once', async () => {
const content = 'Hello, my dear friends!';
const richEditor = await renderRichEditor({ value: content });

const editor = screen.getByRole('textbox');

selectContent(editor, content);
await richEditor.format('italic');
await richEditor.format('bold');
await richEditor.format('strikethrough');

expect(within(editor).getByText(content)).toHaveClass(textFormatClasses.bold);
expect(within(editor).getByText(content)).toHaveClass(textFormatClasses.italic);
expect(within(editor).getByText(content)).toHaveClass(
textFormatClasses.strikethrough,
);

// Removes bold without breaking others formatting
selectContent(editor, content);
await richEditor.format('bold');

expect(within(editor).getByText(content)).not.toHaveClass(textFormatClasses.bold);
expect(within(editor).getByText(content)).toHaveClass(textFormatClasses.italic);
expect(within(editor).getByText(content)).toHaveClass(
textFormatClasses.strikethrough,
);
});

test('Formatting can be applied to a part of text', async () => {
const user = userEvent.setup();
const richEditor = await renderRichEditor({ value: 'Hello, my dear friends!' });

const editor = screen.getByRole('textbox');
await user.click(editor);

// Apply formatting
selectText(editor, 'friends');
await richEditor.format('italic');

expect(within(editor).getByText('friends')).toHaveClass(textFormatClasses.italic);
});

describe('Formatting via keyboard shortcuts', () => {
const cases: {
title: string;
shortcut: string;
formatClass: RegExp;
}[] = [
{
title: 'Ctrl+I must toggle Italic format',
shortcut: '{Control>}i{/Control}',
formatClass: textFormatClasses.italic,
},
{
title: 'Ctrl+B must toggle Bold format',
shortcut: '{Control>}b{/Control}',
formatClass: textFormatClasses.bold,
},
];

cases.forEach(({ title, shortcut, formatClass }) =>
test(title, async () => {
const user = userEvent.setup();
await renderRichEditor({
value: 'The quick brown fox jumps over the lazy dog',
});

const editor = screen.getByRole('textbox');
await user.click(editor);

const selectionText = 'quick brown fox';

// Apply formatting
selectText(editor, selectionText);
await user.keyboard(shortcut);
expect(within(editor).getByText(selectionText)).toHaveClass(formatClass);

// Remove formatting
selectText(editor, selectionText);
await user.keyboard(shortcut);
expect(
within(editor).getByText(selectionText, { exact: false }),
).not.toHaveClass(formatClass);
}),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { screen, within } from '@testing-library/react';

import { renderRichEditor } from '../utils/renderRichEditor';
import { selectContent } from '../utils/utils';

test('Updates heading level correctly', async () => {
const content = 'Hello, my dear friends!';
const richEditor = await renderRichEditor({ value: content });

const editor = screen.getByRole('textbox');
selectContent(editor, content);

// Plain text becomes heading
await richEditor.insert({ type: 'heading', data: { level: 1 } });
expect(within(editor).getByRole('heading', { level: 1 })).toHaveTextContent(content);

// Heading level is updated when different level applied
await richEditor.insert({ type: 'heading', data: { level: 3 } });

expect(within(editor).getByRole('heading', { level: 3 })).toHaveTextContent(content);
expect(within(editor).queryByRole('heading', { level: 1 })).not.toBeInTheDocument();

// Heading reverts to paragraph when same level applied again
await richEditor.insert({ type: 'heading', data: { level: 3 } });

expect(within(editor).queryByRole('heading')).not.toBeInTheDocument();
expect(within(editor).getByText(content)).toBeInTheDocument();

// After editing the screen should contain one paragraph with a text
const editorChildren = editor.children;
expect(editorChildren).toHaveLength(1);
expect(editorChildren[0]).toHaveRole('paragraph');
expect(editorChildren[0]).toHaveTextContent(content);
});
Loading