-
Notifications
You must be signed in to change notification settings - Fork 1
fix: formatting buttons applies to entire paragraph #338
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
vitonsky
merged 24 commits into
master
from
337-formatting-buttons-applies-to-entire-paragraph
Jul 6, 2026
Merged
Changes from 15 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
ffd5bb9
test: organize tests
vitonsky 45bc10e
fix: use native formatting
vitonsky c46fa9f
feat: normalize ast
vitonsky 1577447
test: formatting in table cell
vitonsky a8d05d0
refactor: universal formatting lifter
vitonsky 6cf63a3
refactor: move remark plugins
vitonsky a25ef10
refactor: organize nodes lifting
vitonsky 092c237
test: cases for format lifting
vitonsky 007d282
test: formatting in blocks
vitonsky b281ecb
fix: group all siblings with the equal formatting
vitonsky 22ca247
test: update rich editor tests
vitonsky 784b4a7
chore: move rich editor tests
vitonsky 709e3de
chore: organize tests
vitonsky b7cd3d0
chore: organize tests
vitonsky 76ae1da
chore: organize tests
vitonsky 179447c
test: query correct element
vitonsky 54f4300
chore: remove FormattingNode plugin
vitonsky ba4ce15
feat: support inline code with formatting
vitonsky e2fda43
test: cases for sync context
vitonsky f2e941b
test: add example for async context
vitonsky 3dd23ff
fix: nested formatting
vitonsky b39d1c4
chore: add await
vitonsky 17857e2
chore: clean code
vitonsky 17a409a
fix: optimize AST for heading
vitonsky File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
47 changes: 47 additions & 0 deletions
47
packages/app/src/features/NoteEditor/RichEditor/__tests__/interactions/readonly.dom.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); |
43 changes: 43 additions & 0 deletions
43
packages/app/src/features/NoteEditor/RichEditor/__tests__/isolation.dom.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
122 changes: 122 additions & 0 deletions
122
packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/formatting.dom.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| 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); | ||
|
|
||
| // Cancel formatting | ||
| selectText(editor, selectionText); | ||
| await user.keyboard(shortcut); | ||
| expect(editor).not.toHaveClass(formatClass); | ||
| }), | ||
| ); | ||
| }); | ||
34 changes: 34 additions & 0 deletions
34
packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/heading.dom.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.