From f5dfad6fbce2701e9fabab100c7e4ebf17e61f50 Mon Sep 17 00:00:00 2001 From: Vivin Krishna <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Thu, 14 Sep 2023 22:21:12 +0530 Subject: [PATCH] Rich text editor & viewer | Support for absolute link (#1496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## 🤨 Rationale 1. Add absolute links in the editor by either pasting or manually entering a URL (HTTP/HTTPS). 2. The input and output for absolute links in Markdown follow the [AutoLink ](https://spec.commonmark.org/0.30/#autolink) format in the CommonMark flavor i.e. ``. 3. Links in the viewer will render as `nimble-anchor`. Part of https://github.com/ni/nimble/issues/1372 4. Links in the editor will render as `` as we have few constraints in rendering `nimble-anchor` in the editor. https://github.com/ni/nimble/issues/1516 ## 👩‍💻 Implementation #### Rich text editor: 1. Installed [@tiptap/extension-link](https://tiptap.dev/api/marks/link) to enable link in editor. 2. Configuring the following settings for the link in the editor: - Preventing users from opening a link in the editor by disabling the [`openOnClick`](https://tiptap.dev/api/marks/link#open-on-click) - Disallowing users from pasting a link to an already selected word by disabling [`linkOnPaste`](https://tiptap.dev/api/marks/link#link-on-paste), especially since hyperlinks are not supported for the initial pass. - Add Regex to [validate](https://tiptap.dev/api/marks/link#validate) that the entered/parsed URL conforms to the HTTPS/HTTP protocol. URLs not using HTTPS/HTTP will be displayed as plain text. 5. Adding custom link serializer for autolink in `markdown-serializer.ts`. 6. Preventing the application of additional formatting, such as bold or italics, to a link by using [`excludes: '_'`](https://prosemirror.net/docs/ref/#model.MarkSpec.excludes). This is because CommonMark's [autolink](https://spec.commonmark.org/0.30/#autolink) does not support the simultaneous application of another formatting within the link, making it impossible to render a specific part of the link in autolink markdowns, like ``. #### Rich text viewer (or markdown-parser): 1. Rendering links as `nimble-anchor` by updating the schema in `markdown-parser.ts`. 2. [Validate](https://markdown-it.github.io/markdown-it/#MarkdownIt.prototype.validateLink) if the links in the input markdown string are HTTPS/HTTP. 3. [Normalize the link text](https://markdown-it.github.io/markdown-it/#MarkdownIt.prototype.normalizeLinkText) to render the link as is, instead of updating the link text if it contains any encoded or non-ASCII characters. ## 🧪 Testing 1. rich-text-editor.spec.ts: 1. "Absolute link interactions in the editor" - These tests cover user interactions with the editor like typing or copying and pasting the links. This includes testing the `validate` in index.ts (only HTTP/HTTPS), assessing links within various nodes, and restricting the application of other marks (bold/italics) to the links within the editor. 2. "Absolute link markdown tests" - These tests ensure the parsed links are rendered as `` tags and not as `nimble-anchor` within the editor. This distinction arises because we have adjusted the schema of the link in the `markdown-parser.ts` to parse it as a `nimble-anchor`. It also covers `getMarkdown` is the same as `setMarkdown` for links similar to the tests pattern we followed for other marks and nodes. 2. markdown-parser.spec.ts: 1. "Absolute link" - These tests cover more in-depth of link formats that could possibly converted into links when parsed from a markdown string. It verifies `autolink`, `validateLink`, and `normalizeLinkText` in tokenizer rules and configurations set in the `markdown-parser.ts`. 3. markdown-serializer.spec.ts: 1. These tests ensure only the text content(not `href`) in the `` tag is serialized to autolink markdown string. 2. It also covers how other marks (bold/italics) are ignored when the link is wrapped within it. ## ✅ Checklist - [x] I have updated the project documentation to reflect my changes or determined no changes are needed. --------- Signed-off-by: Sai krishnan Perumal Signed-off-by: Sai krishnan Perumal <123591928+saikrishnan-ni@users.noreply.github.com> Co-authored-by: Sai krishnan Perumal Co-authored-by: Sai krishnan Perumal <123591928+saikrishnan-ni@users.noreply.github.com> --- ...-9f73b6f1-7567-4817-9f21-3c1c80bdd5c3.json | 7 + package-lock.json | 22 + packages/nimble-components/package.json | 1 + .../src/rich-text/editor/index.ts | 53 ++- .../src/rich-text/editor/styles.ts | 34 +- .../testing/rich-text-editor.pageobject.ts | 45 +- .../tests/rich-text-editor-matrix.stories.ts | 11 +- .../editor/tests/rich-text-editor.spec.ts | 354 +++++++++++++++ .../editor/tests/rich-text-editor.stories.ts | 14 +- .../src/rich-text/models/markdown-parser.ts | 54 ++- .../rich-text/models/markdown-serializer.ts | 23 +- .../models/testing/markdown-parser-utils.ts | 18 + .../models/tests/markdown-parser.spec.ts | 403 +++++++++++++++++- .../models/tests/markdown-serializer.spec.ts | 37 +- .../src/rich-text/viewer/styles.ts | 16 +- 15 files changed, 1045 insertions(+), 47 deletions(-) create mode 100644 change/@ni-nimble-components-9f73b6f1-7567-4817-9f21-3c1c80bdd5c3.json diff --git a/change/@ni-nimble-components-9f73b6f1-7567-4817-9f21-3c1c80bdd5c3.json b/change/@ni-nimble-components-9f73b6f1-7567-4817-9f21-3c1c80bdd5c3.json new file mode 100644 index 0000000000..3494da0727 --- /dev/null +++ b/change/@ni-nimble-components-9f73b6f1-7567-4817-9f21-3c1c80bdd5c3.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Support for absolute links in rich text components", + "packageName": "@ni/nimble-components", + "email": "123377523+vivinkrishna-ni@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/package-lock.json b/package-lock.json index 2fde7003f4..607f22504f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10705,6 +10705,22 @@ "@tiptap/core": "^2.0.0" } }, + "node_modules/@tiptap/extension-link": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.1.7.tgz", + "integrity": "sha512-NDfoMCkThng1B530pMg5y69+eWoghZXK2uCntrJH7Rs8jNeGMyt9wGIOd7N8ZYz0oJ2ZYKzZjS0RANdBDS17DA==", + "dependencies": { + "linkifyjs": "^4.1.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0", + "@tiptap/pm": "^2.0.0" + } + }, "node_modules/@tiptap/extension-list-item": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.1.6.tgz", @@ -22663,6 +22679,11 @@ "uc.micro": "^1.0.1" } }, + "node_modules/linkifyjs": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.1.tgz", + "integrity": "sha512-zFN/CTVmbcVef+WaDXT63dNzzkfRBKT1j464NJQkV7iSgJU0sLBus9W0HBwnXK13/hf168pbrx/V/bjEHOXNHA==" + }, "node_modules/liquidjs": { "version": "9.43.0", "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-9.43.0.tgz", @@ -34734,6 +34755,7 @@ "@tiptap/extension-document": "^2.1.6", "@tiptap/extension-history": "^2.1.6", "@tiptap/extension-italic": "^2.1.6", + "@tiptap/extension-link": "^2.1.6", "@tiptap/extension-list-item": "^2.1.6", "@tiptap/extension-ordered-list": "^2.1.6", "@tiptap/extension-paragraph": "^2.1.6", diff --git a/packages/nimble-components/package.json b/packages/nimble-components/package.json index ec7043e730..023fc855d1 100644 --- a/packages/nimble-components/package.json +++ b/packages/nimble-components/package.json @@ -70,6 +70,7 @@ "@tiptap/extension-document": "^2.1.6", "@tiptap/extension-history": "^2.1.6", "@tiptap/extension-italic": "^2.1.6", + "@tiptap/extension-link": "^2.1.6", "@tiptap/extension-list-item": "^2.1.6", "@tiptap/extension-ordered-list": "^2.1.6", "@tiptap/extension-paragraph": "^2.1.6", diff --git a/packages/nimble-components/src/rich-text/editor/index.ts b/packages/nimble-components/src/rich-text/editor/index.ts index d32f23b716..c9b5337666 100644 --- a/packages/nimble-components/src/rich-text/editor/index.ts +++ b/packages/nimble-components/src/rich-text/editor/index.ts @@ -11,13 +11,15 @@ import { findParentNode, isList, AnyExtension, - Extension + Extension, + Mark } from '@tiptap/core'; import Bold from '@tiptap/extension-bold'; import BulletList from '@tiptap/extension-bullet-list'; import Document from '@tiptap/extension-document'; import History from '@tiptap/extension-history'; import Italic from '@tiptap/extension-italic'; +import Link, { LinkOptions } from '@tiptap/extension-link'; import ListItem from '@tiptap/extension-list-item'; import OrderedList from '@tiptap/extension-ordered-list'; import Paragraph from '@tiptap/extension-paragraph'; @@ -31,6 +33,7 @@ import { TipTapNodeName } from './types'; import type { ErrorPattern } from '../../patterns/error/types'; import { RichTextMarkdownParser } from '../models/markdown-parser'; import { RichTextMarkdownSerializer } from '../models/markdown-serializer'; +import { anchorTag } from '../../anchor'; declare global { interface HTMLElementTagNameMap { @@ -330,6 +333,8 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { } private createTiptapEditor(): Editor { + const customLink = this.getCustomLinkExtension(); + /** * For more information on the extensions for the supported formatting options, refer to the links below. * Tiptap marks: https://tiptap.dev/api/marks @@ -350,11 +355,57 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { Placeholder.configure({ placeholder: '', showOnlyWhenEditable: false + }), + 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 + HTMLAttributes: { + rel: 'noopener noreferrer', + target: null + }, + autolink: true, + openOnClick: false, + // linkOnPaste can be enabled when hyperlink support added + // See: https://github.com/ni/nimble/issues/1527 + linkOnPaste: false, + validate: href => /^https?:\/\//i.test(href) }) ] }); } + /** + * Extending the default link mark schema defined in the TipTap. + * + * "excludes": https://prosemirror.net/docs/ref/#model.MarkSpec.excludes + * "inclusive": https://prosemirror.net/docs/ref/#model.MarkSpec.inclusive + * "parseHTML": https://tiptap.dev/guide/custom-extensions#parse-html + * "renderHTML": https://tiptap.dev/guide/custom-extensions/#render-html + */ + private getCustomLinkExtension(): Mark { + return Link.extend({ + // Excludes can be removed/enabled when hyperlink support added + // See: https://github.com/ni/nimble/issues/1527 + excludes: '_', + // Inclusive can be updated when hyperlink support added + // See: https://github.com/ni/nimble/issues/1527 + inclusive: false, + parseHTML() { + // To load the `nimble-anchor` from the HTML parsed content by markdown-parser as links in the + // Tiptap editor, the `parseHTML` of Link extension should return `anchorTag`. This is because the + // link mark schema in `markdown-parser.ts` file uses `` as anchor tag and not ``. + return [{ tag: anchorTag }]; + }, + // HTMLAttribute cannot be in camelCase as we want to match it with the name in Tiptap + // eslint-disable-next-line @typescript-eslint/naming-convention + renderHTML({ HTMLAttributes }) { + // The below 'a' tag should be replaced with 'nimble-anchor' once the below issue is fixed. + // https://github.com/ni/nimble/issues/1516 + return ['a', HTMLAttributes]; + } + }); + } + /** * This function takes the Fragment from parseMarkdownToDOM function and return the serialized string using XMLSerializer */ diff --git a/packages/nimble-components/src/rich-text/editor/styles.ts b/packages/nimble-components/src/rich-text/editor/styles.ts index 0b890ee0a9..4d8bc3789e 100644 --- a/packages/nimble-components/src/rich-text/editor/styles.ts +++ b/packages/nimble-components/src/rich-text/editor/styles.ts @@ -12,7 +12,8 @@ import { failColor, iconSize, smallDelay, - standardPadding + standardPadding, + linkFontColor } from '../../theme-provider/design-tokens'; import { styles as errorStyles } from '../../patterns/error/styles'; @@ -188,6 +189,37 @@ export const styles = css` color: ${controlLabelDisabledFontColor}; } + ${ + /** + * Custom anchor stylings can be removed once leveraging 'nimble-anchor' is supported. + * See: https://github.com/ni/nimble/issues/1516 + */ '' + } + .ProseMirror a { + color: ${linkFontColor}; + white-space: normal; + ${ + /** + * Setting 'pointer-events: none;' to restrict the user from opening a link using the right-click context menu: If the user manually edits + * the link's text content, the 'href' attribute of the anchor tag will not be updated. If they attempt to open it using + * the right-click context menu with 'Open in new tab/window,' it will still navigate to the link specified + * in the 'href' attribute, which may create unnecessary confusion while trying to open the link. + * + * Using pointer-events: none to disable link interactions can be removed when hyperlink support is added. + * see: https://github.com/ni/nimble/issues/1527 + */ '' + } + pointer-events: none; + } + + :host([disabled]) .ProseMirror a { + color: ${bodyDisabledFontColor}; + fill: currentcolor; + cursor: default; + } + + ${/** End of anchor styles */ ''} + .footer-section { display: flex; justify-content: space-between; 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 0606622fd3..c953291676 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 @@ -5,7 +5,9 @@ import type { ToggleButton } from '../../../toggle-button'; import type { ToolbarButton } from './types'; import { getTagsFromElement, - getLeafContentsFromElement + getLeafContentsFromElement, + getLastChildElement, + getLastChildElementAttribute } from '../../models/testing/markdown-parser-utils'; /** @@ -115,15 +117,18 @@ export class RichTextEditorPageObject { } public async setEditorTextContent(value: string): Promise { - let lastElement = this.getTiptapEditor()?.lastElementChild; - - while (lastElement?.lastElementChild) { - lastElement = lastElement?.lastElementChild; - } - lastElement!.parentElement!.textContent = value; + const lastElement = this.getEditorLastChildElement(); + lastElement.parentElement!.textContent = value; await waitForUpdatesAsync(); } + public getEditorLastChildAttribute(attribute: string): string { + return getLastChildElementAttribute( + attribute, + this.getTiptapEditor() as HTMLElement + ); + } + public getEditorFirstChildTagName(): string { return this.getTiptapEditor()?.firstElementChild?.tagName ?? ''; } @@ -142,6 +147,28 @@ export class RichTextEditorPageObject { ); } + public getEditorTagNamesWithClosingTags(): string[] { + const tagNames: string[] = []; + const tiptapEditor = this.getTiptapEditor(); + + const processNode = (node: Node): void => { + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element; + tagNames.push(el.tagName); + + el.childNodes.forEach(processNode); + + tagNames.push(`/${el.tagName}`); + } + }; + + if (tiptapEditor) { + processNode(tiptapEditor); + } + + return tagNames.slice(1, -1); + } + public getFormattingButtonTextContent( toolbarButton: ToolbarButton ): string { @@ -224,6 +251,10 @@ export class RichTextEditorPageObject { return buttons[button]; } + private getEditorLastChildElement(): Element { + return getLastChildElement(this.getTiptapEditor() as HTMLElement)!; + } + private getIconSlot( button: ToolbarButton ): HTMLSpanElement | null | undefined { diff --git a/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor-matrix.stories.ts b/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor-matrix.stories.ts index 1299141954..bfe669f555 100644 --- a/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor-matrix.stories.ts +++ b/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor-matrix.stories.ts @@ -30,7 +30,7 @@ const metadata: Meta = { } }; -const richTextMarkdownString = '1. **Bold*Italics***'; +const richTextMarkdownString = '1. \n2. **Bold*Italics***'; export default metadata; @@ -197,6 +197,15 @@ longWordContentInMobileWidth.play = (): void => { ); }; +export const longLinkInMobileWidth: StoryFn = createStory(mobileWidthComponent); +longLinkInMobileWidth.play = (): void => { + document + .querySelector('nimble-rich-text-editor')! + .setMarkdown( + '' + ); +}; + export const hiddenRichTextEditor: StoryFn = createStory( hiddenWrapper(html`<${richTextEditorTag} hidden>`) ); 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 4566ffe063..9b55942546 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 @@ -664,6 +664,188 @@ describe('RichTextEditor', () => { 'P' ]); }); + + describe('Absolute link interactions in the editor', () => { + describe('various absolute links without other nodes and marks', () => { + const supportedAbsoluteLink: { name: string }[] = [ + { name: 'https://nimble.ni.dev/ ' }, + { name: 'HTTPS://NIMBLE.NI.DEV ' }, + { name: 'HttPS://NIMBLE.ni.DEV ' }, + { name: 'http://nimble.ni.dev/ ' }, + { name: 'HTTP://NIMBLE.NI.DEV ' }, + { name: 'HttP://nimble.NI.dev ' }, + { + name: 'https://www.example.com/path/=equals&ersand?question$dollar+plus,comma;semicolon@At ' + }, + { name: 'https://example.com/my%20page.html ' }, + { name: 'https://example.com/smiley😀.html ' }, + { name: 'https://www.😀.com ' }, + { name: 'https://example.com/пример.html ' } + ]; + + const focused: string[] = []; + const disabled: string[] = []; + for (const value of supportedAbsoluteLink) { + const specType = getSpecTypeByNamedList( + value, + focused, + disabled + ); + specType( + `should change the ${value.name} to "a" tag when it is a valid absolute link`, + // eslint-disable-next-line @typescript-eslint/no-loop-func + async () => { + await pageObject.setEditorTextContent(value.name); + + expect(pageObject.getEditorTagNames()).toEqual([ + 'P', + 'A' + ]); + expect(pageObject.getEditorLeafContents()).toEqual([ + // Name without the trailing space used by the editor to trigger conversion to a link + value.name.slice(0, -1) + ]); + } + ); + } + }); + + it('the "a" tag should have href and rel attributes', async () => { + await pageObject.setEditorTextContent( + 'https://nimble.ni.dev/ ' + ); + + expect(pageObject.getEditorLastChildAttribute('href')).toBe( + 'https://nimble.ni.dev/' + ); + expect(pageObject.getEditorLastChildAttribute('rel')).toBe( + 'noopener noreferrer' + ); + }); + + it('should not affect bold formatting on the link in editor', async () => { + await pageObject.clickFooterButton(ToolbarButton.bold); + await pageObject.setEditorTextContent( + 'https://nimble.ni.dev/ ' + ); + + expect(pageObject.getEditorTagNamesWithClosingTags()).toEqual([ + 'P', + 'A', + '/A', + 'STRONG', + '/STRONG', + '/P' + ]); + expect(pageObject.getEditorLeafContents()).toEqual([ + 'https://nimble.ni.dev/', + ' ' + ]); + }); + + it('should not affect italics formatting on the link in editor', async () => { + await pageObject.clickFooterButton(ToolbarButton.italics); + await pageObject.setEditorTextContent( + 'https://nimble.ni.dev/ ' + ); + + expect(pageObject.getEditorTagNamesWithClosingTags()).toEqual([ + 'P', + 'A', + '/A', + 'EM', + '/EM', + '/P' + ]); + expect(pageObject.getEditorLeafContents()).toEqual([ + 'https://nimble.ni.dev/', + ' ' + ]); + }); + + it('should able to add links to the bullet list', async () => { + await pageObject.setEditorTextContent( + 'https://nimble.ni.dev/ ' + ); + await pageObject.clickFooterButton(ToolbarButton.bulletList); + + expect(pageObject.getEditorTagNames()).toEqual([ + 'UL', + 'LI', + 'P', + 'A' + ]); + expect(pageObject.getEditorLeafContents()).toEqual([ + 'https://nimble.ni.dev/' + ]); + }); + + it('should able to add links to the numbered list', async () => { + await pageObject.setEditorTextContent( + 'https://nimble.ni.dev/ ' + ); + await pageObject.clickFooterButton(ToolbarButton.numberedList); + + expect(pageObject.getEditorTagNames()).toEqual([ + 'OL', + 'LI', + 'P', + 'A' + ]); + expect(pageObject.getEditorLeafContents()).toEqual([ + 'https://nimble.ni.dev/' + ]); + }); + + describe('various absolute links with different protocols other than https/http should be render as unchanged strings', () => { + const differentProtocolLinks: { name: string }[] = [ + { name: 'ftp://example.com/files/document.pdf ' }, + { name: 'mailto:info@example.com ' }, + { name: 'info@example.com ' }, + { name: 'file:///path/to/local/file.txt ' }, + { name: 'tel:+1234567890 ' }, + // eslint-disable-next-line no-script-url + { name: 'javascript:void(0) ' }, + { name: 'data:image/png;base64,iVBORw0KG... ' }, + { name: 'ftps://example.com/files/document.pdf ' }, + { name: 'ssh://username@example.com ' }, + { name: 'urn:isbn:0451450523 ' }, + { + name: 'magnet:?xt=urn:btih:8c6dcd8d4f9151cb5cc01c68225b92db417c411f&dn=ExampleFile.iso ' + }, + { + name: 'bitcoin:1Hf1KqNPZzkFJ5Wv8VPop9uaF5RjKN3N9s?amount=0.001 ' + }, + // eslint-disable-next-line no-script-url + { name: 'javascript:vbscript:alert("not alert") ' }, + { name: 'test://test.com ' } + ]; + + const focused: string[] = []; + const disabled: string[] = []; + for (const value of differentProtocolLinks) { + const specType = getSpecTypeByNamedList( + value, + focused, + disabled + ); + specType( + `string "${value.name}" renders as plain text "${value.name}" within paragraph tag`, + // eslint-disable-next-line @typescript-eslint/no-loop-func + async () => { + await pageObject.setEditorTextContent(value.name); + + expect(pageObject.getEditorTagNames()).toEqual([ + 'P' + ]); + expect(pageObject.getEditorLeafContents()).toEqual([ + value.name + ]); + } + ); + } + }); + }); }); describe('various wacky string values input into the editor', () => { @@ -709,6 +891,178 @@ describe('RichTextEditor', () => { ]); }); + describe('Absolute link markdown tests', () => { + describe('asserting rendered links in the editor', () => { + it('absolute link markdown string to "a" tags with the link as the text content', () => { + element.setMarkdown(''); + + expect(pageObject.getEditorTagNames()).toEqual(['P', 'A']); + expect(pageObject.getEditorLeafContents()).toEqual([ + 'https://nimble.ni.dev/' + ]); + expect(pageObject.getEditorLastChildAttribute('href')).toBe( + 'https://nimble.ni.dev/' + ); + }); + + it('bulleted list with absolute links markdown string to "ul", "li" and "a" tags', () => { + element.setMarkdown('* '); + + expect(pageObject.getEditorTagNames()).toEqual([ + 'UL', + 'LI', + 'P', + 'A' + ]); + expect(pageObject.getEditorLeafContents()).toEqual([ + 'https://nimble.ni.dev/' + ]); + expect(pageObject.getEditorLastChildAttribute('href')).toBe( + 'https://nimble.ni.dev/' + ); + }); + + it('numbered list with absolute links markdown string to "ol", "li" and "a" tags', () => { + element.setMarkdown('1. '); + + expect(pageObject.getEditorTagNames()).toEqual([ + 'OL', + 'LI', + 'P', + 'A' + ]); + expect(pageObject.getEditorLeafContents()).toEqual([ + 'https://nimble.ni.dev/' + ]); + expect(pageObject.getEditorLastChildAttribute('href')).toBe( + 'https://nimble.ni.dev/' + ); + }); + + it('absolute links in bold markdown string should not be parsed to "strong" tag', () => { + element.setMarkdown('****'); + + expect(pageObject.getEditorTagNames()).toEqual(['P', 'A']); + expect(pageObject.getEditorLeafContents()).toEqual([ + 'https://nimble.ni.dev/' + ]); + expect(pageObject.getEditorLastChildAttribute('href')).toBe( + 'https://nimble.ni.dev/' + ); + }); + + it('absolute links in italics markdown string should not be parsed to "em" tag', () => { + element.setMarkdown('**'); + + expect(pageObject.getEditorTagNames()).toEqual(['P', 'A']); + expect(pageObject.getEditorLeafContents()).toEqual([ + 'https://nimble.ni.dev/' + ]); + expect(pageObject.getEditorLastChildAttribute('href')).toBe( + 'https://nimble.ni.dev/' + ); + }); + + it('absolute links in both bold and italics markdown string should not be parsed to "strong" and "em" tag', () => { + element.setMarkdown('______'); + + expect(pageObject.getEditorTagNames()).toEqual(['P', 'A']); + expect(pageObject.getEditorLeafContents()).toEqual([ + 'https://nimble.ni.dev/' + ]); + expect(pageObject.getEditorLastChildAttribute('href')).toBe( + 'https://nimble.ni.dev/' + ); + }); + + it('adding marks like bold inside absolute links should not be parsed to "strong" tag', () => { + element.setMarkdown(''); + + expect(pageObject.getEditorTagNames()).toEqual(['P', 'A']); + expect(pageObject.getEditorLeafContents()).toEqual([ + 'https://**nimble**.ni.dev/' + ]); + expect(pageObject.getEditorLastChildAttribute('href')).toBe( + 'https://**nimble**.ni.dev/' + ); + }); + }); + + describe('asserting getMarkdown for rendered links', () => { + it('absolute link markdown string', () => { + element.setMarkdown(''); + + expect(element.getMarkdown()).toEqual( + '' + ); + }); + + it('bulleted list with absolute links markdown string', () => { + element.setMarkdown('* '); + + expect(element.getMarkdown()).toEqual( + '* ' + ); + }); + + it('numbered list with absolute links markdown string', () => { + element.setMarkdown('1. '); + + expect(element.getMarkdown()).toEqual( + '1. ' + ); + }); + + it('absolute links in bold markdown string should not be serialized to link in bold markdown', () => { + element.setMarkdown('****'); + + expect(element.getMarkdown()).toEqual( + '' + ); + }); + + it('absolute links in italics markdown string should not be serialized to link in italics markdown', () => { + element.setMarkdown('**'); + + expect(element.getMarkdown()).toEqual( + '' + ); + }); + + it('absolute links in both bold and italics markdown string should not be serialized to link in bold and italics markdown', () => { + element.setMarkdown('______'); + + expect(element.getMarkdown()).toEqual( + '' + ); + }); + + it('adding marks like bold inside absolute links should not be serialized to bold markdown', () => { + element.setMarkdown(''); + + expect(element.getMarkdown()).toEqual( + '' + ); + }); + + it('adding marks like italics inside absolute links should not be serialized to italics markdown', () => { + element.setMarkdown(''); + + expect(element.getMarkdown()).toEqual( + '' + ); + }); + + it('adding both the italics and bold inside absolute links should not be serialized to bold and italics markdown', () => { + element.setMarkdown(''); + + expect(element.getMarkdown()).toEqual( + '' + ); + }); + }); + }); + it('Should return a empty string when empty string is assigned', () => { element.setMarkdown('markdown string'); expect(element.getMarkdown()).toBe('markdown string'); diff --git a/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor.stories.ts b/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor.stories.ts index a3cf5c2ec8..a34823b78c 100644 --- a/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor.stories.ts +++ b/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor.stories.ts @@ -12,6 +12,7 @@ import { type LabelUserArgs } from '../../../label-provider/base/tests/label-user-stories-utils'; import { labelProviderRichTextTag } from '../../../label-provider/rich-text'; +import { richTextMarkdownString } from '../../../utilities/tests/rich-text-markdown-string'; // eslint-disable-next-line @typescript-eslint/no-empty-interface interface RichTextEditorArgs extends LabelUserArgs { @@ -38,20 +39,9 @@ const exampleDataType = { const plainString = 'Plain text' as const; -const markdownString = ` -Supported rich text formatting options: -1. **Bold** -2. *Italics* -3. Numbered lists - 1. Option 1 - 2. Option 2 -4. Bulleted lists - * Option 1 - * Option 2` as const; - const dataSets = { [exampleDataType.plainString]: plainString, - [exampleDataType.markdownString]: markdownString + [exampleDataType.markdownString]: richTextMarkdownString } as const; const richTextEditorDescription = 'The rich text editor component allows users to add/edit text formatted with various styling options including bold, italics, numbered lists, and bulleted lists. The editor generates markdown output and takes markdown as input. The markdown flavor used is [CommonMark](https://spec.commonmark.org/0.30/).\n\n See the [rich text viewer](?path=/docs/incubating-rich-text-viewer--docs) component to render markdown without allowing editing.'; 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 bd04821e07..ce7b2aaea4 100644 --- a/packages/nimble-components/src/rich-text/models/markdown-parser.ts +++ b/packages/nimble-components/src/rich-text/models/markdown-parser.ts @@ -3,14 +3,19 @@ import { defaultMarkdownParser, MarkdownParser } from 'prosemirror-markdown'; -import { DOMSerializer } from 'prosemirror-model'; +import { DOMSerializer, Schema } from 'prosemirror-model'; +import { anchorTag } from '../../anchor'; /** * Provides markdown parser for rich text components */ export class RichTextMarkdownParser { + private static readonly updatedSchema = this.getSchemaWithLinkConfiguration(); + private static readonly markdownParser = this.initializeMarkdownParser(); - private static readonly domSerializer = DOMSerializer.fromSchema(schema); + private static readonly domSerializer = DOMSerializer.fromSchema( + this.updatedSchema + ); /** * This function takes a markdown string, parses it using the ProseMirror MarkdownParser, serializes the parsed content into a @@ -47,10 +52,53 @@ export class RichTextMarkdownParser { 'escape' ]); + supportedTokenizerRules.validateLink = href => /^https?:\/\//i.test(href); + + /** + * In order to display encoded characters, non-ASCII characters, emojis, and other special characters in their original form, + * we bypass the default normalization of link text in markdown-it. This is done because we support only "AutoLink" feature in CommonMark flavor. + * "normalizeLinkText" method reference in markdown-it: https://github.com/markdown-it/markdown-it/blob/2b6cac25823af011ff3bc7628bc9b06e483c5a08/lib/index.js#L67C1-L86C2 + * + * We can use the default normalization once hyperlink support is added. + * See: https://github.com/ni/nimble/issues/1527 + */ + supportedTokenizerRules.normalizeLinkText = url => url; + return new MarkdownParser( - schema, + this.updatedSchema, supportedTokenizerRules, defaultMarkdownParser.tokens ); } + + private static getSchemaWithLinkConfiguration(): Schema { + return new Schema({ + nodes: schema.spec.nodes, + marks: { + link: { + attrs: { + href: {}, + rel: { default: 'noopener noreferrer' } + }, + // Inclusive can be updated when hyperlink support added + // See: https://github.com/ni/nimble/issues/1527 + inclusive: false, + // Excludes can be removed/enabled when hyperlink support added + // See: https://github.com/ni/nimble/issues/1527 + excludes: '_', + toDOM(node) { + return [ + anchorTag, + { + href: node.attrs.href as Attr, + rel: node.attrs.rel as Attr + } + ]; + } + }, + em: schema.spec.marks.get('em')!, + strong: schema.spec.marks.get('strong')! + } + }); + } } 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 cad1e14484..b6e8a145ef 100644 --- a/packages/nimble-components/src/rich-text/models/markdown-serializer.ts +++ b/packages/nimble-components/src/rich-text/models/markdown-serializer.ts @@ -51,7 +51,28 @@ export class RichTextMarkdownSerializer { }; const marks = { italic: defaultMarkdownSerializer.marks.em!, - bold: defaultMarkdownSerializer.marks.strong! + bold: defaultMarkdownSerializer.marks.strong!, + /** + * When a user inserts an absolute link into the editor and then modifies it, the 'defaultMarkdownSerializer.marks.link' function + * will detect whether it should be serialized as an autolink () or a hyperlink ([text](url)) in Markdown format by + * comparing the link text with 'href'. Since our markdown-parser only supports the autolink format, we need to ensure that the + * serializer also only supports autolink. Unfortunately, prosemirror-markdown does not offer a built-in way to update the + * 'defaultMarkdownSerializer' for this purpose. Therefore, we had to create a modified implementation to enable support for + * only autolink in serialization. This modified implementation will just load the link text content in between '<>' angular brackets + * and ignores the 'href' part. + * + * Autolink markdown in CommonMark flavor: https://spec.commonmark.org/0.30/#autolinks + * ProseMirror model reference: https://github.com/ProseMirror/prosemirror-markdown/blob/c7210d0e55c82bfb0b2f7cba5dffe804575fafb3/src/to_markdown.ts#L3C1-L26C2 + * + * The defaultMarkdownSerializer can be used once hyperlink support is added: + * See: https://github.com/ni/nimble/issues/1527 + */ + link: { + open: '<', + close: '>', + escape: false, + expelEnclosingWhitespace: true + } }; return new MarkdownSerializer(nodes, marks); } diff --git a/packages/nimble-components/src/rich-text/models/testing/markdown-parser-utils.ts b/packages/nimble-components/src/rich-text/models/testing/markdown-parser-utils.ts index 84a635973b..71437195fc 100644 --- a/packages/nimble-components/src/rich-text/models/testing/markdown-parser-utils.ts +++ b/packages/nimble-components/src/rich-text/models/testing/markdown-parser-utils.ts @@ -15,3 +15,21 @@ export const getLeafContentsFromElement = ( .map(el => el.textContent || ''); return nodes; }; + +export const getLastChildElementAttribute = ( + attribute: string, + doc: DocumentFragment | HTMLElement +): string => { + return getLastChildElement(doc)?.getAttribute(attribute) ?? ''; +}; + +export function getLastChildElement( + doc: DocumentFragment | HTMLElement +): Element | null | undefined { + let lastElement = doc.lastElementChild; + + while (lastElement?.lastElementChild) { + lastElement = lastElement.lastElementChild; + } + return lastElement; +} 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 1022d34c7b..c84da64367 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 @@ -3,7 +3,8 @@ import { wackyStrings } from '../../../utilities/tests/wacky-strings'; import { RichTextMarkdownParser } from '../markdown-parser'; import { getLeafContentsFromElement, - getTagsFromElement + getTagsFromElement, + getLastChildElementAttribute } from '../testing/markdown-parser-utils'; describe('Markdown parser', () => { @@ -210,9 +211,399 @@ describe('Markdown parser', () => { ]); }); + describe('Absolute link', () => { + describe('various valid absolute links should render same as in the markdown', () => { + const supportedAbsoluteLink: { + name: string, + validLink: string + }[] = [ + { + name: 'Lowercase HTTPS URL', + validLink: '' + }, + { + name: 'Uppercase HTTPS URL', + validLink: '' + }, + { + name: 'Mixed case HTTPS URL', + validLink: '' + }, + { + name: 'Lowercase HTTP URL', + validLink: '' + }, + { + name: 'Uppercase HTTP URL', + validLink: '' + }, + { + name: 'Mixed case HTTP URL', + validLink: '' + }, + { + name: 'URL with reserved characters', + validLink: + '' + }, + { + name: 'Whitespace encoded URL', + validLink: '' + }, + { + name: 'Question mark encoded URL', + validLink: + '' + }, + { + name: 'Emoji encoded URL', + validLink: + '' + }, + { + name: 'Ampersand encoded URL', + validLink: + '' + }, + { + name: 'Non-latin encoded URL', + validLink: + '' + }, + { + name: 'URL with Fragment Identifier', + validLink: '' + }, + { + name: 'URL with marks', + validLink: '' + }, + { + name: 'URL with Port Number', + validLink: '' + } + ]; + + const focused: string[] = []; + const disabled: string[] = []; + for (const value of supportedAbsoluteLink) { + const specType = getSpecTypeByNamedList( + value, + focused, + disabled + ); + specType( + `${value.name} to "nimble-anchor" tags with the link as the text content`, + // eslint-disable-next-line @typescript-eslint/no-loop-func + () => { + const doc = RichTextMarkdownParser.parseMarkdownToDOM( + value.validLink + ); + const renderedLink = value.validLink.slice(1, -1); + + expect(getTagsFromElement(doc)).toEqual([ + 'P', + 'NIMBLE-ANCHOR' + ]); + expect(getLeafContentsFromElement(doc)).toEqual([ + renderedLink + ]); + expect( + getLastChildElementAttribute('href', doc) + ).toBe(renderedLink); + } + ); + } + }); + + describe('various absolute links with non-ASCII (IRI) characters within it', () => { + const supportedAbsoluteLink: { + name: string, + validLink: string, + encodeURL: string + }[] = [ + { + name: 'Emoji', + validLink: '', + encodeURL: 'https://example.com/smiley%F0%9F%98%80.html' + }, + { + name: 'Emoji at the host (punycode encoded)', + validLink: '', + encodeURL: 'https://www.xn--e28h.com' + }, + { + name: 'Square brackets', + validLink: '', + encodeURL: 'https://example.com/%5Bpage%5D/index.html' + }, + { + name: 'Backslashes', + validLink: '', + encodeURL: 'https://example.com%5Cpath%5Cto%5Cresource' + }, + { + name: 'Open and close braces', + validLink: '', + encodeURL: 'https://example.com/%7Bpage%7D/index.html' + }, + { + name: 'Pipe', + validLink: '', + encodeURL: 'https://example.com/page%7C/index.html' + }, + { + name: 'Caret', + validLink: '', + encodeURL: 'https://example.com/page%5E/index.html' + }, + { + name: 'Percent', + validLink: '', + encodeURL: 'https://example.com/page%25/index.html' + }, + { + name: 'Basic IRI characters', + validLink: '', + encodeURL: 'https://example.com/%C3%A9l%C3%A8ve.html' + }, + { + name: 'Non-Latin Scripts', + validLink: '', + encodeURL: + 'https://example.com/%D0%BF%D1%80%D0%B8%D0%BC%D0%B5%D1%80.html' + }, + { + name: 'Math symbols', + validLink: '', + encodeURL: 'https://example.com/%E2%88%9A2.html' + }, + { + name: 'Special symbols', + validLink: '', + encodeURL: 'https://example.com/%E2%99%A5-music.html' + }, + { + name: 'Accented Characters', + validLink: '', + encodeURL: 'https://example.com/espa%C3%B1a.html' + }, + { + name: 'Japanese Characters', + validLink: '', + encodeURL: 'https://example.com/%E6%9D%B1%E4%BA%AC.html' + } + ]; + + const focused: string[] = []; + const disabled: string[] = []; + for (const value of supportedAbsoluteLink) { + const specType = getSpecTypeByNamedList( + value, + focused, + disabled + ); + specType( + `${value.name} to "nimble-anchor" tags with the non-ASCII characters as the text content and encoded as their href`, + // eslint-disable-next-line @typescript-eslint/no-loop-func + () => { + const doc = RichTextMarkdownParser.parseMarkdownToDOM( + value.validLink + ); + const renderedLink = value.validLink.slice(1, -1); + + expect(getTagsFromElement(doc)).toEqual([ + 'P', + 'NIMBLE-ANCHOR' + ]); + expect(getLeafContentsFromElement(doc)).toEqual([ + renderedLink + ]); + expect( + getLastChildElementAttribute('href', doc) + ).toBe(value.encodeURL); + } + ); + } + }); + + it('absolute link should add "rel" attribute', () => { + const doc = RichTextMarkdownParser.parseMarkdownToDOM( + '' + ); + + expect(getLastChildElementAttribute('rel', doc)).toBe( + 'noopener noreferrer' + ); + }); + + it('bulleted list with absolute links markdown string to "ul", "li" and "nimble-anchor" HTML tags', () => { + const doc = RichTextMarkdownParser.parseMarkdownToDOM( + '* ' + ); + + expect(getTagsFromElement(doc)).toEqual([ + 'UL', + 'LI', + 'P', + 'NIMBLE-ANCHOR' + ]); + expect(getLeafContentsFromElement(doc)).toEqual([ + 'https://nimble.ni.dev/' + ]); + expect(getLastChildElementAttribute('href', doc)).toBe( + 'https://nimble.ni.dev/' + ); + }); + + it('numbered list with absolute links markdown string to "ol", "li" and "nimble-anchor" HTML tags', () => { + const doc = RichTextMarkdownParser.parseMarkdownToDOM( + '1. ' + ); + + expect(getTagsFromElement(doc)).toEqual([ + 'OL', + 'LI', + 'P', + 'NIMBLE-ANCHOR' + ]); + expect(getLeafContentsFromElement(doc)).toEqual([ + 'https://nimble.ni.dev/' + ]); + expect(getLastChildElementAttribute('href', doc)).toBe( + 'https://nimble.ni.dev/' + ); + }); + + it('absolute links in bold markdown string should not be parsed to "strong" HTML tag', () => { + const doc = RichTextMarkdownParser.parseMarkdownToDOM( + '****' + ); + + expect(getTagsFromElement(doc)).toEqual(['P', 'NIMBLE-ANCHOR']); + expect(getLeafContentsFromElement(doc)).toEqual([ + 'https://nimble.ni.dev/' + ]); + expect(getLastChildElementAttribute('href', doc)).toBe( + 'https://nimble.ni.dev/' + ); + }); + + it('absolute links in italics markdown string should not be parsed to "em" HTML tag', () => { + const doc = RichTextMarkdownParser.parseMarkdownToDOM( + '**' + ); + + expect(getTagsFromElement(doc)).toEqual(['P', 'NIMBLE-ANCHOR']); + expect(getLeafContentsFromElement(doc)).toEqual([ + 'https://nimble.ni.dev/' + ]); + expect(getLastChildElementAttribute('href', doc)).toBe( + 'https://nimble.ni.dev/' + ); + }); + + it('absolute links in both bold and italics markdown string should not be parsed to "strong" and "em" HTML tag', () => { + const doc = RichTextMarkdownParser.parseMarkdownToDOM( + '______' + ); + + expect(getTagsFromElement(doc)).toEqual(['P', 'NIMBLE-ANCHOR']); + expect(getLeafContentsFromElement(doc)).toEqual([ + 'https://nimble.ni.dev/' + ]); + expect(getLastChildElementAttribute('href', doc)).toBe( + 'https://nimble.ni.dev/' + ); + }); + + describe('various absolute links with different protocols other than https/http should be render as unchanged strings', () => { + const differentProtocolLinks: { name: string }[] = [ + { name: '' }, + { name: '' }, + { name: '' }, + { name: '' }, + { name: '' }, + { name: '' }, + { name: '' }, + { name: '' }, + { name: '' }, + { + name: '' + }, + { + name: '' + }, + { name: '' }, + { name: '' } + ]; + + const focused: string[] = []; + const disabled: string[] = []; + for (const value of differentProtocolLinks) { + const specType = getSpecTypeByNamedList( + value, + focused, + disabled + ); + specType( + `string "${value.name}" renders as plain text within paragraph tag`, + // eslint-disable-next-line @typescript-eslint/no-loop-func + () => { + const doc = RichTextMarkdownParser.parseMarkdownToDOM( + value.name + ); + + expect(getTagsFromElement(doc)).toEqual(['P']); + expect(getLeafContentsFromElement(doc)).toEqual([ + value.name + ]); + } + ); + } + }); + + describe('various unsafe characters in an absolute link', () => { + const notSupportedAbsoluteLink: { + name: string + }[] = [ + { name: '>' }, + { name: '' }, + { name: 'http://www.example.com/' }, + { name: '' }, + { name: '' }, + { name: ' { + const doc = RichTextMarkdownParser.parseMarkdownToDOM( + value.name + ); + + expect(getTagsFromElement(doc)).toEqual(['P']); + expect(getLeafContentsFromElement(doc)).toEqual([ + value.name + ]); + } + ); + } + }); + }); + it('combination of all supported markdown string', () => { const doc = RichTextMarkdownParser.parseMarkdownToDOM( - '1. ***Numbered list with bold and italics***\n* ___Bulleted list with bold and italics___' + '1. ***Numbered list with bold and italics***\n* ___Bulleted list with bold and italics___\n* ' ); expect(getTagsFromElement(doc)).toEqual([ @@ -225,11 +616,15 @@ describe('Markdown parser', () => { 'LI', 'P', 'EM', - 'STRONG' + 'STRONG', + 'LI', + 'P', + 'NIMBLE-ANCHOR' ]); expect(getLeafContentsFromElement(doc)).toEqual([ 'Numbered list with bold and italics', - 'Bulleted list with bold and italics' + 'Bulleted list with bold and italics', + 'https://nimble.ni.dev/' ]); }); }); 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 3b3afbb556..85f9c43111 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 @@ -7,6 +7,7 @@ import ListItem from '@tiptap/extension-list-item'; import OrderedList from '@tiptap/extension-ordered-list'; import Paragraph from '@tiptap/extension-paragraph'; import Text from '@tiptap/extension-text'; +import Link from '@tiptap/extension-link'; import type { Node } from 'prosemirror-model'; import { RichTextMarkdownSerializer } from '../markdown-serializer'; import { getSpecTypeByNamedList } from '../../../utilities/tests/parameterized'; @@ -22,7 +23,10 @@ describe('Markdown serializer', () => { OrderedList, ListItem, Bold, - Italic + Italic, + Link.extend({ + excludes: '_' + }) ] }); @@ -51,11 +55,31 @@ describe('Markdown serializer', () => { html: 'Italics', markdown: '*Italics*' }, + { + name: 'Link', + html: '

https://nimble.ni.dev

', + markdown: '' + }, { name: 'Bold and Italics', html: 'Bold and Italics', markdown: '***Bold and Italics***' }, + { + name: 'Link and Bold', + html: '

https://nimble.ni.dev

', + markdown: '' + }, + { + name: 'Link and Italics', + html: '

https://nimble.ni.dev

', + markdown: '' + }, + { + name: 'Link, Bold and Italics', + html: '

https://nimble.ni.dev

', + markdown: '' + }, { name: 'Italics without spaces in between bold texts', html: 'Bolditalicsbold', @@ -116,6 +140,11 @@ describe('Markdown serializer', () => { html: '
  1. Numbered list with italics

', markdown: '1. *Numbered list with italics*' }, + { + name: 'Numbered list with link', + html: '
  1. https://nimble.ni.dev

', + markdown: '1. ' + }, { name: 'Bulleted list', html: '
  • Bulleted list

', @@ -136,6 +165,11 @@ describe('Markdown serializer', () => { html: '
  • Bulleted list with italics

', markdown: '* *Bulleted list with italics*' }, + { + name: 'Bullet list with link', + html: '', + markdown: '* ' + }, { name: 'Nested list with levels 1 - Bulleted list, 2 - Numbered list (Bold)', html: '
  • Bulleted list

    1. Nested bold numbered list

', @@ -252,7 +286,6 @@ describe('Markdown serializer', () => { html: 'Highlight', plainText: 'Highlight' }, - { name: 'Link', html: 'Link', plainText: 'Link' }, { name: 'Strikethrough', html: 'Strikethrough', diff --git a/packages/nimble-components/src/rich-text/viewer/styles.ts b/packages/nimble-components/src/rich-text/viewer/styles.ts index 691d23eecc..0ffe474bab 100644 --- a/packages/nimble-components/src/rich-text/viewer/styles.ts +++ b/packages/nimble-components/src/rich-text/viewer/styles.ts @@ -1,11 +1,6 @@ import { css } from '@microsoft/fast-element'; import { display } from '@microsoft/fast-foundation'; -import { - bodyFont, - bodyFontColor, - linkActiveFontColor, - linkFontColor -} from '../../theme-provider/design-tokens'; +import { bodyFont, bodyFontColor } from '../../theme-provider/design-tokens'; export const styles = css` ${display('flex')} @@ -46,13 +41,4 @@ export const styles = css` li > p:empty { display: none; } - - a { - word-break: break-all; - color: ${linkFontColor}; - } - - a:active { - color: ${linkActiveFontColor}; - } `;