Skip to content

Commit

Permalink
Rich Text Editor | Added HardBreak extension in editor for force line…
Browse files Browse the repository at this point in the history
… break (<br> tag) (#1517)

# Pull Request

## 🤨 Rationale

Fix for [Bug
2516897](https://dev.azure.com/ni/DevCentral/_workitems/edit/2516897):
Switching to a new line by pressing enter adds more spaces than
expected.

- Pressing enter in the editor for a new line adds a `Paragraph tag`
which has more space than expected between lines.
- Enabling [HardBreak ](https://tiptap.dev/api/nodes/hard-break) will
allow the user to switch to a new line by pressing `Shift/Ctrl/Cmd +
Enter` which will make minimal space between lines by adding `<br> tag`
instead of `Paragraph Tag`.

## 👩‍💻 Implementation

- Added [`HardBreak` ](https://tiptap.dev/api/nodes/hard-break) node in
Tiptap Editor.
- Enabled
[`newline`](https://github.com/markdown-it/markdown-it/blob/2b6cac25823af011ff3bc7628bc9b06e483c5a08/lib/rules_inline/newline.js#L27C8-L28C42)
and
[`escape`](https://github.com/markdown-it/markdown-it/blob/2b6cac25823af011ff3bc7628bc9b06e483c5a08/lib/rules_inline/escape.js#L1)
rules in MarkdownIt for `RichTextMarkdownParser` to parse the HardBreak
markdown syntax as per [CommonMark
Spec](https://spec.commonmark.org/0.30/#hard-line-breaks) to `<br> tag`.
- Added `hardBreak` node to `RichTextMarkdownSerializer` to serialize
the `<br> tag` to respective hard break markdown syntax.
- As we are using `prosemirror-markdown`, it serializes the `<br>` tag
to backslash with [line
ending](https://spec.commonmark.org/0.30/#hard-line-breaks) syntax as
per the [CommonMark Spec example
633](https://spec.commonmark.org/0.30/#example-633).

In Windows `Shift/Ctrl+ Enter` and in Mac `Cmd + Enter` will switch to a
new line (line break).

We are not exposing any format buttons to add line breaks (Hard Break),
The only way to break the line is by using the Keys mentioned above.



## 🧪 Testing

- Added unit tests and visual tests for the functionality.
- Manually tested and verified the functionality of the supported
features in Storybook build.

## ✅ Checklist

<!--- Review the list and put an x in the boxes that apply or ~~strike
through~~ around items that don't (along with an explanation). -->

- [x] I have updated the project documentation to reflect my changes or
determined no changes are needed.
  • Loading branch information
aagash-ni authored Sep 15, 2023
1 parent 2754ae5 commit 129af80
Show file tree
Hide file tree
Showing 12 changed files with 409 additions and 9 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Shift/Ctrl/Cmd + Enter will add line break in editor",
"packageName": "@ni/nimble-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/nimble-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"@tiptap/extension-paragraph": "^2.1.6",
"@tiptap/extension-placeholder": "^2.1.6",
"@tiptap/extension-text": "^2.1.6",
"@tiptap/extension-hard-break": "^2.1.6",
"@types/d3-array": "^3.0.4",
"@types/d3-random": "^3.0.1",
"@types/d3-scale": "^4.0.2",
Expand Down
2 changes: 2 additions & 0 deletions packages/nimble-components/src/rich-text/editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import Paragraph from '@tiptap/extension-paragraph';
import Placeholder from '@tiptap/extension-placeholder';
import type { PlaceholderOptions } from '@tiptap/extension-placeholder';
import Text from '@tiptap/extension-text';
import HardBreak from '@tiptap/extension-hard-break';
import { template } from './template';
import { styles } from './styles';
import type { ToggleButton } from '../../toggle-button';
Expand Down Expand Up @@ -356,6 +357,7 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern {
placeholder: '',
showOnlyWhenEditable: false
}),
HardBreak,
customLink.configure({
// HTMLAttribute cannot be in camelCase as we want to match it with the name in Tiptap
// eslint-disable-next-line @typescript-eslint/naming-convention
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ export class RichTextEditorPageObject {
await waitForUpdatesAsync();
}

public async pressShiftEnterKeysInEditor(): Promise<void> {
const editor = this.getTiptapEditor();
const shiftEnterEvent = new KeyboardEvent('keydown', {
key: keyEnter,
shiftKey: true,
bubbles: true,
cancelable: true
});
editor!.dispatchEvent(shiftEnterEvent);
await waitForUpdatesAsync();
}

public async pressTabKeyInEditor(): Promise<void> {
const editor = this.getTiptapEditor();
const event = new KeyboardEvent('keydown', {
Expand Down Expand Up @@ -117,6 +129,13 @@ export class RichTextEditorPageObject {
}

public async setEditorTextContent(value: string): Promise<void> {
const lastElement = this.getEditorLastChildElement();
const textNode = document.createTextNode(value);
lastElement.parentElement!.appendChild(textNode);
await waitForUpdatesAsync();
}

public async replaceEditorContent(value: string): Promise<void> {
const lastElement = this.getEditorLastChildElement();
lastElement.parentElement!.textContent = value;
await waitForUpdatesAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,26 @@ longWordContentInMobileWidth.play = (): void => {
);
};

const newLineWithForceLineBreakContent = `
This is a line 1\\
This line enters new line using hardbreak <br> tag
This line enters new line in paragraph tag
1. Point 1
* Sub point 1\\
Hard break sub point content
`;

export const newLineWithForceLineBreakInMobileWidth: StoryFn = createStory(mobileWidthComponent);
newLineWithForceLineBreakInMobileWidth.play = (): void => {
document
.querySelector('nimble-rich-text-editor')!
.setMarkdown(newLineWithForceLineBreakContent);
};

export const longLinkInMobileWidth: StoryFn = createStory(mobileWidthComponent);
longLinkInMobileWidth.play = (): void => {
document
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,15 @@ describe('RichTextEditor', () => {
});

describe('rich text formatting options to its respective HTML elements', () => {
it('should have "br" tag name when clicking shift + enter', async () => {
await pageObject.setEditorTextContent('Plain text 1');
await pageObject.pressShiftEnterKeysInEditor();
await pageObject.setEditorTextContent('Plain text 2');
await pageObject.pressShiftEnterKeysInEditor();
await pageObject.setEditorTextContent('Plain text 3');
expect(pageObject.getEditorTagNames()).toEqual(['P', 'BR', 'BR']);
});

it('should have "strong" tag name for bold button click', async () => {
await pageObject.clickFooterButton(ToolbarButton.bold);
await pageObject.setEditorTextContent('bold');
Expand All @@ -357,6 +366,20 @@ describe('RichTextEditor', () => {
expect(pageObject.getEditorLeafContents()).toEqual(['bold']);
});

it('should have br tag name when pressing shift + Enter with bold content', async () => {
await pageObject.clickFooterButton(ToolbarButton.bold);
await pageObject.setEditorTextContent('bold1');
await pageObject.pressShiftEnterKeysInEditor();
await pageObject.setEditorTextContent('bold after hard break');

expect(pageObject.getEditorTagNames()).toEqual([
'P',
'STRONG',
'BR',
'STRONG'
]);
});

it('should have "em" tag name for italics button click', async () => {
await pageObject.clickFooterButton(ToolbarButton.italics);
await pageObject.setEditorTextContent('italics');
Expand All @@ -365,6 +388,20 @@ describe('RichTextEditor', () => {
expect(pageObject.getEditorLeafContents()).toEqual(['italics']);
});

it('should have br tag name when pressing shift + Enter with Italics content', async () => {
await pageObject.clickFooterButton(ToolbarButton.italics);
await pageObject.setEditorTextContent('italics1');
await pageObject.pressShiftEnterKeysInEditor();
await pageObject.setEditorTextContent('italics after hard break');

expect(pageObject.getEditorTagNames()).toEqual([
'P',
'EM',
'BR',
'EM'
]);
});

it('should have "ol" tag name for numbered list button click', async () => {
await pageObject.setEditorTextContent('numbered list');
await pageObject.clickFooterButton(ToolbarButton.numberedList);
Expand All @@ -375,6 +412,22 @@ describe('RichTextEditor', () => {
]);
});

it('should have br tag name when pressing shift + Enter with numbered list content', async () => {
await pageObject.setEditorTextContent('numbered list1');
await pageObject.clickFooterButton(ToolbarButton.numberedList);
await pageObject.pressShiftEnterKeysInEditor();
await pageObject.setEditorTextContent(
'Hard break in first level of numbered list'
);

expect(pageObject.getEditorTagNames()).toEqual([
'OL',
'LI',
'P',
'BR'
]);
});

it('should have multiple "ol" tag names for numbered list button click', async () => {
await pageObject.setEditorTextContent('numbered list 1');
await pageObject.clickFooterButton(ToolbarButton.numberedList);
Expand Down Expand Up @@ -418,6 +471,25 @@ describe('RichTextEditor', () => {
).toBeTrue();
});

it('should have br tag name when pressing shift + Enter with nested numbered lists content', async () => {
await pageObject.setEditorTextContent('List');
await pageObject.clickFooterButton(ToolbarButton.numberedList);
await pageObject.pressEnterKeyInEditor();
await pageObject.pressTabKeyInEditor();
await pageObject.pressShiftEnterKeysInEditor();
await pageObject.setEditorTextContent('Hard break in Nested list');

expect(pageObject.getEditorTagNames()).toEqual([
'OL',
'LI',
'P',
'OL',
'LI',
'P',
'BR'
]);
});

it('should have "ol" tag names for numbered lists when clicking "tab" to make it nested and "shift+Tab" to make it usual list', async () => {
await pageObject.setEditorTextContent('List');
await pageObject.clickFooterButton(ToolbarButton.numberedList);
Expand Down Expand Up @@ -488,6 +560,22 @@ describe('RichTextEditor', () => {
expect(pageObject.getEditorLeafContents()).toEqual(['Bullet List']);
});

it('should have br tag name when pressing shift + Enter with bulleted list content', async () => {
await pageObject.setEditorTextContent('Bulleted List 1');
await pageObject.clickFooterButton(ToolbarButton.bulletList);
await pageObject.pressShiftEnterKeysInEditor();
await pageObject.setEditorTextContent(
'Hard break in first level of bulleted List'
);

expect(pageObject.getEditorTagNames()).toEqual([
'UL',
'LI',
'P',
'BR'
]);
});

it('should have multiple "ul" tag names for bullet list button click', async () => {
await pageObject.setEditorTextContent('Bullet List 1');
await pageObject.clickFooterButton(ToolbarButton.bulletList);
Expand Down Expand Up @@ -531,6 +619,25 @@ describe('RichTextEditor', () => {
).toBeTrue();
});

it('should have br tag name when pressing shift + Enter with nested bulleted lists content', async () => {
await pageObject.setEditorTextContent('List');
await pageObject.clickFooterButton(ToolbarButton.bulletList);
await pageObject.pressEnterKeyInEditor();
await pageObject.pressTabKeyInEditor();
await pageObject.pressShiftEnterKeysInEditor();
await pageObject.setEditorTextContent('Nested List');

expect(pageObject.getEditorTagNames()).toEqual([
'UL',
'LI',
'P',
'UL',
'LI',
'P',
'BR'
]);
});

it('should have "ul" tag name for bullet list and "ol" tag name for nested numbered list', async () => {
await pageObject.setEditorTextContent('Bullet List');
await pageObject.clickFooterButton(ToolbarButton.bulletList);
Expand Down Expand Up @@ -1074,6 +1181,11 @@ describe('RichTextEditor', () => {
expect(element.getMarkdown()).toBe('new markdown string');
});

it('setting an markdown with hard break syntax should have respective br tag', () => {
element.setMarkdown('markdown\\\nstring');
expect(pageObject.getEditorTagNames()).toEqual(['P', 'BR']);
});

describe('Should return respective markdown when supported rich text formatting options from markdown string is assigned', () => {
beforeEach(async () => {
await connect();
Expand Down Expand Up @@ -1266,6 +1378,70 @@ describe('RichTextEditor', () => {
}
});

describe('`getMarkdown` with hard break backslashes should be same immediately after `setMarkdown`', () => {
const r = String.raw;
const hardBreakMarkdownStrings: { name: string, value: string }[] = [
{
name: 'bold and italics',
value: r`**bold**\
*Italics*`
},
{
name: 'two first level bulleted list items',
value: r`* list\
hard break content
* list`
},
{
name: 'two first level bulleted list items and with nested list',
value: r`* list\
hard break content
* list
* nested list\
nested hard break content`
},
{
name: 'two first level numbered list items',
value: r`1. list\
hard break content
2. list`
},
{
name: 'two first level numbered list items and with nested list',
value: r`1. list\
hard break content
2. list
1. nested list\
nested hard break content`
}
];

const focused: string[] = [];
const disabled: string[] = [];
for (const value of hardBreakMarkdownStrings) {
const specType = getSpecTypeByNamedList(value, focused, disabled);
specType(
`markdown string with hard break in "${value.name}" returns as same without any change`,
// eslint-disable-next-line @typescript-eslint/no-loop-func
async () => {
element.setMarkdown(value.value);

await connect();

expect(element.getMarkdown()).toBe(value.value);

await disconnect();
}
);
}
});

describe('Should return markdown without any changes when various wacky string values are assigned', () => {
const focused: string[] = [];
const disabled: string[] = [];
Expand Down Expand Up @@ -1479,10 +1655,10 @@ describe('RichTextEditor', () => {
it('should initialize "empty" to true and set false when there is content', async () => {
expect(element.empty).toBeTrue();

await pageObject.setEditorTextContent('not empty');
await pageObject.replaceEditorContent('not empty');
expect(element.empty).toBeFalse();

await pageObject.setEditorTextContent('');
await pageObject.replaceEditorContent('');
expect(element.empty).toBeTrue();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ export class RichTextMarkdownParser {
const supportedTokenizerRules = zeroTokenizerConfiguration.enable([
'emphasis',
'list',
'escape',
'autolink',
'escape'
'newline'
]);

supportedTokenizerRules.validateLink = href => /^https?:\/\//i.test(href);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export class RichTextMarkdownSerializer {
orderedList: orderedListNode,
doc: defaultMarkdownSerializer.nodes.doc!,
paragraph: defaultMarkdownSerializer.nodes.paragraph!,
text: defaultMarkdownSerializer.nodes.text!
text: defaultMarkdownSerializer.nodes.text!,
hardBreak: defaultMarkdownSerializer.nodes.hard_break!
};
const marks = {
italic: defaultMarkdownSerializer.marks.em!,
Expand Down
Loading

0 comments on commit 129af80

Please sign in to comment.