Skip to content

Commit

Permalink
Rich Text Editor | Add setMarkdown() and getMarkdown() methods to set…
Browse files Browse the repository at this point in the history
… and get content from Tip Tap editor (#1424)

# Pull Request

## 🤨 Rationale
This PR contains logic for parsing the assigned markdown content,
setting it to the editor and also serializing the editor's data to
markdown content.

AzDo Feature:
https://dev.azure.com/ni/DevCentral/_backlogs/backlog/ASW%20SystemLink%20LIMS/Features/?workitem=2350963
Issue: #1288

## 👩‍💻 Implementation

* Exposed getMarkdown() and setmarkdown() methods to facilitate the
exchange of data with the editor.
* Used MarkdownParser from
[prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown/tree/master)
package for parsing markdown strings, DOM serializer from
[prosemirror-model ](https://github.com/ProseMirror/prosemirror-model)
package to serialize the content as DOM structure, XML Serializer to
serialize it to HTML string and sets it to the editor.
* To serialize the tip-tap [document
](https://prosemirror.net/docs/ref/#model.Document_Structure)in
getMarkdown() used MarkdownSerializer from
[prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown/tree/master)
package to serialize the node based on schema.
* Enabled `emphasis` and `list` rules in MarkdownParser, this allows
users to set bold, italics, numbered, and bulleted lists in a CommonMark
flavor to the editor. All other basic markdown formats are disabled.
* Added only respective nodes and marks for bold, italics, numbered and
bulleted lists in `MarkdownSerializer`

## 🧪 Testing

* Added unit tests and visual tests for the component.
* Manually tested and verified the functionality of the supported
features.

## ✅ 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: Aagash Raaj <[email protected]>
Co-authored-by: SOLITONTECH\vivin.krishna <[email protected]>
Co-authored-by: SOLITONTECH\aagash.maridas <[email protected]>
Co-authored-by: Jesse Attas <[email protected]>
  • Loading branch information
4 people authored Aug 22, 2023
1 parent 51b3fa5 commit 42928b3
Show file tree
Hide file tree
Showing 8 changed files with 1,013 additions and 33 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Add setMarkdown and getMarkdown methods in rich-text-editor",
"packageName": "@ni/nimble-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
135 changes: 133 additions & 2 deletions packages/nimble-components/src/rich-text-editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import { observable } from '@microsoft/fast-element';
import { DesignSystem, FoundationElement } from '@microsoft/fast-foundation';
import { keyEnter, keySpace } from '@microsoft/fast-web-utilities';
import { Editor } from '@tiptap/core';
import {
schema,
defaultMarkdownParser,
MarkdownParser,
MarkdownSerializer,
defaultMarkdownSerializer,
MarkdownSerializerState
} from 'prosemirror-markdown';
import { DOMSerializer, Node } from 'prosemirror-model';
import Bold from '@tiptap/extension-bold';
import BulletList from '@tiptap/extension-bullet-list';
import Document from '@tiptap/extension-document';
Expand Down Expand Up @@ -52,16 +61,29 @@ export class RichTextEditor extends FoundationElement {
/**
* @internal
*/
public editor!: HTMLDivElement;
public editorContainer!: HTMLDivElement;

private tiptapEditor!: Editor;
private editor!: HTMLDivElement;

private readonly markdownParser = this.initializeMarkdownParser();
private readonly markdownSerializer = this.initializeMarkdownSerializer();
private readonly domSerializer = DOMSerializer.fromSchema(schema);
private readonly xmlSerializer = new XMLSerializer();

public constructor() {
super();
this.initializeEditor();
}

/**
* @internal
*/
public override connectedCallback(): void {
super.connectedCallback();
this.initializeEditor();
if (!this.editor.isConnected) {
this.editorContainer.append(this.editor);
}
this.bindEditorTransactionEvent();
}

Expand Down Expand Up @@ -153,6 +175,26 @@ export class RichTextEditor extends FoundationElement {
return true;
}

/**
* This function load tip tap editor with provided markdown content by parsing into html
* @public
*/
public setMarkdown(markdown: string): void {
const html = this.getHtmlContent(markdown);
this.tiptapEditor.commands.setContent(html);
}

/**
* This function returns markdown string by serializing tiptap editor document using prosemirror MarkdownSerializer
* @public
*/
public getMarkdown(): string {
const markdownContent = this.markdownSerializer.serialize(
this.tiptapEditor.state.doc
);
return markdownContent;
}

/**
* @internal
*/
Expand All @@ -163,7 +205,96 @@ export class RichTextEditor extends FoundationElement {
return false;
}

/**
* This function takes the Fragment from parseMarkdownToDOM function and return the serialized string using XMLSerializer
*/
private getHtmlContent(markdown: string): string {
const documentFragment = this.parseMarkdownToDOM(markdown);
return this.xmlSerializer.serializeToString(documentFragment);
}

private initializeMarkdownParser(): MarkdownParser {
/**
* It configures the tokenizer of the default Markdown parser with the 'zero' preset.
* The 'zero' preset is a configuration with no rules enabled by default to selectively enable specific rules.
* https://github.com/markdown-it/markdown-it/blob/2b6cac25823af011ff3bc7628bc9b06e483c5a08/lib/presets/zero.js#L1
*
*/
const zeroTokenizerConfiguration = defaultMarkdownParser.tokenizer.configure('zero');

// The detailed information of the supported rules were provided in the below CommonMark spec document.
// https://spec.commonmark.org/0.30/
const supportedTokenizerRules = zeroTokenizerConfiguration.enable([
'emphasis',
'list'
]);

return new MarkdownParser(
schema,
supportedTokenizerRules,
defaultMarkdownParser.tokens
);
}

private initializeMarkdownSerializer(): MarkdownSerializer {
/**
* orderedList Node is getting 'order' attribute which it is not present in the
* tip-tap orderedList Node and having start instead of order, Changed it to start (nodes.attrs.start)
* Assigned updated node in place of orderedList node from defaultMarkdownSerializer
* https://github.com/ProseMirror/prosemirror-markdown/blob/b7c1fd2fb74c7564bfe5428c7c8141ded7ebdd9f/src/to_markdown.ts#L94C2-L101C7
*/
const orderedListNode = function orderedList(
state: MarkdownSerializerState,
node: Node
): void {
const start = (node.attrs.start as number) || 1;
const maxW = String(start + node.childCount - 1).length;
const space = state.repeat(' ', maxW + 2);
state.renderList(node, space, i => {
const nStr = String(start + i);
return `${state.repeat(' ', maxW - nStr.length) + nStr}. `;
});
};

/**
* Internally Tiptap editor creates it own schema ( Nodes AND Marks ) based on the extensions ( Here Starter Kit is used for Bold, italic, orderedList and
* bulletList extensions) and defaultMarkdownSerializer uses schema from prosemirror-markdown to serialize the markdown.
* So, there is variations in the nodes and marks name (Eg. 'ordered_list' in prosemirror-markdown schema whereas 'orderedList' in tip tap editor schema),
* To fix up this reassigned the respective nodes and marks with tip-tap editor schema.
*/
const nodes = {
bulletList: defaultMarkdownSerializer.nodes.bullet_list!,
listItem: defaultMarkdownSerializer.nodes.list_item!,
orderedList: orderedListNode,
doc: defaultMarkdownSerializer.nodes.doc!,
paragraph: defaultMarkdownSerializer.nodes.paragraph!,
text: defaultMarkdownSerializer.nodes.text!
};
const marks = {
italic: defaultMarkdownSerializer.marks.em!,
bold: defaultMarkdownSerializer.marks.strong!
};
return new MarkdownSerializer(nodes, marks);
}

private parseMarkdownToDOM(value: string): HTMLElement | DocumentFragment {
const parsedMarkdownContent = this.markdownParser.parse(value);
if (parsedMarkdownContent === null) {
return document.createDocumentFragment();
}

return this.domSerializer.serializeFragment(
parsedMarkdownContent.content
);
}

private initializeEditor(): void {
// Create div from the constructor because the TipTap editor requires its host element before the template is instantiated.
this.editor = document.createElement('div');
this.editor.className = 'editor';
this.editor.setAttribute('aria-multiline', 'true');
this.editor.setAttribute('role', 'textbox');

/**
* For more information on the extensions for the supported formatting options, refer to the links below.
* Tiptap marks: https://tiptap.dev/api/marks
Expand Down
4 changes: 4 additions & 0 deletions packages/nimble-components/src/rich-text-editor/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export const styles = css`
overflow: auto;
}
.editor-container {
display: contents;
}
.ProseMirror {
${
/**
Expand Down
6 changes: 1 addition & 5 deletions packages/nimble-components/src/rich-text-editor/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,7 @@ import { iconNumberListTag } from '../icons/number-list';
export const template = html<RichTextEditor>`
<template>
<div class="container">
<section
${ref('editor')}
class="editor"
role="textbox"
aria-multiline="true">
<section ${ref('editorContainer')} class="editor-container">
</section>
<section class="footer-section" part="footer-section">
<${toolbarTag}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { keySpace, keyEnter, keyTab } from '@microsoft/fast-web-utilities';
import type { RichTextEditor } from '..';
import { waitForUpdatesAsync } from '../../testing/async-helpers';
import type { ToggleButton } from '../../toggle-button';
import type { ToolbarButton } from './types';

/**
* Page object for the `nimble-rich-text-editor` component.
Expand Down Expand Up @@ -73,54 +74,54 @@ export class RichTextEditorPageObject {

/**
* To click a formatting button in the footer section, pass its position value as an index (starting from '0')
* @param buttonIndex can be imported from an enum for each button using the `ButtonIndex`.
* @param button can be imported from an enum for each button using the `ButtonIndex`.
*/
public async clickFooterButton(buttonIndex: number): Promise<void> {
const button = this.getFormattingButton(buttonIndex);
button!.click();
public async clickFooterButton(button: ToolbarButton): Promise<void> {
const toggleButton = this.getFormattingButton(button);
toggleButton!.click();
await waitForUpdatesAsync();
}

/**
* To retrieve the checked state of the button, provide its position value as an index (starting from '0')
* @param buttonIndex can be imported from an enum for each button using the `ButtonIndex`.
* @param button can be imported from an enum for each button using the `ButtonIndex`.
*/
public getButtonCheckedState(buttonIndex: number): boolean {
const button = this.getFormattingButton(buttonIndex);
return button!.checked;
public getButtonCheckedState(button: ToolbarButton): boolean {
const toggleButton = this.getFormattingButton(button);
return toggleButton!.checked;
}

/**
* To retrieve the tab index of the button, provide its position value as an index (starting from '0')
* @param buttonIndex can be imported from an enum for each button using the `ButtonIndex`.
* @param button can be imported from an enum for each button using the `ButtonIndex`.
*/
public getButtonTabIndex(buttonIndex: number): number {
const button = this.getFormattingButton(buttonIndex);
return button!.tabIndex;
public getButtonTabIndex(button: ToolbarButton): number {
const toggleButton = this.getFormattingButton(button);
return toggleButton!.tabIndex;
}

/**
* To trigger a space key press for the button, provide its position value as an index (starting from '0')
* @param buttonIndex can be imported from an enum for each button using the `ButtonIndex`.
* @param button can be imported from an enum for each button using the `ButtonIndex`.
*/
public spaceKeyActivatesButton(buttonIndex: number): void {
const button = this.getFormattingButton(buttonIndex)!;
public spaceKeyActivatesButton(button: ToolbarButton): void {
const toggleButton = this.getFormattingButton(button)!;
const event = new KeyboardEvent('keypress', {
key: keySpace
} as KeyboardEventInit);
button.control.dispatchEvent(event);
toggleButton.control.dispatchEvent(event);
}

/**
* To trigger a enter key press for the button, provide its position value as an index (starting from '0')
* @param buttonIndex can be imported from an enum for each button using the `ButtonIndex`.
* @param button can be imported from an enum for each button using the `ButtonIndex`.
*/
public enterKeyActivatesButton(buttonIndex: number): void {
const button = this.getFormattingButton(buttonIndex)!;
public enterKeyActivatesButton(button: ToolbarButton): void {
const toggleButton = this.getFormattingButton(button)!;
const event = new KeyboardEvent('keypress', {
key: keyEnter
} as KeyboardEventInit);
button.control.dispatchEvent(event);
toggleButton.control.dispatchEvent(event);
}

public async setEditorTextContent(value: string): Promise<void> {
Expand Down Expand Up @@ -166,7 +167,7 @@ export class RichTextEditorPageObject {
}

private getFormattingButton(
index: number
index: ToolbarButton
): ToggleButton | null | undefined {
const buttons: NodeListOf<ToggleButton> = this.richTextEditorElement.shadowRoot!.querySelectorAll(
'nimble-toggle-button'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
tokenNames
} from '../../theme-provider/design-token-names';
import { buttonTag } from '../../button';
import { loremIpsum } from '../../utilities/tests/lorem-ipsum';

const metadata: Meta = {
title: 'Tests/Rich Text Editor',
Expand All @@ -23,13 +24,20 @@ const metadata: Meta = {
}
};

const richTextMarkdownString = '1. **Bold*Italics***';

export default metadata;

// prettier-ignore
const component = (): ViewTemplate => html`
<${richTextEditorTag}></${richTextEditorTag}>
`;

const playFunction = (): void => {
const editorNodeList = document.querySelectorAll('nimble-rich-text-editor');
editorNodeList.forEach(element => element.setMarkdown(richTextMarkdownString));
};

const editorSizingTestCase = (
[widthLabel, widthStyle]: [string, string],
[heightLabel, heightStyle]: [string, string]
Expand All @@ -49,6 +57,8 @@ export const richTextEditorThemeMatrix: StoryFn = createMatrixThemeStory(
createMatrix(component)
);

richTextEditorThemeMatrix.play = playFunction;

export const richTextEditorSizing: StoryFn = createStory(html`
${createMatrix(editorSizingTestCase, [
[
Expand All @@ -64,6 +74,47 @@ export const richTextEditorSizing: StoryFn = createStory(html`
])}
`);

const mobileWidthComponent = html`
<${richTextEditorTag} style="padding: 20px; width: 300px;">
<${buttonTag} slot="footer-actions" appearance="ghost">Cancel</${buttonTag}>
<${buttonTag} slot="footer-actions" appearance="outline">Ok</${buttonTag}>
</${richTextEditorTag}>
`;

export const plainTextContentInMobileWidth: StoryFn = createStory(mobileWidthComponent);

plainTextContentInMobileWidth.play = (): void => {
document.querySelector('nimble-rich-text-editor')!.setMarkdown(loremIpsum);
};

const multipleSubPointsContent = `
1. Sub point 1
1. Sub point 2
1. Sub point 3
1. Sub point 4
1. Sub point 5
1. Sub point 6
1. Sub point 7
1. Sub point 8
1. Sub point 9`;

export const multipleSubPointsContentInMobileWidth: StoryFn = createStory(mobileWidthComponent);

multipleSubPointsContentInMobileWidth.play = (): void => {
document
.querySelector('nimble-rich-text-editor')!
.setMarkdown(multipleSubPointsContent);
};

export const longWordContentInMobileWidth: StoryFn = createStory(mobileWidthComponent);

longWordContentInMobileWidth.play = (): void => {
document
.querySelector('nimble-rich-text-editor')!
.setMarkdown(
'ThisIsALongWordWithoutSpaceToTestLongWordInSmallWidthThisIsALongWordWithoutSpaceToTestLongWordInSmallWidth'
);
};
export const hiddenRichTextEditor: StoryFn = createStory(
hiddenWrapper(html`<${richTextEditorTag} hidden></${richTextEditorTag}>`)
);
Loading

0 comments on commit 42928b3

Please sign in to comment.