From 1c0e5777a40988819ee30b14bb34a828692ee602 Mon Sep 17 00:00:00 2001 From: "SOLITONTECH\\vivin.krishna" <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:07:54 +0530 Subject: [PATCH 01/19] restructure folder changes --- package-lock.json | 94 ++-- packages/nimble-components/package.json | 21 +- .../nimble-components/src/all-components.ts | 4 +- .../src/rich-text-editor/index.ts | 361 ------------- .../src/rich-text/editor/index.ts | 481 ++++++++++++++++++ .../editor}/specs/README.md | 12 +- .../specs/spec-images/button-state.png | Bin .../specs/spec-images/editor-sample.png | Bin .../specs/spec-images/viewer-sample.png | Bin .../editor}/styles.ts | 99 +++- .../editor}/template.ts | 26 +- .../testing/rich-text-editor.pageobject.ts | 71 ++- .../editor}/testing/types.ts | 0 .../tests/rich-text-editor-matrix.stories.ts | 98 +++- .../editor}/tests/rich-text-editor.spec.ts | 233 ++++++++- .../editor}/tests/rich-text-editor.stories.ts | 53 +- .../editor}/tests/types.spec.ts | 0 .../models/markdown-parser.ts} | 89 +--- .../rich-text/models/markdown-serializer.ts | 47 ++ .../src/rich-text/viewer/index.ts | 75 +++ .../viewer}/specs/README.md | 0 .../viewer}/styles.ts | 2 +- .../viewer}/template.ts | 0 .../testing/rich-text-viewer.pageobject.ts | 0 .../tests/rich-text-viewer-matrix.stories.ts | 12 +- .../viewer}/tests/rich-text-viewer.spec.ts | 8 +- .../viewer}/tests/rich-text-viewer.stories.ts | 4 +- 27 files changed, 1201 insertions(+), 589 deletions(-) delete mode 100644 packages/nimble-components/src/rich-text-editor/index.ts create mode 100644 packages/nimble-components/src/rich-text/editor/index.ts rename packages/nimble-components/src/{rich-text-editor => rich-text/editor}/specs/README.md (96%) rename packages/nimble-components/src/{rich-text-editor => rich-text/editor}/specs/spec-images/button-state.png (100%) rename packages/nimble-components/src/{rich-text-editor => rich-text/editor}/specs/spec-images/editor-sample.png (100%) rename packages/nimble-components/src/{rich-text-editor => rich-text/editor}/specs/spec-images/viewer-sample.png (100%) rename packages/nimble-components/src/{rich-text-editor => rich-text/editor}/styles.ts (64%) rename packages/nimble-components/src/{rich-text-editor => rich-text/editor}/template.ts (75%) rename packages/nimble-components/src/{rich-text-editor => rich-text/editor}/testing/rich-text-editor.pageobject.ts (75%) rename packages/nimble-components/src/{rich-text-editor => rich-text/editor}/testing/types.ts (100%) rename packages/nimble-components/src/{rich-text-editor => rich-text/editor}/tests/rich-text-editor-matrix.stories.ts (54%) rename packages/nimble-components/src/{rich-text-editor => rich-text/editor}/tests/rich-text-editor.spec.ts (86%) rename packages/nimble-components/src/{rich-text-editor => rich-text/editor}/tests/rich-text-editor.stories.ts (71%) rename packages/nimble-components/src/{rich-text-editor => rich-text/editor}/tests/types.spec.ts (100%) rename packages/nimble-components/src/{rich-text-viewer/index.ts => rich-text/models/markdown-parser.ts} (55%) create mode 100644 packages/nimble-components/src/rich-text/models/markdown-serializer.ts create mode 100644 packages/nimble-components/src/rich-text/viewer/index.ts rename packages/nimble-components/src/{rich-text-viewer => rich-text/viewer}/specs/README.md (100%) rename packages/nimble-components/src/{rich-text-viewer => rich-text/viewer}/styles.ts (96%) rename packages/nimble-components/src/{rich-text-viewer => rich-text/viewer}/template.ts (100%) rename packages/nimble-components/src/{rich-text-viewer => rich-text/viewer}/testing/rich-text-viewer.pageobject.ts (100%) rename packages/nimble-components/src/{rich-text-viewer => rich-text/viewer}/tests/rich-text-viewer-matrix.stories.ts (91%) rename packages/nimble-components/src/{rich-text-viewer => rich-text/viewer}/tests/rich-text-viewer.spec.ts (99%) rename packages/nimble-components/src/{rich-text-viewer => rich-text/viewer}/tests/rich-text-viewer.stories.ts (92%) diff --git a/package-lock.json b/package-lock.json index 17c6824c54..a7c8871191 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9477,9 +9477,9 @@ } }, "node_modules/@tiptap/core": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.0.4.tgz", - "integrity": "sha512-2YOMjRqoBGEP4YGgYpuPuBBJHMeqKOhLnS0WVwjVP84zOmMgZ7A8M6ILC9Xr7Q/qHZCvyBGWOSsI7+3HsEzzYQ==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.1.7.tgz", + "integrity": "sha512-1pqTwlTnwTKQSNQmmTWhs2lwdvd+hFFNFZnrRAfvZhQZA6qPmPmKMNTcYmK38Tn4axKth6mhBamzTJgMZFI7ng==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9489,9 +9489,9 @@ } }, "node_modules/@tiptap/extension-bold": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.0.4.tgz", - "integrity": "sha512-CWSQy1uWkVsen8HUsqhm+oEIxJrCiCENABUbhaVcJL/MqhnP4Trrh1B6O00Yfoc0XToPRRibDaHMFs4A3MSO0g==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.1.7.tgz", + "integrity": "sha512-GZV2D91WENkWd1W29vM4kyGWObcxOKQrY8MuCvTdxni1kobEc/LPZzQ1XiQmiNTvXTMcBz5ckLpezdjASV1dNg==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9501,9 +9501,9 @@ } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.0.4.tgz", - "integrity": "sha512-JSZKBVTaKSuLl5fR4EKE4dOINOrgeRHYA25Vj6cWjgdvpTw5ef7vcUdn9yP4JwTmLRI+VnnMlYL3rqigU3iZNg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.1.7.tgz", + "integrity": "sha512-BReix1wkGNH12DSWGnWPKNu4do92Avh98aLkRS1o1V1Y49/+YGMYtfBXB9obq40o0WqKvk4MoM+rhKbfEc44Gg==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9513,9 +9513,9 @@ } }, "node_modules/@tiptap/extension-document": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.0.4.tgz", - "integrity": "sha512-mCj2fAhnNhIHttPSqfTPSSTGwClGaPYvhT56Ij/Pi4iCrWjPXzC4XnIkIHSS34qS2tJN4XJzr/z7lm3NeLkF1w==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.1.7.tgz", + "integrity": "sha512-tZyoPPmvzti7PEnyulXomEtINd/Oi2S84uOt6gw7DTCnDq5bF5sn1IfN8Icqp9t4jDwyLXy2TL0Zg/sR0a2Ibg==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9525,9 +9525,9 @@ } }, "node_modules/@tiptap/extension-history": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.0.4.tgz", - "integrity": "sha512-3GAUszn1xZx3vniHMiX9BSKmfvb5QOb0oSLXInN+hx80CgJDIHqIFuhx2dyV9I/HWpa0cTxaLWj64kfDzb1JVg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.1.7.tgz", + "integrity": "sha512-8SIEKSImrIkqJThym1bPD13sC4/76UrG+piQ30xKQU4B7zUFCbutvrwYuQHSRvaEt8BPdTv2LWIK+wBkIgbWVA==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9538,9 +9538,9 @@ } }, "node_modules/@tiptap/extension-italic": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.0.4.tgz", - "integrity": "sha512-C/6+qs4Jh8xERRP0wcOopA1+emK8MOkBE4RQx5NbPnT2iCpERP0GlmHBFQIjaYPctZgKFHxsCfRnneS5Xe76+A==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.1.7.tgz", + "integrity": "sha512-7e37f+OFqisdY19nWIthbSNHMJy4+4dec06rUICPrkiuFaADj5HjUQr0dyWpL/LkZh92Wf/rWgp4V/lEwon3jA==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9550,9 +9550,9 @@ } }, "node_modules/@tiptap/extension-list-item": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.0.4.tgz", - "integrity": "sha512-tSkbLgRo1QMNDJttWs9FeRywkuy5T2HdLKKfUcUNzT3s0q5AqIJl7VyimsBL4A6MUfN1qQMZCMHB4pM9Mkluww==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.1.7.tgz", + "integrity": "sha512-hd/E4qQopBXWa6kdFY19qFVgqj4fzdPgAnzdXJ2XW7bC6O2CusmHphRRZ5FBsuspYTN/6/fv0i0jK9rSGlsEyA==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9562,9 +9562,9 @@ } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.0.4.tgz", - "integrity": "sha512-Kfg+8k9p4iJCUKP/yIa18LfUpl9trURSMP/HX3/yQTz9Ul1vDrjxeFjSE5uWNvupcXRAM24js+aYrCmV7zpU+Q==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.1.7.tgz", + "integrity": "sha512-3XIXqbZmYkNzF+8PQ2jcCOCj0lpC3y9HGM/+joPIunhiUiktrIgpbUDv2E1Gq5lJHYqthIeujniI2dB85tkwJQ==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9574,9 +9574,9 @@ } }, "node_modules/@tiptap/extension-paragraph": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.0.4.tgz", - "integrity": "sha512-nDxpopi9WigVqpfi8nU3B0fWYB14EMvKIkutNZo8wJvKGTZufNI8hw66wupIx/jZH1gFxEa5dHerw6aSYuWjgQ==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.1.7.tgz", + "integrity": "sha512-cLqX27hNrXrwZCKrIW8OC3rW2+MT8hhS37+cdqOxZo5hUqQ9EF/puwS0w8uUZ7B3awX9Jm1QZDMjjERLkcmobw==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -9585,10 +9585,23 @@ "@tiptap/core": "^2.0.0" } }, + "node_modules/@tiptap/extension-placeholder": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.1.7.tgz", + "integrity": "sha512-IiBoItYYNS7hb/zmPitw3w6Cylmp9qX+zW+QKe3lDkCNPeKxyQr86AnVLcQYOuXg62cLV9dp+4azZzHoz9SOcg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0", + "@tiptap/pm": "^2.0.0" + } + }, "node_modules/@tiptap/extension-text": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.0.4.tgz", - "integrity": "sha512-i8/VFlVZh7TkAI49KKX5JmC0tM8RGwyg5zUpozxYbLdCOv07AkJt+E1fLJty9mqH4Y5HJMNnyNxsuZ9Ol/ySRA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.1.7.tgz", + "integrity": "sha512-3xaMMMNydLgoS+o+yOvaZF04ui9spJwJZl8VyYgcJKVGGLGRlWHrireXN5/OqXG2jLb/jWqXVx5idppQjX+PMA==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -33325,16 +33338,17 @@ "@ni/nimble-tokens": "^6.3.0", "@tanstack/table-core": "^8.9.3", "@tanstack/virtual-core": "^3.0.0-beta.44", - "@tiptap/core": "^2.0.4", - "@tiptap/extension-bold": "^2.0.4", - "@tiptap/extension-bullet-list": "^2.0.4", - "@tiptap/extension-document": "^2.0.4", - "@tiptap/extension-history": "^2.0.4", - "@tiptap/extension-italic": "^2.0.4", - "@tiptap/extension-list-item": "^2.0.4", - "@tiptap/extension-ordered-list": "^2.0.4", - "@tiptap/extension-paragraph": "^2.0.4", - "@tiptap/extension-text": "^2.0.4", + "@tiptap/core": "^2.1.6", + "@tiptap/extension-bold": "^2.1.6", + "@tiptap/extension-bullet-list": "^2.1.6", + "@tiptap/extension-document": "^2.1.6", + "@tiptap/extension-history": "^2.1.6", + "@tiptap/extension-italic": "^2.1.6", + "@tiptap/extension-list-item": "^2.1.6", + "@tiptap/extension-ordered-list": "^2.1.6", + "@tiptap/extension-paragraph": "^2.1.6", + "@tiptap/extension-placeholder": "^2.1.6", + "@tiptap/extension-text": "^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/package.json b/packages/nimble-components/package.json index f7edd3b9bc..07c757fa6c 100644 --- a/packages/nimble-components/package.json +++ b/packages/nimble-components/package.json @@ -64,16 +64,17 @@ "@ni/nimble-tokens": "^6.3.0", "@tanstack/table-core": "^8.9.3", "@tanstack/virtual-core": "^3.0.0-beta.44", - "@tiptap/core": "^2.0.4", - "@tiptap/extension-bold": "^2.0.4", - "@tiptap/extension-bullet-list": "^2.0.4", - "@tiptap/extension-document": "^2.0.4", - "@tiptap/extension-history": "^2.0.4", - "@tiptap/extension-italic": "^2.0.4", - "@tiptap/extension-list-item": "^2.0.4", - "@tiptap/extension-ordered-list": "^2.0.4", - "@tiptap/extension-paragraph": "^2.0.4", - "@tiptap/extension-text": "^2.0.4", + "@tiptap/core": "^2.1.6", + "@tiptap/extension-bold": "^2.1.6", + "@tiptap/extension-bullet-list": "^2.1.6", + "@tiptap/extension-document": "^2.1.6", + "@tiptap/extension-history": "^2.1.6", + "@tiptap/extension-italic": "^2.1.6", + "@tiptap/extension-list-item": "^2.1.6", + "@tiptap/extension-ordered-list": "^2.1.6", + "@tiptap/extension-paragraph": "^2.1.6", + "@tiptap/extension-placeholder": "^2.1.6", + "@tiptap/extension-text": "^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/all-components.ts b/packages/nimble-components/src/all-components.ts index 4ed75dd127..db13e259ad 100644 --- a/packages/nimble-components/src/all-components.ts +++ b/packages/nimble-components/src/all-components.ts @@ -33,8 +33,8 @@ import './menu-item'; import './number-field'; import './radio'; import './radio-group'; -import './rich-text-editor'; -import './rich-text-viewer'; +import './rich-text/editor'; +import './rich-text/viewer'; import './select'; import './spinner'; import './switch'; diff --git a/packages/nimble-components/src/rich-text-editor/index.ts b/packages/nimble-components/src/rich-text-editor/index.ts deleted file mode 100644 index b88609bf97..0000000000 --- a/packages/nimble-components/src/rich-text-editor/index.ts +++ /dev/null @@ -1,361 +0,0 @@ -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'; -import History from '@tiptap/extension-history'; -import Italic from '@tiptap/extension-italic'; -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 { template } from './template'; -import { styles } from './styles'; -import type { ToggleButton } from '../toggle-button'; - -declare global { - interface HTMLElementTagNameMap { - 'nimble-rich-text-editor': RichTextEditor; - } -} - -/** - * A nimble styled rich text editor - */ -export class RichTextEditor extends FoundationElement { - /** - * @internal - */ - @observable - public boldButton!: ToggleButton; - - /** - * @internal - */ - @observable - public italicsButton!: ToggleButton; - - /** - * @internal - */ - @observable - public bulletListButton!: ToggleButton; - - /** - * @internal - */ - @observable - public numberedListButton!: ToggleButton; - - /** - * @internal - */ - 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(); - if (!this.editor.isConnected) { - this.editorContainer.append(this.editor); - } - this.bindEditorTransactionEvent(); - } - - /** - * @internal - */ - public override disconnectedCallback(): void { - super.disconnectedCallback(); - this.unbindEditorTransactionEvent(); - } - - /** - * Toggle the bold mark and focus back to the editor - * @internal - */ - public boldButtonClick(): void { - this.tiptapEditor.chain().focus().toggleBold().run(); - } - - /** - * Toggle the bold mark and focus back to the editor - * @internal - */ - public boldButtonKeyDown(event: KeyboardEvent): boolean { - if (this.keyActivatesButton(event)) { - this.tiptapEditor.chain().focus().toggleBold().run(); - return false; - } - return true; - } - - /** - * Toggle the italics mark and focus back to the editor - * @internal - */ - public italicsButtonClick(): void { - this.tiptapEditor.chain().focus().toggleItalic().run(); - } - - /** - * Toggle the italics mark and focus back to the editor - * @internal - */ - public italicsButtonKeyDown(event: KeyboardEvent): boolean { - if (this.keyActivatesButton(event)) { - this.tiptapEditor.chain().focus().toggleItalic().run(); - return false; - } - return true; - } - - /** - * Toggle the unordered list node and focus back to the editor - * @internal - */ - public bulletListButtonClick(): void { - this.tiptapEditor.chain().focus().toggleBulletList().run(); - } - - /** - * Toggle the unordered list node and focus back to the editor - * @internal - */ - public bulletListButtonKeyDown(event: KeyboardEvent): boolean { - if (this.keyActivatesButton(event)) { - this.tiptapEditor.chain().focus().toggleBulletList().run(); - return false; - } - return true; - } - - /** - * Toggle the ordered list node and focus back to the editor - * @internal - */ - public numberedListButtonClick(): void { - this.tiptapEditor.chain().focus().toggleOrderedList().run(); - } - - /** - * Toggle the ordered list node and focus back to the editor - * @internal - */ - public numberedListButtonKeyDown(event: KeyboardEvent): boolean { - if (this.keyActivatesButton(event)) { - this.tiptapEditor.chain().focus().toggleOrderedList().run(); - return false; - } - 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 - */ - public stopEventPropagation(event: Event): boolean { - // Don't bubble the 'change' event from the toggle button because - // all the formatting button has its own 'toggle' event through 'click' and 'keydown'. - event.stopPropagation(); - 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 - * Tiptap nodes: https://tiptap.dev/api/nodes - */ - this.tiptapEditor = new Editor({ - element: this.editor, - extensions: [ - Document, - Paragraph, - Text, - BulletList, - OrderedList, - ListItem, - Bold, - Italic, - History - ] - }); - } - - /** - * Binding the "transaction" event to the editor allows continuous monitoring the events and updating the button state in response to - * various actions such as mouse events, keyboard events, changes in the editor content etc,. - * https://tiptap.dev/api/events#transaction - */ - private bindEditorTransactionEvent(): void { - this.tiptapEditor.on('transaction', () => { - this.updateEditorButtonsState(); - }); - } - - private unbindEditorTransactionEvent(): void { - this.tiptapEditor.off('transaction'); - } - - private updateEditorButtonsState(): void { - this.boldButton.checked = this.tiptapEditor.isActive('bold'); - this.italicsButton.checked = this.tiptapEditor.isActive('italic'); - this.bulletListButton.checked = this.tiptapEditor.isActive('bulletList'); - this.numberedListButton.checked = this.tiptapEditor.isActive('orderedList'); - } - - private keyActivatesButton(event: KeyboardEvent): boolean { - switch (event.key) { - case keySpace: - case keyEnter: - return true; - default: - return false; - } - } -} - -const nimbleRichTextEditor = RichTextEditor.compose({ - baseName: 'rich-text-editor', - template, - styles -}); - -DesignSystem.getOrCreate() - .withPrefix('nimble') - .register(nimbleRichTextEditor()); -export const richTextEditorTag = DesignSystem.tagFor(RichTextEditor); diff --git a/packages/nimble-components/src/rich-text/editor/index.ts b/packages/nimble-components/src/rich-text/editor/index.ts new file mode 100644 index 0000000000..173e7a9cf7 --- /dev/null +++ b/packages/nimble-components/src/rich-text/editor/index.ts @@ -0,0 +1,481 @@ +import { observable, attr, DOM } from '@microsoft/fast-element'; +import { + applyMixins, + ARIAGlobalStatesAndProperties, + DesignSystem, + FoundationElement +} from '@microsoft/fast-foundation'; +import { keyEnter, keySpace } from '@microsoft/fast-web-utilities'; +import { Editor, AnyExtension, Extension } 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 ListItem from '@tiptap/extension-list-item'; +import OrderedList from '@tiptap/extension-ordered-list'; +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 { template } from './template'; +import { styles } from './styles'; +import type { ToggleButton } from '../../toggle-button'; +import type { ErrorPattern } from '../../patterns/error/types'; +import { RichTextMarkdownParser } from '../models/markdown-parser'; +import { richTextMarkdownSerializer } from '../models/markdown-serializer'; + +declare global { + interface HTMLElementTagNameMap { + 'nimble-rich-text-editor': RichTextEditor; + } +} + +/** + * A nimble styled rich text editor + */ +export class RichTextEditor extends FoundationElement implements ErrorPattern { + /** + * @internal + */ + public editor = this.createEditor(); + + /** + * @internal + */ + public tiptapEditor = this.createTiptapEditor(); + + /** + * Whether to disable user from editing and interacting with toolbar buttons + * + * @public + * HTML Attribute: disabled + */ + @attr({ mode: 'boolean' }) + public disabled = false; + + /** + * Whether to hide the footer of the rich text editor + * + * @public + * HTML Attribute: footer-hidden + */ + @attr({ attribute: 'footer-hidden', mode: 'boolean' }) + public footerHidden = false; + + /** + * Whether to display the error state. + * + * @public + * HTML Attribute: error-visible + */ + @attr({ attribute: 'error-visible', mode: 'boolean' }) + public errorVisible = false; + + /** + * A message explaining why the value is invalid. + * + * @public + * HTML Attribute: error-text + */ + @attr({ attribute: 'error-text' }) + public errorText?: string; + + /** + * @public + * HTML Attribute: placeholder + */ + @attr + public placeholder?: string; + + /** + * True if the editor is empty or contains only whitespace, false otherwise. + * + * @public + */ + public get empty(): boolean { + // Tiptap [isEmpty](https://tiptap.dev/api/editor#is-empty) returns false even if the editor has only whitespace. + // However, the expectation is to return true if the editor is empty or contains only whitespace. + // Hence, by retrieving the current text content using Tiptap state docs and then trimming the string to determine whether it is empty or not. + return this.tiptapEditor.state.doc.textContent.trim().length === 0; + } + + /** + * @internal + */ + @observable + public boldButton!: ToggleButton; + + /** + * @internal + */ + @observable + public italicsButton!: ToggleButton; + + /** + * @internal + */ + @observable + public bulletListButton!: ToggleButton; + + /** + * @internal + */ + @observable + public numberedListButton!: ToggleButton; + + /** + * The width of the vertical scrollbar, if displayed. + * @internal + */ + @observable + public scrollbarWidth = -1; + + /** + * @internal + */ + public editorContainer!: HTMLDivElement; + + private resizeObserver?: ResizeObserver; + private updateScrollbarWidthQueued = false; + + private readonly markdownParser = new RichTextMarkdownParser(); + private readonly markdownSerializer = richTextMarkdownSerializer(); + private readonly xmlSerializer = new XMLSerializer(); + + /** + * @internal + */ + public override connectedCallback(): void { + super.connectedCallback(); + if (!this.editor.isConnected) { + this.editorContainer.append(this.editor); + } + this.bindEditorTransactionEvent(); + this.bindEditorUpdateEvent(); + this.stopNativeInputEventPropagation(); + this.resizeObserver = new ResizeObserver(() => this.onResize()); + this.resizeObserver.observe(this); + } + + /** + * @internal + */ + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this.unbindEditorTransactionEvent(); + this.unbindEditorUpdateEvent(); + this.unbindNativeInputEvent(); + this.resizeObserver?.disconnect(); + } + + /** + * @internal + */ + public disabledChanged(): void { + this.tiptapEditor.setEditable(!this.disabled); + this.setEditorTabIndex(); + this.editor.setAttribute( + 'aria-disabled', + this.disabled ? 'true' : 'false' + ); + } + + /** + * Update the placeholder text and view of the editor. + * @internal + */ + public placeholderChanged(): void { + const placeholderExtension = this.getTipTapExtension( + 'placeholder' + ) as Extension; + placeholderExtension.options.placeholder = this.placeholder ?? ''; + this.tiptapEditor.view.dispatch(this.tiptapEditor.state.tr); + + this.queueUpdateScrollbarWidth(); + } + + /** + * @internal + */ + public ariaLabelChanged(): void { + if (this.ariaLabel !== null && this.ariaLabel !== undefined) { + this.editor.setAttribute('aria-label', this.ariaLabel); + } else { + this.editor.removeAttribute('aria-label'); + } + } + + /** + * Toggle the bold mark and focus back to the editor + * @internal + */ + public boldButtonClick(): void { + this.tiptapEditor.chain().focus().toggleBold().run(); + } + + /** + * Toggle the bold mark and focus back to the editor + * @internal + */ + public boldButtonKeyDown(event: KeyboardEvent): boolean { + if (this.keyActivatesButton(event)) { + this.tiptapEditor.chain().focus().toggleBold().run(); + return false; + } + return true; + } + + /** + * Toggle the italics mark and focus back to the editor + * @internal + */ + public italicsButtonClick(): void { + this.tiptapEditor.chain().focus().toggleItalic().run(); + } + + /** + * Toggle the italics mark and focus back to the editor + * @internal + */ + public italicsButtonKeyDown(event: KeyboardEvent): boolean { + if (this.keyActivatesButton(event)) { + this.tiptapEditor.chain().focus().toggleItalic().run(); + return false; + } + return true; + } + + /** + * Toggle the unordered list node and focus back to the editor + * @internal + */ + public bulletListButtonClick(): void { + this.tiptapEditor.chain().focus().toggleBulletList().run(); + } + + /** + * Toggle the unordered list node and focus back to the editor + * @internal + */ + public bulletListButtonKeyDown(event: KeyboardEvent): boolean { + if (this.keyActivatesButton(event)) { + this.tiptapEditor.chain().focus().toggleBulletList().run(); + return false; + } + return true; + } + + /** + * Toggle the ordered list node and focus back to the editor + * @internal + */ + public numberedListButtonClick(): void { + this.tiptapEditor.chain().focus().toggleOrderedList().run(); + } + + /** + * Toggle the ordered list node and focus back to the editor + * @internal + */ + public numberedListButtonKeyDown(event: KeyboardEvent): boolean { + if (this.keyActivatesButton(event)) { + this.tiptapEditor.chain().focus().toggleOrderedList().run(); + return false; + } + 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 + */ + public stopEventPropagation(event: Event): boolean { + // Don't bubble the 'change' event from the toggle button because + // all the formatting button has its own 'toggle' event through 'click' and 'keydown'. + event.stopPropagation(); + return false; + } + + private createEditor(): HTMLDivElement { + const editor = document.createElement('div'); + editor.className = 'editor'; + editor.setAttribute('aria-multiline', 'true'); + editor.setAttribute('role', 'textbox'); + editor.setAttribute('aria-disabled', 'false'); + return editor; + } + + private createTiptapEditor(): Editor { + /** + * For more information on the extensions for the supported formatting options, refer to the links below. + * Tiptap marks: https://tiptap.dev/api/marks + * Tiptap nodes: https://tiptap.dev/api/nodes + */ + return new Editor({ + element: this.editor, + extensions: [ + Document, + Paragraph, + Text, + BulletList, + OrderedList, + ListItem, + Bold, + Italic, + History, + Placeholder.configure({ + placeholder: '', + showOnlyWhenEditable: false + }) + ] + }); + } + + /** + * This function takes the Fragment from parseMarkdownToDOM function and return the serialized string using XMLSerializer + */ + private getHtmlContent(markdown: string): string { + const documentFragment = this.markdownParser.parseMarkdownToDOM(markdown); + return this.xmlSerializer.serializeToString(documentFragment); + } + + /** + * Binding the "transaction" event to the editor allows continuous monitoring the events and updating the button state in response to + * various actions such as mouse events, keyboard events, changes in the editor content etc,. + * https://tiptap.dev/api/events#transaction + */ + private bindEditorTransactionEvent(): void { + this.tiptapEditor.on('transaction', () => { + this.updateEditorButtonsState(); + }); + } + + private unbindEditorTransactionEvent(): void { + this.tiptapEditor.off('transaction'); + } + + private updateEditorButtonsState(): void { + this.boldButton.checked = this.tiptapEditor.isActive('bold'); + this.italicsButton.checked = this.tiptapEditor.isActive('italic'); + this.bulletListButton.checked = this.tiptapEditor.isActive('bulletList'); + this.numberedListButton.checked = this.tiptapEditor.isActive('orderedList'); + } + + private keyActivatesButton(event: KeyboardEvent): boolean { + switch (event.key) { + case keySpace: + case keyEnter: + return true; + default: + return false; + } + } + + private unbindEditorUpdateEvent(): void { + this.tiptapEditor.off('update'); + } + + /** + * input event is fired when there is a change in the content of the editor. + * + * https://tiptap.dev/api/events#update + */ + private bindEditorUpdateEvent(): void { + this.tiptapEditor.on('update', () => { + this.$emit('input'); + this.queueUpdateScrollbarWidth(); + }); + } + + /** + * Stopping the native input event propagation emitted by the contenteditable element in the Tiptap + * since there is an issue (linked below) in ProseMirror where selecting the text and removing it + * does not trigger the native HTMLElement input event. So using the "update" event emitted by the + * Tiptap to capture it as an "input" customEvent in the rich text editor. + * + * Prose Mirror issue: https://discuss.prosemirror.net/t/how-to-handle-select-backspace-delete-cut-type-kind-of-events-handletextinput-or-handledomevents-input-doesnt-help/4844 + */ + private stopNativeInputEventPropagation(): void { + this.tiptapEditor.view.dom.addEventListener('input', event => { + event.stopPropagation(); + }); + } + + private unbindNativeInputEvent(): void { + this.tiptapEditor.view.dom.removeEventListener('input', () => {}); + } + + private queueUpdateScrollbarWidth(): void { + if (!this.$fastController.isConnected) { + return; + } + if (!this.updateScrollbarWidthQueued) { + this.updateScrollbarWidthQueued = true; + DOM.queueUpdate(() => this.updateScrollbarWidth()); + } + } + + private updateScrollbarWidth(): void { + this.updateScrollbarWidthQueued = false; + this.scrollbarWidth = this.tiptapEditor.view.dom.offsetWidth + - this.tiptapEditor.view.dom.clientWidth; + } + + private onResize(): void { + this.scrollbarWidth = this.tiptapEditor.view.dom.offsetWidth + - this.tiptapEditor.view.dom.clientWidth; + } + + private getTipTapExtension( + extensionName: string + ): AnyExtension | undefined { + return this.tiptapEditor.extensionManager.extensions.find( + extension => extension.name === extensionName + ); + } + + private setEditorTabIndex(): void { + this.tiptapEditor.setOptions({ + editorProps: { + attributes: { + tabindex: this.disabled ? '-1' : '0' + } + } + }); + } +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface RichTextEditor extends ARIAGlobalStatesAndProperties {} +applyMixins(RichTextEditor, ARIAGlobalStatesAndProperties); + +const nimbleRichTextEditor = RichTextEditor.compose({ + baseName: 'rich-text-editor', + template, + styles +}); + +DesignSystem.getOrCreate() + .withPrefix('nimble') + .register(nimbleRichTextEditor()); +export const richTextEditorTag = DesignSystem.tagFor(RichTextEditor); diff --git a/packages/nimble-components/src/rich-text-editor/specs/README.md b/packages/nimble-components/src/rich-text/editor/specs/README.md similarity index 96% rename from packages/nimble-components/src/rich-text-editor/specs/README.md rename to packages/nimble-components/src/rich-text/editor/specs/README.md index d5928ee243..1157db3c4b 100644 --- a/packages/nimble-components/src/rich-text-editor/specs/README.md +++ b/packages/nimble-components/src/rich-text/editor/specs/README.md @@ -102,8 +102,8 @@ Example usage of the `nimble-rich-text-editor` in the application layer is as fo _Props/Attrs_ -- `empty` - is a read-only property that indicates whether the editor is empty or not. This will be achieved through Tiptap's - [isEmpty](https://tiptap.dev/api/editor#is-empty) API. The component and the Angular directive will have a getter method +- `empty` - is a read-only property that indicates whether the editor is empty or not. This will be achieved by retrieving the current text + content from the editor and calculating its length. The component and the Angular directive will have a getter method that can be used to bind it in the Angular application. - `fit-to-content` - is a boolean attribute allows the text area to expand vertically to fit the content. - `placeholder` - is a string attribute to include a placeholder text for the editor when it is empty. This text is passed as plain text (not markdown) @@ -150,6 +150,14 @@ problematic when attempting to clear the editor's content by setting the markdow empty and hasn't undergone processing. To overcome this issue, utilizing `methods` could offer a potential solution, allowing the content to be set regardless of whether it has changed from its previous value. +_empty_ + +We considered utilizing Tiptap's [isEmpty](https://tiptap.dev/api/editor#is-empty) API to determine whether the editor is empty. However, this API +does not return true if the editor only consists of whitespace. In the context of the comments feature, this property is exposed to find out the +editor's empty state, even when it contains only whitespace. This is necessary because the Backend service for comments does not permit the +creation of comments comprised of just whitespace. Consequently, by using this property, we should disable the `OK` button when the editor is +empty. To achieve this, we retrieve the current text content value, trim the string, and return true if its length is zero. + _Events_ - `input` - event emitted when there is a change in the editor. This can be achieved through Tiptap's [update event](https://tiptap.dev/api/events#update). diff --git a/packages/nimble-components/src/rich-text-editor/specs/spec-images/button-state.png b/packages/nimble-components/src/rich-text/editor/specs/spec-images/button-state.png similarity index 100% rename from packages/nimble-components/src/rich-text-editor/specs/spec-images/button-state.png rename to packages/nimble-components/src/rich-text/editor/specs/spec-images/button-state.png diff --git a/packages/nimble-components/src/rich-text-editor/specs/spec-images/editor-sample.png b/packages/nimble-components/src/rich-text/editor/specs/spec-images/editor-sample.png similarity index 100% rename from packages/nimble-components/src/rich-text-editor/specs/spec-images/editor-sample.png rename to packages/nimble-components/src/rich-text/editor/specs/spec-images/editor-sample.png diff --git a/packages/nimble-components/src/rich-text-editor/specs/spec-images/viewer-sample.png b/packages/nimble-components/src/rich-text/editor/specs/spec-images/viewer-sample.png similarity index 100% rename from packages/nimble-components/src/rich-text-editor/specs/spec-images/viewer-sample.png rename to packages/nimble-components/src/rich-text/editor/specs/spec-images/viewer-sample.png diff --git a/packages/nimble-components/src/rich-text-editor/styles.ts b/packages/nimble-components/src/rich-text/editor/styles.ts similarity index 64% rename from packages/nimble-components/src/rich-text-editor/styles.ts rename to packages/nimble-components/src/rich-text/editor/styles.ts index e32148e60e..2af9ce8127 100644 --- a/packages/nimble-components/src/rich-text-editor/styles.ts +++ b/packages/nimble-components/src/rich-text/editor/styles.ts @@ -1,17 +1,24 @@ import { css } from '@microsoft/fast-element'; import { display } from '@microsoft/fast-foundation'; import { + bodyDisabledFontColor, bodyFont, bodyFontColor, borderHoverColor, borderRgbPartialColor, borderWidth, + controlLabelFontColor, + controlLabelDisabledFontColor, + failColor, + iconSize, smallDelay, standardPadding -} from '../theme-provider/design-tokens'; +} from '../../theme-provider/design-tokens'; +import { styles as errorStyles } from '../../patterns/error/styles'; export const styles = css` ${display('inline-flex')} + ${errorStyles} :host { font: ${bodyFont}; @@ -21,6 +28,10 @@ export const styles = css` --ni-private-rich-text-editor-hover-indicator-width: calc( ${borderWidth} + 1px ); + ${ + /** Initial height of rich text editor with one line space when the footer is visible. */ '' + } + height: 82px; --ni-private-rich-text-editor-footer-section-height: 40px; ${ /** Minimum width is added to accommodate all the possible buttons in the toolbar and to support the mobile width. */ '' @@ -29,6 +40,7 @@ export const styles = css` } .container { + box-sizing: border-box; display: flex; flex-direction: column; position: relative; @@ -60,38 +72,56 @@ export const styles = css` } } + :host([disabled]) .container { + color: ${bodyDisabledFontColor}; + border: ${borderWidth} solid rgba(${borderRgbPartialColor}, 0.1); + } + + :host([error-visible]) .container { + border-bottom-color: ${failColor}; + } + :host(:hover) .container::after { - width: 100%; + width: calc(100% + 2 * ${borderWidth}); + } + + :host([disabled]:hover) .container::after { + width: 0px; + } + + :host([error-visible]) .container::after { + border-bottom-color: ${failColor}; + } + + .editor-container { + display: contents; } .editor { + display: flex; + flex-direction: column; border: ${borderWidth} solid transparent; border-radius: 0px; - height: calc( - 100% - var(--ni-private-rich-text-editor-footer-section-height) - ); - overflow: auto; + flex: 1; + overflow: hidden; } - .editor-container { - display: contents; + :host([footer-hidden]) .editor { + height: 100%; } .ProseMirror { - ${ - /** - * Min height represents the one line space for the initial view and max height is referred from the visual design. - * However, max height will be `fit-content` when the `fit-to-content` attribute for the editor component is implemented. - */ '' - } - min-height: 32px; - max-height: 132px; + overflow: auto; height: 100%; - border: ${borderWidth} solid transparent; + border: 0px; border-radius: 0px; background-color: transparent; font: inherit; padding: 8px; + ${ + /* This padding ensures that showing/hiding the error icon doesn't affect text layout */ '' + } + padding-right: calc(${iconSize}); box-sizing: border-box; position: relative; color: inherit; @@ -139,15 +169,39 @@ export const styles = css` margin-block: 0; } + ${ + /** + * Styles provided by Tiptap are necessary to display the placeholder value when the editor is empty. + * Tiptap doc reference: https://tiptap.dev/api/extensions/placeholder#additional-setup + */ '' + } + .ProseMirror p.is-editor-empty:first-child::before { + color: ${controlLabelFontColor}; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; + word-break: break-word; + } + + :host([disabled]) .ProseMirror p.is-editor-empty:first-child::before { + color: ${controlLabelDisabledFontColor}; + } + .footer-section { display: flex; justify-content: space-between; + flex-shrink: 0; border: ${borderWidth} solid transparent; border-top-color: rgba(${borderRgbPartialColor}, 0.1); height: var(--ni-private-rich-text-editor-footer-section-height); overflow: hidden; } + :host([footer-hidden]) .footer-section { + display: none; + } + nimble-toolbar::part(positioning-region) { background: transparent; padding-right: 8px; @@ -164,4 +218,15 @@ export const styles = css` gap: ${standardPadding}; place-items: center; } + + :host([error-visible]) .error-icon { + display: none; + } + + :host([error-visible]) .error-icon.scrollbar-width-calculated { + display: inline-flex; + position: absolute; + top: calc(${standardPadding} / 2); + right: var(--ni-private-rich-text-editor-scrollbar-width); + } `; diff --git a/packages/nimble-components/src/rich-text-editor/template.ts b/packages/nimble-components/src/rich-text/editor/template.ts similarity index 75% rename from packages/nimble-components/src/rich-text-editor/template.ts rename to packages/nimble-components/src/rich-text/editor/template.ts index e8db67808a..f171ade200 100644 --- a/packages/nimble-components/src/rich-text-editor/template.ts +++ b/packages/nimble-components/src/rich-text/editor/template.ts @@ -1,11 +1,13 @@ import { html, ref } from '@microsoft/fast-element'; import type { RichTextEditor } from '.'; -import { toolbarTag } from '../toolbar'; -import { toggleButtonTag } from '../toggle-button'; -import { iconBoldBTag } from '../icons/bold-b'; -import { iconItalicITag } from '../icons/italic-i'; -import { iconListTag } from '../icons/list'; -import { iconNumberListTag } from '../icons/number-list'; +import { toolbarTag } from '../../toolbar'; +import { toggleButtonTag } from '../../toggle-button'; +import { iconBoldBTag } from '../../icons/bold-b'; +import { iconItalicITag } from '../../icons/italic-i'; +import { iconListTag } from '../../icons/list'; +import { iconNumberListTag } from '../../icons/number-list'; +import { errorTextTemplate } from '../../patterns/error/template'; +import { iconExclamationMarkTag } from '../../icons/exclamation-mark'; // prettier-ignore export const template = html` @@ -13,12 +15,18 @@ export const template = html`
-
`; 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 similarity index 75% rename from packages/nimble-components/src/rich-text-editor/testing/rich-text-editor.pageobject.ts rename to packages/nimble-components/src/rich-text/editor/testing/rich-text-editor.pageobject.ts index 2ad02b8ee3..e81c375ab6 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 @@ -1,7 +1,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 { waitForUpdatesAsync } from '../../../testing/async-helpers'; +import type { ToggleButton } from '../../../toggle-button'; import type { ToolbarButton } from './types'; /** @@ -72,38 +72,22 @@ export class RichTextEditorPageObject { await waitForUpdatesAsync(); } - /** - * To click a formatting button in the footer section, pass its position value as an index (starting from '0') - * @param button can be imported from an enum for each button using the `ButtonIndex`. - */ public async clickFooterButton(button: ToolbarButton): Promise { 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 button can be imported from an enum for each button using the `ButtonIndex`. - */ 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 button can be imported from an enum for each button using the `ButtonIndex`. - */ 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 button can be imported from an enum for each button using the `ButtonIndex`. - */ public spaceKeyActivatesButton(button: ToolbarButton): void { const toggleButton = this.getFormattingButton(button)!; const event = new KeyboardEvent('keypress', { @@ -112,10 +96,6 @@ export class RichTextEditorPageObject { toggleButton.control.dispatchEvent(event); } - /** - * To trigger a enter key press for the button, provide its position value as an index (starting from '0') - * @param button can be imported from an enum for each button using the `ButtonIndex`. - */ public enterKeyActivatesButton(button: ToolbarButton): void { const toggleButton = this.getFormattingButton(button)!; const event = new KeyboardEvent('keypress', { @@ -156,10 +136,53 @@ export class RichTextEditorPageObject { .map(el => el.textContent || ''); } + public getEditorTabIndex(): string { + return this.getTiptapEditor()?.getAttribute('tabindex') ?? ''; + } + + public async setFooterHidden(footerHidden: boolean): Promise { + if (footerHidden) { + this.richTextEditorElement.setAttribute('footer-hidden', ''); + } else { + this.richTextEditorElement.removeAttribute('footer-hidden'); + } + await waitForUpdatesAsync(); + } + + public isFooterHidden(): boolean { + const footerSection = this.getFooter()!; + return window.getComputedStyle(footerSection).display === 'none'; + } + + public async setDisabled(disabled: boolean): Promise { + if (disabled) { + this.richTextEditorElement.setAttribute('disabled', ''); + } else { + this.richTextEditorElement.removeAttribute('disabled'); + } + await waitForUpdatesAsync(); + } + + public isButtonDisabled(button: ToolbarButton): boolean { + const toggleButton = this.getFormattingButton(button)!; + return toggleButton.hasAttribute('disabled'); + } + + public getPlaceholderValue(): string { + const editor = this.getTiptapEditor()!; + return editor.firstElementChild?.getAttribute('data-placeholder') ?? ''; + } + private getEditorSection(): Element | null | undefined { return this.richTextEditorElement.shadowRoot?.querySelector('.editor'); } + private getFooter(): Element | null | undefined { + return this.richTextEditorElement.shadowRoot!.querySelector( + '.footer-section' + ); + } + private getTiptapEditor(): Element | null | undefined { return this.richTextEditorElement.shadowRoot?.querySelector( '.ProseMirror' @@ -167,11 +190,11 @@ export class RichTextEditorPageObject { } private getFormattingButton( - index: ToolbarButton + button: ToolbarButton ): ToggleButton | null | undefined { const buttons: NodeListOf = this.richTextEditorElement.shadowRoot!.querySelectorAll( 'nimble-toggle-button' ); - return buttons[index]; + return buttons[button]; } } diff --git a/packages/nimble-components/src/rich-text-editor/testing/types.ts b/packages/nimble-components/src/rich-text/editor/testing/types.ts similarity index 100% rename from packages/nimble-components/src/rich-text-editor/testing/types.ts rename to packages/nimble-components/src/rich-text/editor/testing/types.ts 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 similarity index 54% rename from packages/nimble-components/src/rich-text-editor/tests/rich-text-editor-matrix.stories.ts rename to packages/nimble-components/src/rich-text/editor/tests/rich-text-editor-matrix.stories.ts index a88a07e49a..42223413be 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 @@ -3,19 +3,25 @@ import { html, ViewTemplate } from '@microsoft/fast-element'; import { createMatrixThemeStory, createStory -} from '../../utilities/tests/storybook'; +} from '../../../utilities/tests/storybook'; import { createMatrix, sharedMatrixParameters -} from '../../utilities/tests/matrix'; -import { hiddenWrapper } from '../../utilities/tests/hidden'; +} from '../../../utilities/tests/matrix'; +import { hiddenWrapper } from '../../../utilities/tests/hidden'; import { richTextEditorTag } from '..'; import { cssPropertyFromTokenName, tokenNames -} from '../../theme-provider/design-token-names'; -import { buttonTag } from '../../button'; -import { loremIpsum } from '../../utilities/tests/lorem-ipsum'; +} from '../../../theme-provider/design-token-names'; +import { buttonTag } from '../../../button'; +import { loremIpsum } from '../../../utilities/tests/lorem-ipsum'; +import { + DisabledState, + ErrorState, + disabledStates, + errorStates +} from '../../../utilities/tests/states'; const metadata: Meta = { title: 'Tests/Rich Text Editor', @@ -28,9 +34,43 @@ const richTextMarkdownString = '1. **Bold*Italics***'; export default metadata; +const footerHiddenStates = [ + ['Footer Visible', false], + ['Footer Hidden', true] +] as const; +type FooterHiddenState = (typeof footerHiddenStates)[number]; + +const placeholderValueStates = [ + ['', null], + ['Placeholder', 'Placeholder text'] +] as const; +type PlaceholderValueStates = (typeof placeholderValueStates)[number]; + // prettier-ignore -const component = (): ViewTemplate => html` - <${richTextEditorTag}> +const component = ( + [disabledName, disabled]: DisabledState, + [footerHiddenName, footerHidden]: FooterHiddenState, + [errorStateName, isError, errorText]: ErrorState, + [placeholderName, placeholderText]: PlaceholderValueStates +): ViewTemplate => html` +

+ ${() => footerHiddenName} ${() => errorStateName} ${() => placeholderName} ${() => disabledName} +

+ <${richTextEditorTag} + style="margin: 5px 0px; width: 500px;" + ?disabled="${() => disabled}" + ?footer-hidden="${() => footerHidden}" + ?error-visible="${() => isError}" + error-text="${() => errorText}" + placeholder="${() => placeholderText}" + > + `; const playFunction = (): void => { @@ -38,15 +78,22 @@ const playFunction = (): void => { editorNodeList.forEach(element => element.setMarkdown(richTextMarkdownString)); }; +const longTextPlayFunction = (): void => { + const editorNodeList = document.querySelectorAll('nimble-rich-text-editor'); + editorNodeList.forEach(element => element.setMarkdown( + `${loremIpsum}\n\n **${loremIpsum}**\n\n ${loremIpsum}` + )); +}; + const editorSizingTestCase = ( [widthLabel, widthStyle]: [string, string], [heightLabel, heightStyle]: [string, string] ): ViewTemplate => html`

${widthLabel}; ${heightLabel}

+ )}); margin-bottom: 0px;">${() => widthLabel}; ${() => heightLabel}

- <${richTextEditorTag} style="${widthStyle}; ${heightStyle};"> + <${richTextEditorTag} style="${() => widthStyle}; ${() => heightStyle};"> <${buttonTag} slot="footer-actions" appearance="ghost">Cancel <${buttonTag} slot="footer-actions" appearance="outline">Ok @@ -54,11 +101,34 @@ const editorSizingTestCase = ( `; export const richTextEditorThemeMatrix: StoryFn = createMatrixThemeStory( - createMatrix(component) + createMatrix(component, [ + disabledStates, + footerHiddenStates, + errorStates, + [placeholderValueStates[0]] + ]) ); - richTextEditorThemeMatrix.play = playFunction; +export const errorStateThemeMatrixWithLengthyContent: StoryFn = createMatrixThemeStory( + createMatrix(component, [ + [disabledStates[0]], + [footerHiddenStates[0]], + errorStates, + [placeholderValueStates[0]] + ]) +); +errorStateThemeMatrixWithLengthyContent.play = longTextPlayFunction; + +export const placeholderStateThemeMatrix: StoryFn = createMatrixThemeStory( + createMatrix(component, [ + disabledStates, + [footerHiddenStates[0]], + [errorStates[0]], + placeholderValueStates + ]) +); + export const richTextEditorSizing: StoryFn = createStory(html` ${createMatrix(editorSizingTestCase, [ [ @@ -82,7 +152,6 @@ const mobileWidthComponent = html` `; export const plainTextContentInMobileWidth: StoryFn = createStory(mobileWidthComponent); - plainTextContentInMobileWidth.play = (): void => { document.querySelector('nimble-rich-text-editor')!.setMarkdown(loremIpsum); }; @@ -99,7 +168,6 @@ const multipleSubPointsContent = ` 1. Sub point 9`; export const multipleSubPointsContentInMobileWidth: StoryFn = createStory(mobileWidthComponent); - multipleSubPointsContentInMobileWidth.play = (): void => { document .querySelector('nimble-rich-text-editor')! @@ -107,7 +175,6 @@ multipleSubPointsContentInMobileWidth.play = (): void => { }; export const longWordContentInMobileWidth: StoryFn = createStory(mobileWidthComponent); - longWordContentInMobileWidth.play = (): void => { document .querySelector('nimble-rich-text-editor')! @@ -115,6 +182,7 @@ longWordContentInMobileWidth.play = (): void => { 'ThisIsALongWordWithoutSpaceToTestLongWordInSmallWidthThisIsALongWordWithoutSpaceToTestLongWordInSmallWidth' ); }; + 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 similarity index 86% rename from packages/nimble-components/src/rich-text-editor/tests/rich-text-editor.spec.ts rename to packages/nimble-components/src/rich-text/editor/tests/rich-text-editor.spec.ts index 761db3fb45..7769ef0065 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 @@ -1,12 +1,13 @@ import { html } from '@microsoft/fast-element'; import { richTextEditorTag, RichTextEditor } from '..'; -import { type Fixture, fixture } from '../../utilities/tests/fixture'; -import { getSpecTypeByNamedList } from '../../utilities/tests/parameterized'; +import { type Fixture, fixture } from '../../../utilities/tests/fixture'; +import { getSpecTypeByNamedList } from '../../../utilities/tests/parameterized'; import { RichTextEditorPageObject } from '../testing/rich-text-editor.pageobject'; -import { wackyStrings } from '../../utilities/tests/wacky-strings'; -import type { Button } from '../../button'; -import type { ToggleButton } from '../../toggle-button'; +import { wackyStrings } from '../../../utilities/tests/wacky-strings'; +import type { Button } from '../../../button'; +import type { ToggleButton } from '../../../toggle-button'; import { ToolbarButton } from '../testing/types'; +import { createEventListener } from '../../../utilities/tests/component'; async function setup(): Promise> { return fixture( @@ -47,7 +48,7 @@ describe('RichTextEditor', () => { it('should initialize Tiptap editor', () => { expect(pageObject.editorSectionHasChildNodes()).toBeTrue(); expect(pageObject.getEditorSectionFirstElementChildClassName()).toBe( - 'ProseMirror' + 'tiptap ProseMirror' ); }); @@ -63,6 +64,34 @@ describe('RichTextEditor', () => { expect(editor!.getAttribute('aria-multiline')).toBe('true'); }); + it('should initialize "aria-label" with undefined when there is no "aria-label" set in the element', () => { + const editor = element.shadowRoot?.querySelector('.editor'); + + expect(editor!.hasAttribute('aria-label')).toBeFalse(); + }); + + it('should forwards value of aria-label to internal control', () => { + const editor = element.shadowRoot?.querySelector('.editor'); + element.ariaLabel = 'Rich Text Editor'; + + expect(editor!.getAttribute('aria-label')).toBe('Rich Text Editor'); + }); + + it('should support setting blank "aria-label" value when setting empty string', () => { + const editor = element.shadowRoot?.querySelector('.editor'); + element.ariaLabel = ''; + + expect(editor!.getAttribute('aria-label')).toBe(''); + }); + + it('should remove value of aria-label from internal control when cleared from host', () => { + const editor = element.shadowRoot?.querySelector('.editor'); + element.ariaLabel = 'not empty'; + element.ariaLabel = null; + + expect(editor!.getAttribute('aria-label')).toBeNull(); + }); + it('should have either one of the list buttons checked at the same time on click', async () => { expect( pageObject.getButtonCheckedState(ToolbarButton.bulletList) @@ -146,7 +175,6 @@ describe('RichTextEditor', () => { for (const value of formattingButtons) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `"${value.name}" button click check`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -180,7 +208,6 @@ describe('RichTextEditor', () => { for (const value of formattingButtons) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `"${value.name}" button key press check`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -211,7 +238,6 @@ describe('RichTextEditor', () => { for (const value of formattingButtons) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `"${value.name}" button key press check`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -242,7 +268,6 @@ describe('RichTextEditor', () => { for (const value of formattingButtons) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `"${value.name}" button keyboard shortcut check`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -274,7 +299,6 @@ describe('RichTextEditor', () => { for (const value of formattingButtons) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `"${value.name}" button not propagate change event to parent element`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -603,7 +627,6 @@ describe('RichTextEditor', () => { wackyStrings.forEach(value => { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `wacky string "${value.name}" that are unmodified when rendered the same value within paragraph tag`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -914,7 +937,6 @@ describe('RichTextEditor', () => { const disabled: string[] = []; for (const value of notSupportedMarkdownStrings) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `string "${value.name}" renders as plain text "${value.name}" within paragraph tag`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -946,7 +968,6 @@ describe('RichTextEditor', () => { focused, disabled ); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `wacky string "${value.name}" that are unmodified when set the same "${value.name}" within paragraph tag`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -982,7 +1003,6 @@ describe('RichTextEditor', () => { for (const value of modifiedWackyStrings) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `wacky string "${value.name}" modified when rendered`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -1146,7 +1166,6 @@ describe('RichTextEditor', () => { const disabled: string[] = []; for (const value of notSupportedMarkdownStrings) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `markdown string "${value.name}" returns as plain text "${value.name}" without any change`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -1190,7 +1209,6 @@ describe('RichTextEditor', () => { const disabled: string[] = []; for (const value of specialMarkdownStrings) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `special markdown string "${value.name}" returns as plain text "${value.value}" with added esacpe character`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -1223,7 +1241,6 @@ describe('RichTextEditor', () => { focused, disabled ); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `wacky string "${value.name}" returns unmodified when set the same markdown string"${value.name}"`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -1254,9 +1271,8 @@ describe('RichTextEditor', () => { const disabled: string[] = []; for (const value of wackyStringWithSpecialMarkdownCharacter) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( - ` wacky string contains special markdown syntax "${value.name}" returns as plain text "${value.value}" with added esacpe character`, + ` wacky string contains special markdown syntax "${value.name}" returns as plain text "${value.value}" with added escape character`, // eslint-disable-next-line @typescript-eslint/no-loop-func async () => { element.setMarkdown(value.name); @@ -1286,7 +1302,6 @@ describe('RichTextEditor', () => { for (const value of modifiedWackyStrings) { const specType = getSpecTypeByNamedList(value, focused, disabled); - // eslint-disable-next-line @typescript-eslint/no-loop-func specType( `wacky string "${value.name}" returns modified when assigned`, // eslint-disable-next-line @typescript-eslint/no-loop-func @@ -1302,6 +1317,182 @@ describe('RichTextEditor', () => { ); } }); + + describe('disabled state', () => { + it('should reflect disabled value to the aria-disabled of editor-section', async () => { + const editor = element.shadowRoot?.querySelector('.editor'); + expect(editor!.getAttribute('aria-disabled')).toBe('false'); + + await pageObject.setDisabled(true); + + expect(editor!.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should reflect disabled value to the "contenteditable" attribute of tiptap editor', async () => { + const editor = element.shadowRoot?.querySelector('.ProseMirror'); + expect(editor!.getAttribute('contenteditable')).toBe('true'); + + await pageObject.setDisabled(true); + + expect(editor!.getAttribute('contenteditable')).toBe('false'); + }); + + it('should enable the editor when "disabled" attribute is set and removed', async () => { + const editor = element.shadowRoot?.querySelector('.ProseMirror'); + expect(pageObject.getEditorTabIndex()).toBe('0'); + + await pageObject.setDisabled(true); + await pageObject.setDisabled(false); + + expect(editor!.getAttribute('contenteditable')).toBe('true'); + }); + + it('should change the tabindex value of the editor when disabled value changes', async () => { + expect(pageObject.getEditorTabIndex()).toBe('0'); + + await pageObject.setDisabled(true); + + expect(pageObject.getEditorTabIndex()).toBe('-1'); + }); + + describe('should reflect disabled value to the disabled and aria-disabled state of toggle buttons', () => { + const focused: string[] = []; + const disabled: string[] = []; + for (const value of formattingButtons) { + const specType = getSpecTypeByNamedList( + value, + focused, + disabled + ); + specType( + `for "${value.name}" button`, + // eslint-disable-next-line @typescript-eslint/no-loop-func + async () => { + expect( + pageObject.isButtonDisabled( + value.toolbarButtonIndex + ) + ).toBeFalse(); + + await pageObject.setDisabled(true); + + expect( + pageObject.isButtonDisabled( + value.toolbarButtonIndex + ) + ).toBeTrue(); + } + ); + } + }); + }); + + it('should hide the footer when "footer-hidden" attribute is enabled', async () => { + expect(pageObject.isFooterHidden()).toBeFalse(); + + await pageObject.setFooterHidden(true); + + expect(pageObject.isFooterHidden()).toBeTrue(); + }); + + it('should show the footer when "footer-hidden" attribute is disabled', async () => { + expect(pageObject.isFooterHidden()).toBeFalse(); + + await pageObject.setFooterHidden(true); + await pageObject.setFooterHidden(false); + + expect(pageObject.isFooterHidden()).toBeFalse(); + }); + + it('should fire "input" event when there is an input to the editor', async () => { + const inputEventListener = createEventListener(element, 'input'); + + await pageObject.setEditorTextContent('input'); + await inputEventListener.promise; + + expect(inputEventListener.spy).toHaveBeenCalledTimes(1); + }); + + it('should not fire "input" event when setting the content through "setMarkdown"', () => { + const inputEventListener = createEventListener(element, 'input'); + + element.setMarkdown('input'); + + expect(inputEventListener.spy).not.toHaveBeenCalled(); + }); + + it('should fire "input" event when the text is updated/removed from the editor', async () => { + const inputEventListener = createEventListener(element, 'input'); + + await pageObject.setEditorTextContent('update'); + await inputEventListener.promise; + + expect(inputEventListener.spy).toHaveBeenCalledTimes(1); + + await pageObject.setEditorTextContent(''); + await inputEventListener.promise; + + expect(inputEventListener.spy).toHaveBeenCalledTimes(1); + }); + + it('should initialize "empty" to true and set false when there is content', async () => { + expect(element.empty).toBeTrue(); + + await pageObject.setEditorTextContent('not empty'); + expect(element.empty).toBeFalse(); + + await pageObject.setEditorTextContent(''); + expect(element.empty).toBeTrue(); + }); + + it('should update "empty" when the content is loaded with "setMarkdown"', () => { + expect(element.empty).toBeTrue(); + + element.setMarkdown('not empty'); + expect(element.empty).toBeFalse(); + + element.setMarkdown(''); + expect(element.empty).toBeTrue(); + }); + + it('should return true for "empty" if there is only whitespace', async () => { + expect(element.empty).toBeTrue(); + + await pageObject.setEditorTextContent(' '); + expect(element.empty).toBeTrue(); + + element.setMarkdown(' '); + expect(element.empty).toBeTrue(); + }); + + it('should return true for "empty" even if the placeholder content is set', () => { + expect(element.empty).toBeTrue(); + + element.placeholder = 'Placeholder text'; + expect(element.empty).toBeTrue(); + }); + + it('should initialize the "placeholder" attribute with undefined', () => { + expect(element.placeholder).toBeUndefined(); + }); + + it('should reflect the "placeholder" value to its internal attribute', () => { + expect(pageObject.getPlaceholderValue()).toBe(''); + + element.placeholder = 'Placeholder text'; + + expect(pageObject.getPlaceholderValue()).toBe('Placeholder text'); + }); + + it('should set "placeholder" value to empty when attribute is cleared with an empty string', () => { + element.placeholder = 'Placeholder text'; + + expect(pageObject.getPlaceholderValue()).toBe('Placeholder text'); + + element.placeholder = ''; + + expect(pageObject.getPlaceholderValue()).toBe(''); + }); }); describe('RichTextEditor Before DOM Connection', () => { 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 similarity index 71% rename from packages/nimble-components/src/rich-text-editor/tests/rich-text-editor.stories.ts rename to packages/nimble-components/src/rich-text/editor/tests/rich-text-editor.stories.ts index ff2adc95cf..b068e3a376 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 @@ -1,11 +1,12 @@ import { html, ref, when } from '@microsoft/fast-element'; import type { Meta, StoryObj } from '@storybook/html'; +import { withActions } from '@storybook/addon-actions/decorator'; import { createUserSelectedThemeStory, incubatingWarning -} from '../../utilities/tests/storybook'; +} from '../../../utilities/tests/storybook'; import { RichTextEditor, richTextEditorTag } from '..'; -import { buttonTag } from '../../button'; +import { buttonTag } from '../../../button'; // eslint-disable-next-line @typescript-eslint/no-empty-interface interface RichTextEditorArgs { @@ -14,6 +15,13 @@ interface RichTextEditorArgs { getMarkdown: undefined; editorRef: RichTextEditor; setMarkdownData: (args: RichTextEditorArgs) => void; + disabled: boolean; + footerHidden: boolean; + errorVisible: boolean; + errorText: string; + input: unknown; + empty: unknown; + placeholder: string; } type ExampleDataType = (typeof exampleDataType)[keyof typeof exampleDataType]; @@ -54,11 +62,15 @@ client application must implement that functionality. const metadata: Meta = { title: 'Incubating/Rich Text Editor', tags: ['autodocs'], + decorators: [withActions], parameters: { docs: { description: { component: richTextEditorDescription } + }, + actions: { + handles: ['input'] } }, // prettier-ignore @@ -70,6 +82,11 @@ const metadata: Meta = { <${richTextEditorTag} ${ref('editorRef')} data-unused="${x => x.setMarkdownData(x)}" + ?disabled="${x => x.disabled}" + ?footer-hidden="${x => x.footerHidden}" + ?error-visible="${x => x.errorVisible}" + error-text="${x => x.errorText}" + placeholder="${x => x.placeholder}" > ${when(x => x.footerActionButtons, html` <${buttonTag} appearance="ghost" slot="footer-actions">Cancel @@ -103,11 +120,43 @@ const metadata: Meta = { }, setMarkdownData: { table: { disable: true } + }, + errorVisible: { + description: + 'Whether the editor should be styled to indicate that it is in an invalid state.' + }, + errorText: { + description: + 'A message to be displayed when the editor is in the invalid state explaining why the value is invalid.' + }, + placeholder: { + description: 'Placeholder text to show when editor is empty.' + }, + footerHidden: { + description: + 'Setting `footer-hidden` hides the footer section which consists of all formatting option buttons and the `footer-actions` slot content.' + }, + empty: { + name: 'empty', + description: + 'Read-only boolean value. Returns true if editor is either empty or contains only whitespace.', + control: false + }, + input: { + name: 'input', + description: + 'This event is fired when there is a change in the content of the editor.', + control: false } }, args: { data: exampleDataType.plainString, footerActionButtons: false, + disabled: false, + footerHidden: false, + errorVisible: false, + errorText: 'Value is invalid', + placeholder: 'Placeholder', editorRef: undefined, setMarkdownData: x => { void (async () => { diff --git a/packages/nimble-components/src/rich-text-editor/tests/types.spec.ts b/packages/nimble-components/src/rich-text/editor/tests/types.spec.ts similarity index 100% rename from packages/nimble-components/src/rich-text-editor/tests/types.spec.ts rename to packages/nimble-components/src/rich-text/editor/tests/types.spec.ts diff --git a/packages/nimble-components/src/rich-text-viewer/index.ts b/packages/nimble-components/src/rich-text/models/markdown-parser.ts similarity index 55% rename from packages/nimble-components/src/rich-text-viewer/index.ts rename to packages/nimble-components/src/rich-text/models/markdown-parser.ts index 132daa978d..ab06283a96 100644 --- a/packages/nimble-components/src/rich-text-viewer/index.ts +++ b/packages/nimble-components/src/rich-text/models/markdown-parser.ts @@ -1,60 +1,36 @@ -import { DesignSystem, FoundationElement } from '@microsoft/fast-foundation'; import { schema, defaultMarkdownParser, MarkdownParser } from 'prosemirror-markdown'; import { DOMSerializer } from 'prosemirror-model'; -import { observable } from '@microsoft/fast-element'; -import { template } from './template'; -import { styles } from './styles'; - -declare global { - interface HTMLElementTagNameMap { - 'nimble-rich-text-viewer': RichTextViewer; - } -} /** - * A nimble styled rich text viewer + * Provides markdown parser for rich text components */ -export class RichTextViewer extends FoundationElement { - /** - * - * @public - * Markdown string to render its corresponding rich text content in the component. - */ - @observable - public markdown = ''; - - /** - * @internal - */ - public viewer!: HTMLDivElement; +export class RichTextMarkdownParser { private readonly markdownParser: MarkdownParser; private readonly domSerializer: DOMSerializer; public constructor() { - super(); - this.domSerializer = DOMSerializer.fromSchema(schema); this.markdownParser = this.initializeMarkdownParser(); + this.domSerializer = DOMSerializer.fromSchema(schema); } /** - * @internal - */ - public override connectedCallback(): void { - super.connectedCallback(); - this.updateView(); - } - - /** - * @internal + * + * This function takes a markdown string, parses it using the ProseMirror MarkdownParser, serializes the parsed content into a + * DOM structure using a DOMSerializer, and returns the serialized result. + * If the markdown parser returns null, it will clear the viewer component by creating an empty document fragment. */ - public markdownChanged(): void { - if (this.$fastController.isConnected) { - this.updateView(); + public parseMarkdownToDOM(value: string): HTMLElement | DocumentFragment { + const parsedMarkdownContent = this.markdownParser.parse(value); + if (parsedMarkdownContent === null) { + return document.createDocumentFragment(); } + return this.domSerializer.serializeFragment( + parsedMarkdownContent.content + ); } private initializeMarkdownParser(): MarkdownParser { @@ -80,41 +56,4 @@ export class RichTextViewer extends FoundationElement { defaultMarkdownParser.tokens ); } - - /** - * - * This function takes a markdown string, parses it using the ProseMirror MarkdownParser, serializes the parsed content into a - * DOM structure using a DOMSerializer, and returns the serialized result. - * If the markdown parser returns null, it will clear the viewer component by creating an empty document fragment. - */ - 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 updateView(): void { - if (this.markdown) { - const serializedContent = this.parseMarkdownToDOM(this.markdown); - this.viewer.replaceChildren(serializedContent); - } else { - this.viewer.innerHTML = ''; - } - } } - -const nimbleRichTextViewer = RichTextViewer.compose({ - baseName: 'rich-text-viewer', - template, - styles -}); - -DesignSystem.getOrCreate() - .withPrefix('nimble') - .register(nimbleRichTextViewer()); -export const richTextViewerTag = DesignSystem.tagFor(RichTextViewer); diff --git a/packages/nimble-components/src/rich-text/models/markdown-serializer.ts b/packages/nimble-components/src/rich-text/models/markdown-serializer.ts new file mode 100644 index 0000000000..231bd0dad4 --- /dev/null +++ b/packages/nimble-components/src/rich-text/models/markdown-serializer.ts @@ -0,0 +1,47 @@ +import { + MarkdownSerializer, + defaultMarkdownSerializer, + MarkdownSerializerState +} from 'prosemirror-markdown'; +import type { Node } from 'prosemirror-model'; + +export function richTextMarkdownSerializer(): 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); +} diff --git a/packages/nimble-components/src/rich-text/viewer/index.ts b/packages/nimble-components/src/rich-text/viewer/index.ts new file mode 100644 index 0000000000..dcc5146380 --- /dev/null +++ b/packages/nimble-components/src/rich-text/viewer/index.ts @@ -0,0 +1,75 @@ +import { DesignSystem, FoundationElement } from '@microsoft/fast-foundation'; +import { observable } from '@microsoft/fast-element'; +import { template } from './template'; +import { styles } from './styles'; +import { RichTextMarkdownParser } from '../models/markdown-parser'; + +declare global { + interface HTMLElementTagNameMap { + 'nimble-rich-text-viewer': RichTextViewer; + } +} + +/** + * A nimble styled rich text viewer + */ +export class RichTextViewer extends FoundationElement { + /** + * + * @public + * Markdown string to render its corresponding rich text content in the component. + */ + @observable + public markdown = ''; + + /** + * @internal + */ + public viewer!: HTMLDivElement; + + private readonly markdownParser: RichTextMarkdownParser; + + public constructor() { + super(); + this.markdownParser = new RichTextMarkdownParser(); + } + + /** + * @internal + */ + public override connectedCallback(): void { + super.connectedCallback(); + this.updateView(); + } + + /** + * @internal + */ + public markdownChanged(): void { + if (this.$fastController.isConnected) { + this.updateView(); + } + } + + private updateView(): void { + if (this.markdown) { + const serializedContent = this.markdownParser.parseMarkdownToDOM( + this.markdown + ); + this.viewer.replaceChildren(serializedContent); + } else { + this.viewer.innerHTML = ''; + } + } +} + +const nimbleRichTextViewer = RichTextViewer.compose({ + baseName: 'rich-text-viewer', + template, + styles +}); + +DesignSystem.getOrCreate() + .withPrefix('nimble') + .register(nimbleRichTextViewer()); +export const richTextViewerTag = DesignSystem.tagFor(RichTextViewer); diff --git a/packages/nimble-components/src/rich-text-viewer/specs/README.md b/packages/nimble-components/src/rich-text/viewer/specs/README.md similarity index 100% rename from packages/nimble-components/src/rich-text-viewer/specs/README.md rename to packages/nimble-components/src/rich-text/viewer/specs/README.md diff --git a/packages/nimble-components/src/rich-text-viewer/styles.ts b/packages/nimble-components/src/rich-text/viewer/styles.ts similarity index 96% rename from packages/nimble-components/src/rich-text-viewer/styles.ts rename to packages/nimble-components/src/rich-text/viewer/styles.ts index f40f0522c6..0d9d8fcbfa 100644 --- a/packages/nimble-components/src/rich-text-viewer/styles.ts +++ b/packages/nimble-components/src/rich-text/viewer/styles.ts @@ -5,7 +5,7 @@ import { bodyFontColor, linkActiveFontColor, linkFontColor -} from '../theme-provider/design-tokens'; +} from '../../theme-provider/design-tokens'; export const styles = css` ${display('flex')} diff --git a/packages/nimble-components/src/rich-text-viewer/template.ts b/packages/nimble-components/src/rich-text/viewer/template.ts similarity index 100% rename from packages/nimble-components/src/rich-text-viewer/template.ts rename to packages/nimble-components/src/rich-text/viewer/template.ts diff --git a/packages/nimble-components/src/rich-text-viewer/testing/rich-text-viewer.pageobject.ts b/packages/nimble-components/src/rich-text/viewer/testing/rich-text-viewer.pageobject.ts similarity index 100% rename from packages/nimble-components/src/rich-text-viewer/testing/rich-text-viewer.pageobject.ts rename to packages/nimble-components/src/rich-text/viewer/testing/rich-text-viewer.pageobject.ts diff --git a/packages/nimble-components/src/rich-text-viewer/tests/rich-text-viewer-matrix.stories.ts b/packages/nimble-components/src/rich-text/viewer/tests/rich-text-viewer-matrix.stories.ts similarity index 91% rename from packages/nimble-components/src/rich-text-viewer/tests/rich-text-viewer-matrix.stories.ts rename to packages/nimble-components/src/rich-text/viewer/tests/rich-text-viewer-matrix.stories.ts index 7c91e47565..8e4f36ff3d 100644 --- a/packages/nimble-components/src/rich-text-viewer/tests/rich-text-viewer-matrix.stories.ts +++ b/packages/nimble-components/src/rich-text/viewer/tests/rich-text-viewer-matrix.stories.ts @@ -3,19 +3,19 @@ import { html, ViewTemplate } from '@microsoft/fast-element'; import { createMatrixThemeStory, createStory -} from '../../utilities/tests/storybook'; +} from '../../../utilities/tests/storybook'; import { createMatrix, sharedMatrixParameters -} from '../../utilities/tests/matrix'; -import { hiddenWrapper } from '../../utilities/tests/hidden'; +} from '../../../utilities/tests/matrix'; +import { hiddenWrapper } from '../../../utilities/tests/hidden'; import { richTextViewerTag } from '..'; -import { richTextMarkdownString } from '../../utilities/tests/rich-text-markdown-string'; -import { loremIpsum } from '../../utilities/tests/lorem-ipsum'; +import { richTextMarkdownString } from '../../../utilities/tests/rich-text-markdown-string'; +import { loremIpsum } from '../../../utilities/tests/lorem-ipsum'; import { cssPropertyFromTokenName, tokenNames -} from '../../theme-provider/design-token-names'; +} from '../../../theme-provider/design-token-names'; const metadata: Meta = { title: 'Tests/Rich Text Viewer', diff --git a/packages/nimble-components/src/rich-text-viewer/tests/rich-text-viewer.spec.ts b/packages/nimble-components/src/rich-text/viewer/tests/rich-text-viewer.spec.ts similarity index 99% rename from packages/nimble-components/src/rich-text-viewer/tests/rich-text-viewer.spec.ts rename to packages/nimble-components/src/rich-text/viewer/tests/rich-text-viewer.spec.ts index 1235e90ba1..726feb4a93 100644 --- a/packages/nimble-components/src/rich-text-viewer/tests/rich-text-viewer.spec.ts +++ b/packages/nimble-components/src/rich-text/viewer/tests/rich-text-viewer.spec.ts @@ -1,9 +1,9 @@ import { html } from '@microsoft/fast-element'; -import { RichTextViewer, richTextViewerTag } from '..'; -import { fixture, type Fixture } from '../../utilities/tests/fixture'; +import { fixture, type Fixture } from '../../../utilities/tests/fixture'; import { RichTextViewerPageObject } from '../testing/rich-text-viewer.pageobject'; -import { wackyStrings } from '../../utilities/tests/wacky-strings'; -import { getSpecTypeByNamedList } from '../../utilities/tests/parameterized'; +import { wackyStrings } from '../../../utilities/tests/wacky-strings'; +import { getSpecTypeByNamedList } from '../../../utilities/tests/parameterized'; +import { RichTextViewer, richTextViewerTag } from '..'; async function setup(): Promise> { return fixture( diff --git a/packages/nimble-components/src/rich-text-viewer/tests/rich-text-viewer.stories.ts b/packages/nimble-components/src/rich-text/viewer/tests/rich-text-viewer.stories.ts similarity index 92% rename from packages/nimble-components/src/rich-text-viewer/tests/rich-text-viewer.stories.ts rename to packages/nimble-components/src/rich-text/viewer/tests/rich-text-viewer.stories.ts index cf1e2f14ac..25dfe832e5 100644 --- a/packages/nimble-components/src/rich-text-viewer/tests/rich-text-viewer.stories.ts +++ b/packages/nimble-components/src/rich-text/viewer/tests/rich-text-viewer.stories.ts @@ -3,9 +3,9 @@ import type { Meta, StoryObj } from '@storybook/html'; import { createUserSelectedThemeStory, incubatingWarning -} from '../../utilities/tests/storybook'; +} from '../../../utilities/tests/storybook'; import { richTextViewerTag } from '..'; -import { richTextMarkdownString } from '../../utilities/tests/rich-text-markdown-string'; +import { richTextMarkdownString } from '../../../utilities/tests/rich-text-markdown-string'; interface RichTextViewerArgs { markdown: string; From fa86b5a78f927b771efa5c3bd357f04b713edf77 Mon Sep 17 00:00:00 2001 From: "SOLITONTECH\\vivin.krishna" <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:17:31 +0530 Subject: [PATCH 02/19] Fix component import in angular --- .../rich-text-viewer/nimble-rich-text-viewer.directive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/nimble-rich-text-viewer.directive.ts b/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/nimble-rich-text-viewer.directive.ts index 1440b0d0c0..7609f35d8d 100644 --- a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/nimble-rich-text-viewer.directive.ts +++ b/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/nimble-rich-text-viewer.directive.ts @@ -1,5 +1,5 @@ import { Directive, ElementRef, Input, Renderer2 } from '@angular/core'; -import type { RichTextViewer } from '@ni/nimble-components/dist/esm/rich-text-viewer'; +import type { RichTextViewer } from '@ni/nimble-components/dist/esm/rich-text/viewer'; export type { RichTextViewer }; From c7f895dd2790e5dfdb5b7eb06969cb648901acba Mon Sep 17 00:00:00 2001 From: "SOLITONTECH\\vivin.krishna" <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:33:44 +0530 Subject: [PATCH 03/19] Fixing build issue in angular viewer module --- .../rich-text-viewer/nimble-rich-text-viewer.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/nimble-rich-text-viewer.module.ts b/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/nimble-rich-text-viewer.module.ts index c94455a3a8..d3bc69cf06 100644 --- a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/nimble-rich-text-viewer.module.ts +++ b/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/nimble-rich-text-viewer.module.ts @@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { NimbleRichTextViewerDirective } from './nimble-rich-text-viewer.directive'; -import '@ni/nimble-components/dist/esm/rich-text-viewer'; +import '@ni/nimble-components/dist/esm/rich-text/viewer'; @NgModule({ declarations: [NimbleRichTextViewerDirective], From 01afe477046952ea45b7e97225051687184c014c Mon Sep 17 00:00:00 2001 From: "SOLITONTECH\\vivin.krishna" <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Thu, 31 Aug 2023 15:08:48 +0530 Subject: [PATCH 04/19] Updated the markdown serializer class --- .../src/rich-text/editor/index.ts | 9 +- .../src/rich-text/models/markdown-parser.ts | 1 - .../rich-text/models/markdown-serializer.ts | 91 +++++++++++-------- 3 files changed, 56 insertions(+), 45 deletions(-) diff --git a/packages/nimble-components/src/rich-text/editor/index.ts b/packages/nimble-components/src/rich-text/editor/index.ts index 173e7a9cf7..68b79e63cf 100644 --- a/packages/nimble-components/src/rich-text/editor/index.ts +++ b/packages/nimble-components/src/rich-text/editor/index.ts @@ -23,7 +23,7 @@ import { styles } from './styles'; import type { ToggleButton } from '../../toggle-button'; import type { ErrorPattern } from '../../patterns/error/types'; import { RichTextMarkdownParser } from '../models/markdown-parser'; -import { richTextMarkdownSerializer } from '../models/markdown-serializer'; +import { RichTextMarkdownSerializer } from '../models/markdown-serializer'; declare global { interface HTMLElementTagNameMap { @@ -140,7 +140,7 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { private updateScrollbarWidthQueued = false; private readonly markdownParser = new RichTextMarkdownParser(); - private readonly markdownSerializer = richTextMarkdownSerializer(); + private readonly markdownSerializer = new RichTextMarkdownSerializer(); private readonly xmlSerializer = new XMLSerializer(); /** @@ -300,10 +300,7 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { * @public */ public getMarkdown(): string { - const markdownContent = this.markdownSerializer.serialize( - this.tiptapEditor.state.doc - ); - return markdownContent; + return this.markdownSerializer.serializeToMarkdown(this.tiptapEditor.state.doc); } /** 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 ab06283a96..9a1ae9a2c4 100644 --- a/packages/nimble-components/src/rich-text/models/markdown-parser.ts +++ b/packages/nimble-components/src/rich-text/models/markdown-parser.ts @@ -18,7 +18,6 @@ export class RichTextMarkdownParser { } /** - * * This function takes a markdown string, parses it using the ProseMirror MarkdownParser, serializes the parsed content into a * DOM structure using a DOMSerializer, and returns the serialized result. * If the markdown parser returns null, it will clear the viewer component by creating an empty document fragment. 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 231bd0dad4..1f859ac17a 100644 --- a/packages/nimble-components/src/rich-text/models/markdown-serializer.ts +++ b/packages/nimble-components/src/rich-text/models/markdown-serializer.ts @@ -5,43 +5,58 @@ import { } from 'prosemirror-markdown'; import type { Node } from 'prosemirror-model'; -export function richTextMarkdownSerializer(): 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}. `; - }); - }; +/** + * Provides markdown serializer for rich text components + */ +export class RichTextMarkdownSerializer { + private readonly markdownSerializer: MarkdownSerializer; - /** - * 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); + public constructor() { + this.markdownSerializer = this.initializeMarkdownSerializer(); + } + + public serializeToMarkdown(doc: Node): string { + return this.markdownSerializer.serialize(doc); + } + + 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); + } } From bafbe48ffe3fd126a5ed7143b9b8cfe92fde3d7f Mon Sep 17 00:00:00 2001 From: "SOLITONTECH\\vivin.krishna" <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Thu, 31 Aug 2023 15:56:01 +0530 Subject: [PATCH 05/19] Renaming the initialization markdown serializer method --- .../src/rich-text/models/markdown-serializer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 1f859ac17a..0b2436a47b 100644 --- a/packages/nimble-components/src/rich-text/models/markdown-serializer.ts +++ b/packages/nimble-components/src/rich-text/models/markdown-serializer.ts @@ -12,14 +12,14 @@ export class RichTextMarkdownSerializer { private readonly markdownSerializer: MarkdownSerializer; public constructor() { - this.markdownSerializer = this.initializeMarkdownSerializer(); + this.markdownSerializer = this.initializeMarkdownSerializerForTipTap(); } public serializeToMarkdown(doc: Node): string { return this.markdownSerializer.serialize(doc); } - private initializeMarkdownSerializer(): MarkdownSerializer { + private initializeMarkdownSerializerForTipTap(): 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) From 91bb520c481337193b592e77b493952f6318cb41 Mon Sep 17 00:00:00 2001 From: "SOLITONTECH\\vivin.krishna" <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Thu, 31 Aug 2023 16:29:50 +0530 Subject: [PATCH 06/19] Fix lint errors --- packages/nimble-components/src/rich-text/editor/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/nimble-components/src/rich-text/editor/index.ts b/packages/nimble-components/src/rich-text/editor/index.ts index 68b79e63cf..59815ce72c 100644 --- a/packages/nimble-components/src/rich-text/editor/index.ts +++ b/packages/nimble-components/src/rich-text/editor/index.ts @@ -300,7 +300,9 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { * @public */ public getMarkdown(): string { - return this.markdownSerializer.serializeToMarkdown(this.tiptapEditor.state.doc); + return this.markdownSerializer.serializeToMarkdown( + this.tiptapEditor.state.doc + ); } /** From 3a701565de86f2e4b339939b41b39714f43c51ee Mon Sep 17 00:00:00 2001 From: "SOLITONTECH\\vivin.krishna" <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Thu, 31 Aug 2023 16:32:16 +0530 Subject: [PATCH 07/19] Change files --- ...imble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json | 7 +++++++ ...le-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 change/@ni-nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json create mode 100644 change/@ni-nimble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json diff --git a/change/@ni-nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json b/change/@ni-nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json new file mode 100644 index 0000000000..4cc1292b1d --- /dev/null +++ b/change/@ni-nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Revamp rich text components folder structure", + "packageName": "@ni/nimble-angular", + "email": "123377523+vivinkrishna-ni@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@ni-nimble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json b/change/@ni-nimble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json new file mode 100644 index 0000000000..e64898b55e --- /dev/null +++ b/change/@ni-nimble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Update folder paths of rich text components in the nimble-angular", + "packageName": "@ni/nimble-components", + "email": "123377523+vivinkrishna-ni@users.noreply.github.com", + "dependentChangeType": "patch" +} From d37e001ebcbd0ca77487df463a0453ea99d0aa88 Mon Sep 17 00:00:00 2001 From: "SOLITONTECH\\vivin.krishna" <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Thu, 31 Aug 2023 16:33:45 +0530 Subject: [PATCH 08/19] Update change file description --- ...@ni-nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json | 2 +- ...-nimble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/change/@ni-nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json b/change/@ni-nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json index 4cc1292b1d..8edd5163fd 100644 --- a/change/@ni-nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json +++ b/change/@ni-nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "Revamp rich text components folder structure", + "comment": "Update folder paths for importing rich text components", "packageName": "@ni/nimble-angular", "email": "123377523+vivinkrishna-ni@users.noreply.github.com", "dependentChangeType": "patch" diff --git a/change/@ni-nimble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json b/change/@ni-nimble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json index e64898b55e..9c790f46ee 100644 --- a/change/@ni-nimble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json +++ b/change/@ni-nimble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "Update folder paths of rich text components in the nimble-angular", + "comment": "Revamp rich text components folder structure", "packageName": "@ni/nimble-components", "email": "123377523+vivinkrishna-ni@users.noreply.github.com", "dependentChangeType": "patch" From 2c2d5ac7edaddc535a68e6a9775d748de60eb347 Mon Sep 17 00:00:00 2001 From: "SOLITONTECH\\vivin.krishna" <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Fri, 1 Sep 2023 10:13:10 +0530 Subject: [PATCH 09/19] Resolve merge conflicts --- .../src/rich-text/editor/index.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/nimble-components/src/rich-text/editor/index.ts b/packages/nimble-components/src/rich-text/editor/index.ts index 59815ce72c..41b750cdba 100644 --- a/packages/nimble-components/src/rich-text/editor/index.ts +++ b/packages/nimble-components/src/rich-text/editor/index.ts @@ -212,6 +212,7 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { */ public boldButtonClick(): void { this.tiptapEditor.chain().focus().toggleBold().run(); + this.forceFocusEditor(); } /** @@ -221,6 +222,7 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { public boldButtonKeyDown(event: KeyboardEvent): boolean { if (this.keyActivatesButton(event)) { this.tiptapEditor.chain().focus().toggleBold().run(); + this.forceFocusEditor(); return false; } return true; @@ -232,6 +234,7 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { */ public italicsButtonClick(): void { this.tiptapEditor.chain().focus().toggleItalic().run(); + this.forceFocusEditor(); } /** @@ -241,6 +244,7 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { public italicsButtonKeyDown(event: KeyboardEvent): boolean { if (this.keyActivatesButton(event)) { this.tiptapEditor.chain().focus().toggleItalic().run(); + this.forceFocusEditor(); return false; } return true; @@ -252,6 +256,7 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { */ public bulletListButtonClick(): void { this.tiptapEditor.chain().focus().toggleBulletList().run(); + this.forceFocusEditor(); } /** @@ -261,6 +266,7 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { public bulletListButtonKeyDown(event: KeyboardEvent): boolean { if (this.keyActivatesButton(event)) { this.tiptapEditor.chain().focus().toggleBulletList().run(); + this.forceFocusEditor(); return false; } return true; @@ -272,6 +278,7 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { */ public numberedListButtonClick(): void { this.tiptapEditor.chain().focus().toggleOrderedList().run(); + this.forceFocusEditor(); } /** @@ -281,6 +288,7 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { public numberedListButtonKeyDown(event: KeyboardEvent): boolean { if (this.keyActivatesButton(event)) { this.tiptapEditor.chain().focus().toggleOrderedList().run(); + this.forceFocusEditor(); return false; } return true; @@ -462,6 +470,15 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { } }); } + + // In Firefox browser, once the editor gets focused, the blinking caret will be visible until we click format buttons (Bold, Italic ...) in the Firefox browser (changing focus). + // But once any of the toolbar button is clicked, editor internally has its focus but the blinking caret disappears. + // As a workaround, manually triggering blur and setting focus on editor makes the blinking caret to re-appear. + // Mozilla issue https://bugzilla.mozilla.org/show_bug.cgi?id=1496769 tracks removal of this workaround. + private forceFocusEditor(): void { + this.tiptapEditor.commands.blur(); + this.tiptapEditor.commands.focus(); + } } // eslint-disable-next-line @typescript-eslint/no-empty-interface From b7fe9d8dc2a90053fb0fd4b283c5174d20f53dfb Mon Sep 17 00:00:00 2001 From: "SOLITONTECH\\vivin.krishna" <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Fri, 1 Sep 2023 10:18:03 +0530 Subject: [PATCH 10/19] Update editor component paths for rich text editor --- .../rich-text-editor/nimble-rich-text-editor.directive.ts | 2 +- .../rich-text-editor/nimble-rich-text-editor.module.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/nimble-rich-text-editor.directive.ts b/angular-workspace/projects/ni/nimble-angular/rich-text-editor/nimble-rich-text-editor.directive.ts index abd5a2a0a8..865b5f0439 100644 --- a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/nimble-rich-text-editor.directive.ts +++ b/angular-workspace/projects/ni/nimble-angular/rich-text-editor/nimble-rich-text-editor.directive.ts @@ -1,5 +1,5 @@ import { Directive, ElementRef, EventEmitter, HostListener, Input, Output, Renderer2 } from '@angular/core'; -import type { RichTextEditor } from '@ni/nimble-components/dist/esm/rich-text-editor'; +import type { RichTextEditor } from '@ni/nimble-components/dist/esm/rich-text/editor'; import { BooleanValueOrAttribute, toBooleanProperty } from '@ni/nimble-angular/internal-utilities'; export type { RichTextEditor }; diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/nimble-rich-text-editor.module.ts b/angular-workspace/projects/ni/nimble-angular/rich-text-editor/nimble-rich-text-editor.module.ts index 0c6c57ce98..4164c9f09b 100644 --- a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/nimble-rich-text-editor.module.ts +++ b/angular-workspace/projects/ni/nimble-angular/rich-text-editor/nimble-rich-text-editor.module.ts @@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { NimbleRichTextEditorDirective } from './nimble-rich-text-editor.directive'; -import '@ni/nimble-components/dist/esm/rich-text-editor'; +import '@ni/nimble-components/dist/esm/rich-text/editor'; @NgModule({ declarations: [NimbleRichTextEditorDirective], From b819d0e73c2af9b023fe0dcde90563b6a3c97cc4 Mon Sep 17 00:00:00 2001 From: "SOLITONTECH\\vivin.krishna" <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Fri, 1 Sep 2023 10:36:14 +0530 Subject: [PATCH 11/19] Updating pageobject paths for angular files --- .../rich-text-editor/testing/rich-text-editor.pageobject.ts | 4 ++-- .../rich-text-viewer/testing/rich-text-viewer.pageobject.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/testing/rich-text-editor.pageobject.ts b/angular-workspace/projects/ni/nimble-angular/rich-text-editor/testing/rich-text-editor.pageobject.ts index 075882fc1b..3047e7e51a 100644 --- a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/testing/rich-text-editor.pageobject.ts +++ b/angular-workspace/projects/ni/nimble-angular/rich-text-editor/testing/rich-text-editor.pageobject.ts @@ -1,5 +1,5 @@ -import { RichTextEditorPageObject } from '@ni/nimble-components/dist/esm/rich-text-editor/testing/rich-text-editor.pageobject'; -import type { ToolbarButton } from '@ni/nimble-components/dist/esm/rich-text-editor/testing/types'; +import { RichTextEditorPageObject } from '@ni/nimble-components/dist/esm/rich-text/editor/testing/rich-text-editor.pageobject'; +import type { ToolbarButton } from '@ni/nimble-components/dist/esm/rich-text/editor/testing/types'; export { RichTextEditorPageObject }; export type { ToolbarButton }; \ No newline at end of file diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/testing/rich-text-viewer.pageobject.ts b/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/testing/rich-text-viewer.pageobject.ts index 0deb789be0..84b9fedbbb 100644 --- a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/testing/rich-text-viewer.pageobject.ts +++ b/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/testing/rich-text-viewer.pageobject.ts @@ -1,3 +1,3 @@ -import { RichTextViewerPageObject } from '@ni/nimble-components/dist/esm/rich-text-viewer/testing/rich-text-viewer.pageobject'; +import { RichTextViewerPageObject } from '@ni/nimble-components/dist/esm/rich-text/viewer/testing/rich-text-viewer.pageobject'; export { RichTextViewerPageObject }; \ No newline at end of file From 4acdff35737cea1a3f919e0dff0f98673ced1c43 Mon Sep 17 00:00:00 2001 From: "SOLITONTECH\\vivin.krishna" <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Fri, 1 Sep 2023 11:34:03 +0530 Subject: [PATCH 12/19] Resolve merge conflicts --- .../src/rich-text/editor/index.ts | 20 +++++++++++++++---- .../editor/tests/rich-text-editor.spec.ts | 4 ++-- .../src/rich-text/editor/tests/types.spec.ts | 9 +++++++++ .../src/rich-text/editor/types.ts | 11 ++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 packages/nimble-components/src/rich-text/editor/types.ts diff --git a/packages/nimble-components/src/rich-text/editor/index.ts b/packages/nimble-components/src/rich-text/editor/index.ts index 41b750cdba..56a3d9b349 100644 --- a/packages/nimble-components/src/rich-text/editor/index.ts +++ b/packages/nimble-components/src/rich-text/editor/index.ts @@ -6,7 +6,13 @@ import { FoundationElement } from '@microsoft/fast-foundation'; import { keyEnter, keySpace } from '@microsoft/fast-web-utilities'; -import { Editor, AnyExtension, Extension } from '@tiptap/core'; +import { + Editor, + findParentNode, + isList, + AnyExtension, + Extension +} from '@tiptap/core'; import Bold from '@tiptap/extension-bold'; import BulletList from '@tiptap/extension-bullet-list'; import Document from '@tiptap/extension-document'; @@ -22,6 +28,7 @@ import { template } from './template'; import { styles } from './styles'; import type { ToggleButton } from '../../toggle-button'; import type { ErrorPattern } from '../../patterns/error/types'; +import { TipTapNodeName } from './types'; import { RichTextMarkdownParser } from '../models/markdown-parser'; import { RichTextMarkdownSerializer } from '../models/markdown-serializer'; @@ -382,10 +389,15 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { } private updateEditorButtonsState(): void { + const { extensionManager, state } = this.tiptapEditor; + const { extensions } = extensionManager; + const { selection } = state; + const parentList = findParentNode((node: { type: { name: string } }) => isList(node.type.name, extensions))(selection); + this.boldButton.checked = this.tiptapEditor.isActive('bold'); this.italicsButton.checked = this.tiptapEditor.isActive('italic'); - this.bulletListButton.checked = this.tiptapEditor.isActive('bulletList'); - this.numberedListButton.checked = this.tiptapEditor.isActive('orderedList'); + this.bulletListButton.checked = parentList?.node.type.name === TipTapNodeName.bulletList; + this.numberedListButton.checked = parentList?.node.type.name === TipTapNodeName.numberedList; } private keyActivatesButton(event: KeyboardEvent): boolean { @@ -457,7 +469,7 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { extensionName: string ): AnyExtension | undefined { return this.tiptapEditor.extensionManager.extensions.find( - extension => extension.name === extensionName + (extension: { name: string }) => extension.name === extensionName ); } 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 5196f43885..4c486996a0 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 @@ -452,7 +452,7 @@ describe('RichTextEditor', () => { ]); expect( pageObject.getButtonCheckedState(ToolbarButton.numberedList) - ).toBeTrue(); + ).toBeFalse(); expect( pageObject.getButtonCheckedState(ToolbarButton.bulletList) ).toBeTrue(); @@ -534,7 +534,7 @@ describe('RichTextEditor', () => { ).toBeTrue(); expect( pageObject.getButtonCheckedState(ToolbarButton.bulletList) - ).toBeTrue(); + ).toBeFalse(); }); it('should have "ul" tag names for bullet lists when clicking "tab" to make it nested and "shift+Tab" to make it usual list', async () => { diff --git a/packages/nimble-components/src/rich-text/editor/tests/types.spec.ts b/packages/nimble-components/src/rich-text/editor/tests/types.spec.ts index 4f52aecc3b..5f142d7813 100644 --- a/packages/nimble-components/src/rich-text/editor/tests/types.spec.ts +++ b/packages/nimble-components/src/rich-text/editor/tests/types.spec.ts @@ -1,4 +1,5 @@ import type { ToolbarButton } from '../testing/types'; +import type { TipTapNodeName } from '../types'; describe('Editor Toolbar button page object types', () => { it('ToolbarButton fails compile if assigning arbitrary string values', () => { @@ -8,3 +9,11 @@ describe('Editor Toolbar button page object types', () => { expect(value).toEqual('hello'); }); }); + +describe('Tiptap node types', () => { + it('TipTapNodeName fails compile if assigning arbitrary string values', () => { + // @ts-expect-error This expect will fail if the enum-like type is missing "as const" + const value: TipTapNodeName = 'hello'; + expect(value).toEqual('hello'); + }); +}); diff --git a/packages/nimble-components/src/rich-text/editor/types.ts b/packages/nimble-components/src/rich-text/editor/types.ts new file mode 100644 index 0000000000..e402f2de45 --- /dev/null +++ b/packages/nimble-components/src/rich-text/editor/types.ts @@ -0,0 +1,11 @@ +/** + * TipTap node types. + * @public + */ +export const TipTapNodeName = { + bulletList: 'bulletList', + numberedList: 'orderedList' +} as const; + +export type TipTapNodeName = + (typeof TipTapNodeName)[keyof typeof TipTapNodeName]; From fdc3cf7edc6f7aec1b4397f497efe9a7353f815f Mon Sep 17 00:00:00 2001 From: Vivin Krishna <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Fri, 1 Sep 2023 18:09:20 +0530 Subject: [PATCH 13/19] Updated serialize method name --- packages/nimble-components/src/rich-text/editor/index.ts | 2 +- .../src/rich-text/models/markdown-serializer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nimble-components/src/rich-text/editor/index.ts b/packages/nimble-components/src/rich-text/editor/index.ts index b096b8e7d4..8b8006e957 100644 --- a/packages/nimble-components/src/rich-text/editor/index.ts +++ b/packages/nimble-components/src/rich-text/editor/index.ts @@ -315,7 +315,7 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { * @public */ public getMarkdown(): string { - return this.markdownSerializer.serializeToMarkdown( + return this.markdownSerializer.serializeDOMToMarkdown( this.tiptapEditor.state.doc ); } 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 0b2436a47b..e6fbae74a8 100644 --- a/packages/nimble-components/src/rich-text/models/markdown-serializer.ts +++ b/packages/nimble-components/src/rich-text/models/markdown-serializer.ts @@ -15,7 +15,7 @@ export class RichTextMarkdownSerializer { this.markdownSerializer = this.initializeMarkdownSerializerForTipTap(); } - public serializeToMarkdown(doc: Node): string { + public serializeDOMToMarkdown(doc: Node): string { return this.markdownSerializer.serialize(doc); } From 65acbd701836b28aa090d493bc35d4c4f040e334 Mon Sep 17 00:00:00 2001 From: Vivin Krishna <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Fri, 1 Sep 2023 19:28:16 +0530 Subject: [PATCH 14/19] Moved the parser initialization in viewer component just like the editor and removed constructor --- packages/nimble-components/src/rich-text/viewer/index.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/nimble-components/src/rich-text/viewer/index.ts b/packages/nimble-components/src/rich-text/viewer/index.ts index dcc5146380..6d0b86dc2d 100644 --- a/packages/nimble-components/src/rich-text/viewer/index.ts +++ b/packages/nimble-components/src/rich-text/viewer/index.ts @@ -27,12 +27,7 @@ export class RichTextViewer extends FoundationElement { */ public viewer!: HTMLDivElement; - private readonly markdownParser: RichTextMarkdownParser; - - public constructor() { - super(); - this.markdownParser = new RichTextMarkdownParser(); - } + private readonly markdownParser = new RichTextMarkdownParser(); /** * @internal From b38df31c8b9aba847b5e2aadfc9af80a40b43081 Mon Sep 17 00:00:00 2001 From: Vivin Krishna <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Mon, 4 Sep 2023 09:36:10 +0530 Subject: [PATCH 15/19] Resolve PR comments --- .../projects/example-client-app/src/app/app.module.ts | 4 ++-- .../src/app/customapp/customapp.component.ts | 2 +- .../{rich-text-editor => rich-text/editor}/ng-package.json | 0 .../editor}/nimble-rich-text-editor.directive.ts | 0 .../editor}/nimble-rich-text-editor.module.ts | 0 .../{rich-text-editor => rich-text/editor}/public-api.ts | 0 .../editor}/testing/ng-package.json | 0 .../editor}/testing/public-api.ts | 0 .../editor}/testing/rich-text-editor.pageobject.ts | 0 .../editor}/tests/nimble-rich-text-editor.directive.spec.ts | 0 .../{rich-text-viewer => rich-text/viewer}/ng-package.json | 0 .../viewer}/nimble-rich-text-viewer.directive.ts | 0 .../viewer}/nimble-rich-text-viewer.module.ts | 0 .../{rich-text-viewer => rich-text/viewer}/public-api.ts | 0 .../viewer}/testing/ng-package.json | 0 .../viewer}/testing/public-api.ts | 0 .../viewer}/testing/rich-text-viewer.pageobject.ts | 0 .../viewer}/tests/nimble-rich-text-viewer.directive.spec.ts | 0 ...nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json | 2 +- ...ble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json | 2 +- .../src/rich-text/models/markdown-serializer.ts | 6 +----- .../nimble-components/src/rich-text/viewer/specs/README.md | 2 +- 22 files changed, 7 insertions(+), 11 deletions(-) rename angular-workspace/projects/ni/nimble-angular/{rich-text-editor => rich-text/editor}/ng-package.json (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-editor => rich-text/editor}/nimble-rich-text-editor.directive.ts (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-editor => rich-text/editor}/nimble-rich-text-editor.module.ts (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-editor => rich-text/editor}/public-api.ts (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-editor => rich-text/editor}/testing/ng-package.json (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-editor => rich-text/editor}/testing/public-api.ts (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-editor => rich-text/editor}/testing/rich-text-editor.pageobject.ts (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-editor => rich-text/editor}/tests/nimble-rich-text-editor.directive.spec.ts (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-viewer => rich-text/viewer}/ng-package.json (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-viewer => rich-text/viewer}/nimble-rich-text-viewer.directive.ts (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-viewer => rich-text/viewer}/nimble-rich-text-viewer.module.ts (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-viewer => rich-text/viewer}/public-api.ts (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-viewer => rich-text/viewer}/testing/ng-package.json (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-viewer => rich-text/viewer}/testing/public-api.ts (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-viewer => rich-text/viewer}/testing/rich-text-viewer.pageobject.ts (100%) rename angular-workspace/projects/ni/nimble-angular/{rich-text-viewer => rich-text/viewer}/tests/nimble-rich-text-viewer.directive.spec.ts (100%) diff --git a/angular-workspace/projects/example-client-app/src/app/app.module.ts b/angular-workspace/projects/example-client-app/src/app/app.module.ts index 3b92310d7e..6f59580b53 100644 --- a/angular-workspace/projects/example-client-app/src/app/app.module.ts +++ b/angular-workspace/projects/example-client-app/src/app/app.module.ts @@ -22,8 +22,8 @@ import { NimbleTableColumnDateTextModule } from '@ni/nimble-angular/table-column import { NimbleTableColumnEnumTextModule } from '@ni/nimble-angular/table-column/enum-text'; import { NimbleTableColumnIconModule } from '@ni/nimble-angular/table-column/icon'; import { NimbleTableColumnNumberTextModule } from '@ni/nimble-angular/table-column/number-text'; -import { NimbleRichTextViewerModule } from '@ni/nimble-angular/rich-text-viewer'; -import { NimbleRichTextEditorModule } from '@ni/nimble-angular/rich-text-editor'; +import { NimbleRichTextViewerModule } from '@ni/nimble-angular/rich-text/viewer'; +import { NimbleRichTextEditorModule } from '@ni/nimble-angular/rich-text/editor'; import { AppComponent } from './app.component'; import { CustomAppComponent } from './customapp/customapp.component'; import { HeaderComponent } from './header/header.component'; diff --git a/angular-workspace/projects/example-client-app/src/app/customapp/customapp.component.ts b/angular-workspace/projects/example-client-app/src/app/customapp/customapp.component.ts index 21c3a5c6a0..62455a4309 100644 --- a/angular-workspace/projects/example-client-app/src/app/customapp/customapp.component.ts +++ b/angular-workspace/projects/example-client-app/src/app/customapp/customapp.component.ts @@ -3,7 +3,7 @@ import { Component, Inject, ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { DrawerLocation, MenuItem, NimbleDialogDirective, NimbleDrawerDirective, OptionNotFound, OPTION_NOT_FOUND, UserDismissed } from '@ni/nimble-angular'; import type { TableRecord } from '@ni/nimble-angular/table'; -import { NimbleRichTextEditorDirective } from '@ni/nimble-angular/rich-text-editor'; +import { NimbleRichTextEditorDirective } from '@ni/nimble-angular/rich-text/editor'; import { BehaviorSubject, Observable } from 'rxjs'; interface ComboboxItem { diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/ng-package.json b/angular-workspace/projects/ni/nimble-angular/rich-text/editor/ng-package.json similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-editor/ng-package.json rename to angular-workspace/projects/ni/nimble-angular/rich-text/editor/ng-package.json diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/nimble-rich-text-editor.directive.ts b/angular-workspace/projects/ni/nimble-angular/rich-text/editor/nimble-rich-text-editor.directive.ts similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-editor/nimble-rich-text-editor.directive.ts rename to angular-workspace/projects/ni/nimble-angular/rich-text/editor/nimble-rich-text-editor.directive.ts diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/nimble-rich-text-editor.module.ts b/angular-workspace/projects/ni/nimble-angular/rich-text/editor/nimble-rich-text-editor.module.ts similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-editor/nimble-rich-text-editor.module.ts rename to angular-workspace/projects/ni/nimble-angular/rich-text/editor/nimble-rich-text-editor.module.ts diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/public-api.ts b/angular-workspace/projects/ni/nimble-angular/rich-text/editor/public-api.ts similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-editor/public-api.ts rename to angular-workspace/projects/ni/nimble-angular/rich-text/editor/public-api.ts diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/testing/ng-package.json b/angular-workspace/projects/ni/nimble-angular/rich-text/editor/testing/ng-package.json similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-editor/testing/ng-package.json rename to angular-workspace/projects/ni/nimble-angular/rich-text/editor/testing/ng-package.json diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/testing/public-api.ts b/angular-workspace/projects/ni/nimble-angular/rich-text/editor/testing/public-api.ts similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-editor/testing/public-api.ts rename to angular-workspace/projects/ni/nimble-angular/rich-text/editor/testing/public-api.ts diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/testing/rich-text-editor.pageobject.ts b/angular-workspace/projects/ni/nimble-angular/rich-text/editor/testing/rich-text-editor.pageobject.ts similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-editor/testing/rich-text-editor.pageobject.ts rename to angular-workspace/projects/ni/nimble-angular/rich-text/editor/testing/rich-text-editor.pageobject.ts diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-editor/tests/nimble-rich-text-editor.directive.spec.ts b/angular-workspace/projects/ni/nimble-angular/rich-text/editor/tests/nimble-rich-text-editor.directive.spec.ts similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-editor/tests/nimble-rich-text-editor.directive.spec.ts rename to angular-workspace/projects/ni/nimble-angular/rich-text/editor/tests/nimble-rich-text-editor.directive.spec.ts diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/ng-package.json b/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/ng-package.json similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-viewer/ng-package.json rename to angular-workspace/projects/ni/nimble-angular/rich-text/viewer/ng-package.json diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/nimble-rich-text-viewer.directive.ts b/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/nimble-rich-text-viewer.directive.ts similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-viewer/nimble-rich-text-viewer.directive.ts rename to angular-workspace/projects/ni/nimble-angular/rich-text/viewer/nimble-rich-text-viewer.directive.ts diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/nimble-rich-text-viewer.module.ts b/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/nimble-rich-text-viewer.module.ts similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-viewer/nimble-rich-text-viewer.module.ts rename to angular-workspace/projects/ni/nimble-angular/rich-text/viewer/nimble-rich-text-viewer.module.ts diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/public-api.ts b/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/public-api.ts similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-viewer/public-api.ts rename to angular-workspace/projects/ni/nimble-angular/rich-text/viewer/public-api.ts diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/testing/ng-package.json b/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/testing/ng-package.json similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-viewer/testing/ng-package.json rename to angular-workspace/projects/ni/nimble-angular/rich-text/viewer/testing/ng-package.json diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/testing/public-api.ts b/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/testing/public-api.ts similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-viewer/testing/public-api.ts rename to angular-workspace/projects/ni/nimble-angular/rich-text/viewer/testing/public-api.ts diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/testing/rich-text-viewer.pageobject.ts b/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/testing/rich-text-viewer.pageobject.ts similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-viewer/testing/rich-text-viewer.pageobject.ts rename to angular-workspace/projects/ni/nimble-angular/rich-text/viewer/testing/rich-text-viewer.pageobject.ts diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text-viewer/tests/nimble-rich-text-viewer.directive.spec.ts b/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/tests/nimble-rich-text-viewer.directive.spec.ts similarity index 100% rename from angular-workspace/projects/ni/nimble-angular/rich-text-viewer/tests/nimble-rich-text-viewer.directive.spec.ts rename to angular-workspace/projects/ni/nimble-angular/rich-text/viewer/tests/nimble-rich-text-viewer.directive.spec.ts diff --git a/change/@ni-nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json b/change/@ni-nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json index 8edd5163fd..5681985229 100644 --- a/change/@ni-nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json +++ b/change/@ni-nimble-angular-3a7f08ef-dd7e-4bae-8b30-937d3830d563.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "Update folder paths for importing rich text components", + "comment": "Revamp folder structure for rich text components", "packageName": "@ni/nimble-angular", "email": "123377523+vivinkrishna-ni@users.noreply.github.com", "dependentChangeType": "patch" diff --git a/change/@ni-nimble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json b/change/@ni-nimble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json index 9c790f46ee..a6bf4a46e8 100644 --- a/change/@ni-nimble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json +++ b/change/@ni-nimble-components-c9aef77f-1a41-4240-b114-67fc1d5c6c2f.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "Revamp rich text components folder structure", + "comment": "Revamp folder structure for rich text components", "packageName": "@ni/nimble-components", "email": "123377523+vivinkrishna-ni@users.noreply.github.com", "dependentChangeType": "patch" 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 e6fbae74a8..48b5f65557 100644 --- a/packages/nimble-components/src/rich-text/models/markdown-serializer.ts +++ b/packages/nimble-components/src/rich-text/models/markdown-serializer.ts @@ -9,11 +9,7 @@ import type { Node } from 'prosemirror-model'; * Provides markdown serializer for rich text components */ export class RichTextMarkdownSerializer { - private readonly markdownSerializer: MarkdownSerializer; - - public constructor() { - this.markdownSerializer = this.initializeMarkdownSerializerForTipTap(); - } + private readonly markdownSerializer = this.initializeMarkdownSerializerForTipTap(); public serializeDOMToMarkdown(doc: Node): string { return this.markdownSerializer.serialize(doc); diff --git a/packages/nimble-components/src/rich-text/viewer/specs/README.md b/packages/nimble-components/src/rich-text/viewer/specs/README.md index 2af2ffa797..f14ecb9f52 100644 --- a/packages/nimble-components/src/rich-text/viewer/specs/README.md +++ b/packages/nimble-components/src/rich-text/viewer/specs/README.md @@ -1,3 +1,3 @@ # Nimble Rich Text Viewer -The spec of this component is added as part of the [`/rich-text-editor/specs/README.md`](../../rich-text-editor/specs/README.md) +The spec of this component is added as part of the [`/rich-text/editor/specs/README.md`](../../editor/specs/README.md) From 163de0ec83c4e9ac5850a463ba70cabd8f3deca7 Mon Sep 17 00:00:00 2001 From: Vivin Krishna <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Mon, 4 Sep 2023 13:28:59 +0530 Subject: [PATCH 16/19] Moved the specs folder to common rich-text folder --- .../src/rich-text/{editor => }/specs/README.md | 0 .../{editor => }/specs/spec-images/button-state.png | Bin .../specs/spec-images/editor-sample.png | Bin .../specs/spec-images/viewer-sample.png | Bin .../src/rich-text/viewer/specs/README.md | 3 --- 5 files changed, 3 deletions(-) rename packages/nimble-components/src/rich-text/{editor => }/specs/README.md (100%) rename packages/nimble-components/src/rich-text/{editor => }/specs/spec-images/button-state.png (100%) rename packages/nimble-components/src/rich-text/{editor => }/specs/spec-images/editor-sample.png (100%) rename packages/nimble-components/src/rich-text/{editor => }/specs/spec-images/viewer-sample.png (100%) delete mode 100644 packages/nimble-components/src/rich-text/viewer/specs/README.md diff --git a/packages/nimble-components/src/rich-text/editor/specs/README.md b/packages/nimble-components/src/rich-text/specs/README.md similarity index 100% rename from packages/nimble-components/src/rich-text/editor/specs/README.md rename to packages/nimble-components/src/rich-text/specs/README.md diff --git a/packages/nimble-components/src/rich-text/editor/specs/spec-images/button-state.png b/packages/nimble-components/src/rich-text/specs/spec-images/button-state.png similarity index 100% rename from packages/nimble-components/src/rich-text/editor/specs/spec-images/button-state.png rename to packages/nimble-components/src/rich-text/specs/spec-images/button-state.png diff --git a/packages/nimble-components/src/rich-text/editor/specs/spec-images/editor-sample.png b/packages/nimble-components/src/rich-text/specs/spec-images/editor-sample.png similarity index 100% rename from packages/nimble-components/src/rich-text/editor/specs/spec-images/editor-sample.png rename to packages/nimble-components/src/rich-text/specs/spec-images/editor-sample.png diff --git a/packages/nimble-components/src/rich-text/editor/specs/spec-images/viewer-sample.png b/packages/nimble-components/src/rich-text/specs/spec-images/viewer-sample.png similarity index 100% rename from packages/nimble-components/src/rich-text/editor/specs/spec-images/viewer-sample.png rename to packages/nimble-components/src/rich-text/specs/spec-images/viewer-sample.png diff --git a/packages/nimble-components/src/rich-text/viewer/specs/README.md b/packages/nimble-components/src/rich-text/viewer/specs/README.md deleted file mode 100644 index f14ecb9f52..0000000000 --- a/packages/nimble-components/src/rich-text/viewer/specs/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Nimble Rich Text Viewer - -The spec of this component is added as part of the [`/rich-text/editor/specs/README.md`](../../editor/specs/README.md) From c2c6367b6e675b6ddd28d2e76e20ed1d16c64315 Mon Sep 17 00:00:00 2001 From: Vivin Krishna <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Wed, 6 Sep 2023 09:42:10 +0530 Subject: [PATCH 17/19] Resolving PR comments --- .../rich-text/editor/ng-package.json | 2 +- .../rich-text/editor/testing/ng-package.json | 2 +- .../rich-text/viewer/ng-package.json | 2 +- .../rich-text/viewer/testing/ng-package.json | 2 +- .../src/rich-text/editor/index.ts | 96 +++++++++++++++++-- .../src/rich-text/models/markdown-parser.ts | 58 ----------- .../rich-text/models/markdown-serializer.ts | 58 ----------- .../src/rich-text/viewer/index.ts | 60 +++++++++++- 8 files changed, 149 insertions(+), 131 deletions(-) delete mode 100644 packages/nimble-components/src/rich-text/models/markdown-parser.ts delete mode 100644 packages/nimble-components/src/rich-text/models/markdown-serializer.ts diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text/editor/ng-package.json b/angular-workspace/projects/ni/nimble-angular/rich-text/editor/ng-package.json index 7945e60e70..e5440110fb 100644 --- a/angular-workspace/projects/ni/nimble-angular/rich-text/editor/ng-package.json +++ b/angular-workspace/projects/ni/nimble-angular/rich-text/editor/ng-package.json @@ -1,5 +1,5 @@ { - "$schema": "../../../../../node_modules/ng-packagr/ng-package.schema.json", + "$schema": "../../../../../../node_modules/ng-packagr/ng-package.schema.json", "lib": { "entryFile": "public-api.ts" } diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text/editor/testing/ng-package.json b/angular-workspace/projects/ni/nimble-angular/rich-text/editor/testing/ng-package.json index e5440110fb..55f020bdfb 100644 --- a/angular-workspace/projects/ni/nimble-angular/rich-text/editor/testing/ng-package.json +++ b/angular-workspace/projects/ni/nimble-angular/rich-text/editor/testing/ng-package.json @@ -1,5 +1,5 @@ { - "$schema": "../../../../../../node_modules/ng-packagr/ng-package.schema.json", + "$schema": "../../../../../../../node_modules/ng-packagr/ng-package.schema.json", "lib": { "entryFile": "public-api.ts" } diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/ng-package.json b/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/ng-package.json index 7945e60e70..e5440110fb 100644 --- a/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/ng-package.json +++ b/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/ng-package.json @@ -1,5 +1,5 @@ { - "$schema": "../../../../../node_modules/ng-packagr/ng-package.schema.json", + "$schema": "../../../../../../node_modules/ng-packagr/ng-package.schema.json", "lib": { "entryFile": "public-api.ts" } diff --git a/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/testing/ng-package.json b/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/testing/ng-package.json index e5440110fb..55f020bdfb 100644 --- a/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/testing/ng-package.json +++ b/angular-workspace/projects/ni/nimble-angular/rich-text/viewer/testing/ng-package.json @@ -1,5 +1,5 @@ { - "$schema": "../../../../../../node_modules/ng-packagr/ng-package.schema.json", + "$schema": "../../../../../../../node_modules/ng-packagr/ng-package.schema.json", "lib": { "entryFile": "public-api.ts" } diff --git a/packages/nimble-components/src/rich-text/editor/index.ts b/packages/nimble-components/src/rich-text/editor/index.ts index 8b8006e957..7744f61000 100644 --- a/packages/nimble-components/src/rich-text/editor/index.ts +++ b/packages/nimble-components/src/rich-text/editor/index.ts @@ -13,6 +13,15 @@ import { AnyExtension, Extension } 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'; @@ -29,8 +38,6 @@ import { styles } from './styles'; import type { ToggleButton } from '../../toggle-button'; import { TipTapNodeName } from './types'; import type { ErrorPattern } from '../../patterns/error/types'; -import { RichTextMarkdownParser } from '../models/markdown-parser'; -import { RichTextMarkdownSerializer } from '../models/markdown-serializer'; declare global { interface HTMLElementTagNameMap { @@ -146,8 +153,9 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { private resizeObserver?: ResizeObserver; private updateScrollbarWidthQueued = false; - private readonly markdownParser = new RichTextMarkdownParser(); - private readonly markdownSerializer = new RichTextMarkdownSerializer(); + private readonly markdownParser = this.initializeMarkdownParser(); + private readonly markdownSerializer = this.initializeMarkdownSerializer(); + private readonly domSerializer = DOMSerializer.fromSchema(schema); private readonly xmlSerializer = new XMLSerializer(); /** @@ -315,9 +323,10 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { * @public */ public getMarkdown(): string { - return this.markdownSerializer.serializeDOMToMarkdown( + const markdownContent = this.markdownSerializer.serialize( this.tiptapEditor.state.doc ); + return markdownContent; } /** @@ -369,10 +378,85 @@ export class RichTextEditor extends FoundationElement implements ErrorPattern { * This function takes the Fragment from parseMarkdownToDOM function and return the serialized string using XMLSerializer */ private getHtmlContent(markdown: string): string { - const documentFragment = this.markdownParser.parseMarkdownToDOM(markdown); + 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 + ); + } + /** * Binding the "transaction" event to the editor allows continuous monitoring the events and updating the button state in response to * various actions such as mouse events, keyboard events, changes in the editor content etc,. diff --git a/packages/nimble-components/src/rich-text/models/markdown-parser.ts b/packages/nimble-components/src/rich-text/models/markdown-parser.ts deleted file mode 100644 index 9a1ae9a2c4..0000000000 --- a/packages/nimble-components/src/rich-text/models/markdown-parser.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - schema, - defaultMarkdownParser, - MarkdownParser -} from 'prosemirror-markdown'; -import { DOMSerializer } from 'prosemirror-model'; - -/** - * Provides markdown parser for rich text components - */ -export class RichTextMarkdownParser { - private readonly markdownParser: MarkdownParser; - private readonly domSerializer: DOMSerializer; - - public constructor() { - this.markdownParser = this.initializeMarkdownParser(); - this.domSerializer = DOMSerializer.fromSchema(schema); - } - - /** - * This function takes a markdown string, parses it using the ProseMirror MarkdownParser, serializes the parsed content into a - * DOM structure using a DOMSerializer, and returns the serialized result. - * If the markdown parser returns null, it will clear the viewer component by creating an empty document fragment. - */ - public parseMarkdownToDOM(value: string): HTMLElement | DocumentFragment { - const parsedMarkdownContent = this.markdownParser.parse(value); - if (parsedMarkdownContent === null) { - return document.createDocumentFragment(); - } - return this.domSerializer.serializeFragment( - parsedMarkdownContent.content - ); - } - - 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', - 'autolink' - ]); - - return new MarkdownParser( - schema, - supportedTokenizerRules, - defaultMarkdownParser.tokens - ); - } -} diff --git a/packages/nimble-components/src/rich-text/models/markdown-serializer.ts b/packages/nimble-components/src/rich-text/models/markdown-serializer.ts deleted file mode 100644 index 48b5f65557..0000000000 --- a/packages/nimble-components/src/rich-text/models/markdown-serializer.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - MarkdownSerializer, - defaultMarkdownSerializer, - MarkdownSerializerState -} from 'prosemirror-markdown'; -import type { Node } from 'prosemirror-model'; - -/** - * Provides markdown serializer for rich text components - */ -export class RichTextMarkdownSerializer { - private readonly markdownSerializer = this.initializeMarkdownSerializerForTipTap(); - - public serializeDOMToMarkdown(doc: Node): string { - return this.markdownSerializer.serialize(doc); - } - - private initializeMarkdownSerializerForTipTap(): 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); - } -} diff --git a/packages/nimble-components/src/rich-text/viewer/index.ts b/packages/nimble-components/src/rich-text/viewer/index.ts index 6d0b86dc2d..132daa978d 100644 --- a/packages/nimble-components/src/rich-text/viewer/index.ts +++ b/packages/nimble-components/src/rich-text/viewer/index.ts @@ -1,8 +1,13 @@ import { DesignSystem, FoundationElement } from '@microsoft/fast-foundation'; +import { + schema, + defaultMarkdownParser, + MarkdownParser +} from 'prosemirror-markdown'; +import { DOMSerializer } from 'prosemirror-model'; import { observable } from '@microsoft/fast-element'; import { template } from './template'; import { styles } from './styles'; -import { RichTextMarkdownParser } from '../models/markdown-parser'; declare global { interface HTMLElementTagNameMap { @@ -26,8 +31,14 @@ export class RichTextViewer extends FoundationElement { * @internal */ public viewer!: HTMLDivElement; + private readonly markdownParser: MarkdownParser; + private readonly domSerializer: DOMSerializer; - private readonly markdownParser = new RichTextMarkdownParser(); + public constructor() { + super(); + this.domSerializer = DOMSerializer.fromSchema(schema); + this.markdownParser = this.initializeMarkdownParser(); + } /** * @internal @@ -46,11 +57,50 @@ export class RichTextViewer extends FoundationElement { } } + 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', + 'autolink' + ]); + + return new MarkdownParser( + schema, + supportedTokenizerRules, + defaultMarkdownParser.tokens + ); + } + + /** + * + * This function takes a markdown string, parses it using the ProseMirror MarkdownParser, serializes the parsed content into a + * DOM structure using a DOMSerializer, and returns the serialized result. + * If the markdown parser returns null, it will clear the viewer component by creating an empty document fragment. + */ + 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 updateView(): void { if (this.markdown) { - const serializedContent = this.markdownParser.parseMarkdownToDOM( - this.markdown - ); + const serializedContent = this.parseMarkdownToDOM(this.markdown); this.viewer.replaceChildren(serializedContent); } else { this.viewer.innerHTML = ''; From 6c9de89db60d70268c5ae806ade06502715615ad Mon Sep 17 00:00:00 2001 From: Vivin Krishna <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Wed, 6 Sep 2023 09:57:29 +0530 Subject: [PATCH 18/19] Minor import order in viewer spec --- .../src/rich-text/viewer/tests/rich-text-viewer.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nimble-components/src/rich-text/viewer/tests/rich-text-viewer.spec.ts b/packages/nimble-components/src/rich-text/viewer/tests/rich-text-viewer.spec.ts index 726feb4a93..7246dcecc4 100644 --- a/packages/nimble-components/src/rich-text/viewer/tests/rich-text-viewer.spec.ts +++ b/packages/nimble-components/src/rich-text/viewer/tests/rich-text-viewer.spec.ts @@ -1,9 +1,9 @@ import { html } from '@microsoft/fast-element'; +import { RichTextViewer, richTextViewerTag } from '..'; import { fixture, type Fixture } from '../../../utilities/tests/fixture'; import { RichTextViewerPageObject } from '../testing/rich-text-viewer.pageobject'; import { wackyStrings } from '../../../utilities/tests/wacky-strings'; import { getSpecTypeByNamedList } from '../../../utilities/tests/parameterized'; -import { RichTextViewer, richTextViewerTag } from '..'; async function setup(): Promise> { return fixture( From 065743482ee34a9ede4ced6de1770fb43d31f93e Mon Sep 17 00:00:00 2001 From: Vivin Krishna <123377523+vivinkrishna-ni@users.noreply.github.com> Date: Wed, 6 Sep 2023 12:08:05 +0530 Subject: [PATCH 19/19] Update paths for label spec file --- .../editor/tests/rich-text-editor-labels.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor-labels.spec.ts b/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor-labels.spec.ts index 8bcd734c6b..78178af003 100644 --- a/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor-labels.spec.ts +++ b/packages/nimble-components/src/rich-text/editor/tests/rich-text-editor-labels.spec.ts @@ -1,15 +1,15 @@ import { html } from '@microsoft/fast-element'; import { richTextEditorTag, type RichTextEditor } from '..'; -import { type Fixture, fixture } from '../../utilities/tests/fixture'; -import { themeProviderTag, type ThemeProvider } from '../../theme-provider'; +import { type Fixture, fixture } from '../../../utilities/tests/fixture'; +import { themeProviderTag, type ThemeProvider } from '../../../theme-provider'; import { LabelProviderRichText, labelProviderRichTextTag -} from '../../label-provider/rich-text'; +} from '../../../label-provider/rich-text'; import { RichTextEditorPageObject } from '../testing/rich-text-editor.pageobject'; import { LabelProvider, ToolbarButton } from '../testing/types'; -import { getSpecTypeByNamedList } from '../../utilities/tests/parameterized'; -import { waitForUpdatesAsync } from '../../testing/async-helpers'; +import { getSpecTypeByNamedList } from '../../../utilities/tests/parameterized'; +import { waitForUpdatesAsync } from '../../../testing/async-helpers'; async function setup(): Promise> { return fixture(