From 129af8084eab1d47df84d6ee7281fd28bbaef8ec Mon Sep 17 00:00:00 2001
From: aagash-ni <123377167+aagash-ni@users.noreply.github.com>
Date: Sat, 16 Sep 2023 02:27:49 +0530
Subject: [PATCH] Rich Text Editor | Added HardBreak extension in editor for
force line break ( Hard Horizontal
tag) (#1517)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# 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 `
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 `
tag`.
- Added `hardBreak` node to `RichTextMarkdownSerializer` to serialize
the `
tag` to respective hard break markdown syntax.
- As we are using `prosemirror-markdown`, it serializes the `
` 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
- [x] I have updated the project documentation to reflect my changes or
determined no changes are needed.
---
...-90bda8e4-208e-4e0c-ae48-b3730a529c57.json | 7 +
package-lock.json | 13 ++
packages/nimble-components/package.json | 1 +
.../src/rich-text/editor/index.ts | 2 +
.../testing/rich-text-editor.pageobject.ts | 19 ++
.../tests/rich-text-editor-matrix.stories.ts | 20 ++
.../editor/tests/rich-text-editor.spec.ts | 180 +++++++++++++++++-
.../src/rich-text/models/markdown-parser.ts | 3 +-
.../rich-text/models/markdown-serializer.ts | 3 +-
.../models/tests/markdown-parser.spec.ts | 76 ++++++++
.../models/tests/markdown-serializer.spec.ts | 91 ++++++++-
.../src/rich-text/specs/README.md | 3 +
12 files changed, 409 insertions(+), 9 deletions(-)
create mode 100644 change/@ni-nimble-components-90bda8e4-208e-4e0c-ae48-b3730a529c57.json
diff --git a/change/@ni-nimble-components-90bda8e4-208e-4e0c-ae48-b3730a529c57.json b/change/@ni-nimble-components-90bda8e4-208e-4e0c-ae48-b3730a529c57.json
new file mode 100644
index 0000000000..0c8a655141
--- /dev/null
+++ b/change/@ni-nimble-components-90bda8e4-208e-4e0c-ae48-b3730a529c57.json
@@ -0,0 +1,7 @@
+{
+ "type": "patch",
+ "comment": "Shift/Ctrl/Cmd + Enter will add line break in editor",
+ "packageName": "@ni/nimble-components",
+ "email": "123377167+aagash-ni@users.noreply.github.com",
+ "dependentChangeType": "patch"
+}
diff --git a/package-lock.json b/package-lock.json
index f68a07b5c6..70d93315b6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10680,6 +10680,18 @@
"@tiptap/core": "^2.0.0"
}
},
+ "node_modules/@tiptap/extension-hard-break": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.1.8.tgz",
+ "integrity": "sha512-K86FTizvZu7779Gz2XigW1IxAjZXduyZ7w0ipwe+5QBa/Lh6Vfl9wa8TgV1lFAkC2VATsAa3aa36llMIDBgeew==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.0.0"
+ }
+ },
"node_modules/@tiptap/extension-history": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.1.6.tgz",
@@ -34753,6 +34765,7 @@
"@tiptap/extension-bold": "^2.1.6",
"@tiptap/extension-bullet-list": "^2.1.6",
"@tiptap/extension-document": "^2.1.6",
+ "@tiptap/extension-hard-break": "^2.1.6",
"@tiptap/extension-history": "^2.1.6",
"@tiptap/extension-italic": "^2.1.6",
"@tiptap/extension-link": "^2.1.6",
diff --git a/packages/nimble-components/package.json b/packages/nimble-components/package.json
index d79a0ab5b8..ecb8e93825 100644
--- a/packages/nimble-components/package.json
+++ b/packages/nimble-components/package.json
@@ -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",
diff --git a/packages/nimble-components/src/rich-text/editor/index.ts b/packages/nimble-components/src/rich-text/editor/index.ts
index c9b5337666..f0b3064d4a 100644
--- a/packages/nimble-components/src/rich-text/editor/index.ts
+++ b/packages/nimble-components/src/rich-text/editor/index.ts
@@ -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';
@@ -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
diff --git a/packages/nimble-components/src/rich-text/editor/testing/rich-text-editor.pageobject.ts b/packages/nimble-components/src/rich-text/editor/testing/rich-text-editor.pageobject.ts
index c953291676..8d700b4491 100644
--- a/packages/nimble-components/src/rich-text/editor/testing/rich-text-editor.pageobject.ts
+++ b/packages/nimble-components/src/rich-text/editor/testing/rich-text-editor.pageobject.ts
@@ -55,6 +55,18 @@ export class RichTextEditorPageObject {
await waitForUpdatesAsync();
}
+ public async pressShiftEnterKeysInEditor(): Promise
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
diff --git a/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor.spec.ts b/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor.spec.ts
index 9b55942546..db2472e34e 100644
--- a/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor.spec.ts
+++ b/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor.spec.ts
@@ -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');
@@ -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');
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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();
@@ -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[] = [];
@@ -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();
});
diff --git a/packages/nimble-components/src/rich-text/models/markdown-parser.ts b/packages/nimble-components/src/rich-text/models/markdown-parser.ts
index ce7b2aaea4..36d881e174 100644
--- a/packages/nimble-components/src/rich-text/models/markdown-parser.ts
+++ b/packages/nimble-components/src/rich-text/models/markdown-parser.ts
@@ -48,8 +48,9 @@ export class RichTextMarkdownParser {
const supportedTokenizerRules = zeroTokenizerConfiguration.enable([
'emphasis',
'list',
+ 'escape',
'autolink',
- 'escape'
+ 'newline'
]);
supportedTokenizerRules.validateLink = href => /^https?:\/\//i.test(href);
diff --git a/packages/nimble-components/src/rich-text/models/markdown-serializer.ts b/packages/nimble-components/src/rich-text/models/markdown-serializer.ts
index b6e8a145ef..12096c0f0b 100644
--- a/packages/nimble-components/src/rich-text/models/markdown-serializer.ts
+++ b/packages/nimble-components/src/rich-text/models/markdown-serializer.ts
@@ -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!,
diff --git a/packages/nimble-components/src/rich-text/models/tests/markdown-parser.spec.ts b/packages/nimble-components/src/rich-text/models/tests/markdown-parser.spec.ts
index c84da64367..87949d5e06 100644
--- a/packages/nimble-components/src/rich-text/models/tests/markdown-parser.spec.ts
+++ b/packages/nimble-components/src/rich-text/models/tests/markdown-parser.spec.ts
@@ -847,4 +847,80 @@ describe('Markdown parser', () => {
);
}
});
+
+ describe('Markdown string with hard break should have respective br tag when rendered', () => {
+ const focused: string[] = [];
+ const disabled: string[] = [];
+ const r = String.raw;
+ const markdownStringWithHardBreak: {
+ name: string,
+ value: string,
+ tags: string[]
+ }[] = [
+ {
+ name: 'bold and italics',
+ value: r`**bold**\
+*Italics*`,
+ tags: ['P', 'STRONG', 'BR', 'EM']
+ },
+ {
+ name: 'bold and back slash followed by italics',
+ value: r`**bold**\
+ \ *Italics*`,
+ tags: ['P', 'STRONG', 'BR', 'EM']
+ },
+ {
+ name: 'two first level bulleted list items',
+ value: r`* list\
+ hard break content
+
+* list`,
+ tags: ['UL', 'LI', 'P', 'BR', 'LI', 'P']
+ },
+ {
+ name: 'two first level bulleted list items and with nested list',
+ value: r`* list\
+ hard break content
+
+* list
+
+ * nested list\
+ nested hard break content`,
+ tags: ['UL', 'LI', 'P', 'BR', 'LI', 'P', 'UL', 'LI', 'P', 'BR']
+ },
+ {
+ name: 'two first level numbered list items',
+ value: r`1. list\
+ hard break content
+
+2. list`,
+ tags: ['OL', 'LI', 'P', 'BR', 'LI', 'P']
+ },
+ {
+ 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`,
+ tags: ['OL', 'LI', 'P', 'BR', 'LI', 'P', 'OL', 'LI', 'P', 'BR']
+ }
+ ];
+
+ for (const value of markdownStringWithHardBreak) {
+ const specType = getSpecTypeByNamedList(value, focused, disabled);
+ specType(
+ `should render br tag with "${value.name}"`,
+ // eslint-disable-next-line @typescript-eslint/no-loop-func
+ () => {
+ const doc = RichTextMarkdownParser.parseMarkdownToDOM(
+ value.value
+ );
+ expect(getTagsFromElement(doc)).toEqual(value.tags);
+ }
+ );
+ }
+ });
});
diff --git a/packages/nimble-components/src/rich-text/models/tests/markdown-serializer.spec.ts b/packages/nimble-components/src/rich-text/models/tests/markdown-serializer.spec.ts
index 85f9c43111..23fb586a61 100644
--- a/packages/nimble-components/src/rich-text/models/tests/markdown-serializer.spec.ts
+++ b/packages/nimble-components/src/rich-text/models/tests/markdown-serializer.spec.ts
@@ -2,6 +2,7 @@ import { Editor } from '@tiptap/core';
import Bold from '@tiptap/extension-bold';
import BulletList from '@tiptap/extension-bullet-list';
import Document from '@tiptap/extension-document';
+import HardBreak from '@tiptap/extension-hard-break';
import Italic from '@tiptap/extension-italic';
import ListItem from '@tiptap/extension-list-item';
import OrderedList from '@tiptap/extension-ordered-list';
@@ -24,6 +25,7 @@ describe('Markdown serializer', () => {
ListItem,
Bold,
Italic,
+ HardBreak,
Link.extend({
excludes: '_'
})
@@ -271,11 +273,6 @@ describe('Markdown serializer', () => {
plainText: 'CodeBlock'
},
{ name: 'Heading', html: 'Heading
', plainText: 'Heading' },
- {
- name: 'HardBreak',
- html: '
Break
Rule
Hard
Break
Numbered
list
Bulleted
list
list
hard break content
list
nested list
nested hard break content
list
hard break content
list
nested list
nested hard break content