Skip to content

Commit

Permalink
Rich text editor & viewer | Support for absolute link (#1496)
Browse files Browse the repository at this point in the history
# Pull Request

## 🤨 Rationale

<!---
Provide some background and a description of your work.
What problem does this change solve?

Include links to issues, work items, or other discussions.
-->

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. `<URL>`.
3. Links in the viewer will render as `nimble-anchor`. Part of
#1372
4. Links in the editor will render as `<a>` as we have few constraints
in rendering `nimble-anchor` in the editor.
#1516

## 👩‍💻 Implementation

<!---
Describe how the change addresses the problem. Consider factors such as
complexity, alternative solutions, performance impact, etc.

Consider listing files with important changes or comment on them
directly in the pull request.
-->

#### 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 `<https://**nimble**.ni.dev>`.

#### 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 `<a>` 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 `<a>` 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.

<!---
Detail the testing done to ensure this submission meets requirements. 

Include automated/manual test additions or modifications, testing done
on a local build, private CI run results, and additional testing not
covered by automatic pull request validation. If any functionality is
not covered by automated testing, provide justification.
-->

## ✅ 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.

---------

Signed-off-by: Sai krishnan Perumal <[email protected]>
Signed-off-by: Sai krishnan Perumal <[email protected]>
Co-authored-by: Sai krishnan Perumal <[email protected]>
Co-authored-by: Sai krishnan Perumal <[email protected]>
  • Loading branch information
3 people authored Sep 14, 2023
1 parent fe196d5 commit f5dfad6
Show file tree
Hide file tree
Showing 15 changed files with 1,045 additions and 47 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Support for absolute links in rich text components",
"packageName": "@ni/nimble-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
22 changes: 22 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 @@ -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",
Expand Down
53 changes: 52 additions & 1 deletion packages/nimble-components/src/rich-text/editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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<LinkOptions> {
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 `<nimble-anchor>` as anchor tag and not `<a>`.
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
*/
Expand Down
34 changes: 33 additions & 1 deletion packages/nimble-components/src/rich-text/editor/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
failColor,
iconSize,
smallDelay,
standardPadding
standardPadding,
linkFontColor
} from '../../theme-provider/design-tokens';
import { styles as errorStyles } from '../../patterns/error/styles';

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -115,15 +117,18 @@ export class RichTextEditorPageObject {
}

public async setEditorTextContent(value: string): Promise<void> {
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 ?? '';
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const metadata: Meta = {
}
};

const richTextMarkdownString = '1. **Bold*Italics***';
const richTextMarkdownString = '1. <https://nimble.ni.dev>\n2. **Bold*Italics***';

export default metadata;

Expand Down Expand Up @@ -197,6 +197,15 @@ longWordContentInMobileWidth.play = (): void => {
);
};

export const longLinkInMobileWidth: StoryFn = createStory(mobileWidthComponent);
longLinkInMobileWidth.play = (): void => {
document
.querySelector('nimble-rich-text-editor')!
.setMarkdown(
'<https://www.google.com/search?q=what+is+nimble&rlz=1C1CHBF_enIN1007IN1007&oq=what+is+nimble&aqs=chrome..69i57j0i512l9.2837j1j7&sourceid=chrome&ie=UTF-8>'
);
};

export const hiddenRichTextEditor: StoryFn = createStory(
hiddenWrapper(html`<${richTextEditorTag} hidden></${richTextEditorTag}>`)
);
Loading

0 comments on commit f5dfad6

Please sign in to comment.