diff --git a/docs/documentation/docs/controls/RichText.md b/docs/documentation/docs/controls/RichText.md index 0c2f53e40..8a5a4727b 100644 --- a/docs/documentation/docs/controls/RichText.md +++ b/docs/documentation/docs/controls/RichText.md @@ -61,6 +61,30 @@ const onRenderCustomLabel = (rtProps: IRichTextProps): JSX.Element => { } ``` +It is also possible to provide custom formatting styles for built-in rich text elements. These styles are scoped to the current RichText instance and applied both in edit mode and read-only mode. + +```TypeScript + this.onTextChange(text)} +/> +``` + ## Implementation The RichText control can be configured with the following properties: @@ -72,11 +96,29 @@ The RichText control can be configured with the following properties: | className | string | no | The custom CSS class to apply to the RichText control. | | style | React.CSSProperties | no | The custom styles to apply to the RichText control. | | isEditMode | boolean | no | `true` indicates that users will be able to edit the content of the RichText control. `false` will display the rich text as read-only. | +| placeholder | string | no | Placeholder text to show when the editor is empty. | | styleOptions | StyleOptions | no | Define the styles you want to show or hide for the rich text editor | | value | string | no | Sets the rich text to display in the RichText control. | +| customColors | ISwatchColor[] | no | Additional colors to include in the side-panel font color swatch. | +| customStyles | IRichTextCustomFormattingStyles | no | Object-based formatting styles applied by the control to built-in rich text elements such as normal text, headings, block quotes, and lists. | | onChange | (text: string) => string | no | onChange handler for the RichText control. The function must return a `string` containing the rich text to display in the RichText control. | | onRenderLabel | (props: IRichTextProps) => JSX.Element | no | Custom renderer for the RichText control's label. The function must return a `JSX.Element`. | +`IRichTextCustomFormattingStyles` interface + +| Property | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| normal | React.CSSProperties | no | Styles applied to the root rich text content. | +| header2 | React.CSSProperties | no | Styles applied to `h2` content. | +| header3 | React.CSSProperties | no | Styles applied to `h3` content. | +| header4 | React.CSSProperties | no | Styles applied to `h4` content. | +| blockQuote | React.CSSProperties | no | Styles applied to `blockquote` content. | +| div | React.CSSProperties | no | Styles applied to `div` content. | +| p | React.CSSProperties | no | Styles applied to `p` content. | +| ul | React.CSSProperties | no | Styles applied to `ul` content. | +| ol | React.CSSProperties | no | Styles applied to `ol` content. | +| li | React.CSSProperties | no | Styles applied to `li` content. | + `StyleOptions` interface | Property | Type | Required | Description | @@ -86,6 +128,7 @@ The RichText control can be configured with the following properties: | showItalic | boolean | no | Indicates whether to show the **Italic** toolbar button or not. Default value is `true` | | showLink | boolean | no | Indicates whether to show the **Hyperlink** toolbar button or not. Default value is `true` | | showList | boolean | no | Indicates whether to show the **List** toolbar button or not. Default value is `true` | +| showImage | boolean | no | Indicates whether to show the **Image** toolbar button or not. Default value is `true` | | showMore | boolean | no | Indicates whether to show the **More** toolbar button or not. Note that this option is independent from the other `show___` options. I.e.: Setting `showBold` to `false` will disable the **Bold** toolbar, but will not disable it from the formatting pane. Default value is `true` | | showStyles | boolean | no | Indicates whether to show the **Headings** toolbar button or not. Note that this option is independent from the other `show___` options. I.e.: Setting `showBold` to `false` will disable the **Bold** toolbar, but will not disable it from the formatting pane. Default value is `true` | | showUnderline | boolean | no | Indicates whether to show the **Underline** toolbar button or not. Note that this option is independent from the other `show___` options. I.e.: Setting `showBold` to `false` will disable the **Bold** toolbar, but will not disable it from the formatting pane. Default value is `true` | diff --git a/src/controls/richText/RichText.module.scss b/src/controls/richText/RichText.module.scss index d52e70a0e..c6b1f1def 100644 --- a/src/controls/richText/RichText.module.scss +++ b/src/controls/richText/RichText.module.scss @@ -1,5 +1,16 @@ @import 'pkg:@fluentui/react/dist/sass/References.scss'; +:export { + NeutralLighterAlt: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]"; + NeutralPrimary: "[theme:neutralPrimary, default:#{$ms-color-neutralPrimary}]"; + NeutralTertiary: "[theme:neutralTertiary, default:#{$ms-color-neutralTertiary}]"; + ThemePrimary: "[theme:themePrimary, default:#{$ms-color-themePrimary}]"; + NeutralLighter: "[theme:neutralLighter, default:#{$ms-color-neutralLighter}]"; + NeutralDark: "[theme:neutralDark, default:#{$ms-color-neutralDark}]"; + ThemeDarker: "[theme:themeDarker, default:#{$ms-color-themeDarker}]"; + White: "[theme:white, default:#{$ms-color-white}]"; +} + .richtext { color: inherit; } @@ -96,20 +107,23 @@ font-style: normal; font-weight: normal; font-size: 8px; - speak: none; top: 50%; - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]"; + color: var(--rt-neutralLighterAlt, #f8f8f8); } .toolbarSubmenuDisplayButton { width: 100%; } +.editorShell { + position: relative; +} + :global { .pnp-richtext-quill-container { .ql-toolbar { background-color: transparent; - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]"; + color: var(--rt-neutralLighterAlt, #f8f8f8); border: none; display: none; @@ -122,31 +136,31 @@ } .ms-Button { - background-color: "[theme:neutralPrimary, default:#{$ms-color-neutralPrimary}]"; - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]" !important; + background-color: var(--rt-neutralPrimary, #323130); + color: var(--rt-neutralLighterAlt, #f8f8f8) !important; &:hover { - background-color: "[theme:neutralTertiary, default:#{$ms-color-neutralTertiary}]"; - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]"; + background-color: var(--rt-neutralTertiary, #a19f9d); + color: var(--rt-neutralLighterAlt, #f8f8f8); } &:focus { - border: 1px solid "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]"; + border: 1px solid var(--rt-neutralLighterAlt, #f8f8f8); outline: 0; - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]"; + color: var(--rt-neutralLighterAlt, #f8f8f8); } &:active { - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]"; + color: var(--rt-neutralLighterAlt, #f8f8f8); } &.is-checked { - background-color: "[theme:themePrimary, default:#{$ms-color-themePrimary}]" !important; + background-color: var(--rt-themePrimary, #0078d4) !important; } } .ms-Dropdown-title { - background-color: "[theme:neutralPrimary, default:#{$ms-color-neutralPrimary}]"; - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]" !important; + background-color: var(--rt-neutralPrimary, #323130); + color: var(--rt-neutralLighterAlt, #f8f8f8) !important; border: none; min-height: 34px; padding-bottom: 0px; @@ -156,21 +170,22 @@ -webkit-transition: all 0.3s; transition: all 0.3s; -webkit-transition-property: background-color, color; + transition-property: background-color, color; border-radius: 0; &:hover { - background-color: "[theme:neutralTertiary, default:#{$ms-color-neutralTertiary}]"; - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]"; + background-color: var(--rt-neutralTertiary, #a19f9d); + color: var(--rt-neutralLighterAlt, #f8f8f8); } &:focus { - border: 1px solid "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]"; + border: 1px solid var(--rt-neutralLighterAlt, #f8f8f8); outline: 0; - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]"; + color: var(--rt-neutralLighterAlt, #f8f8f8); } &:active { - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]"; + color: var(--rt-neutralLighterAlt, #f8f8f8); } } @@ -182,8 +197,8 @@ .ql-snow.ql-toolbar button, .ql-snow .ql-toolbar button { - background-color: "[theme:neutralPrimary, default:#{$ms-color-neutralPrimary}]"; - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]"; + background-color: var(--rt-neutralPrimary, #323130); + color: var(--rt-neutralLighterAlt, #f8f8f8); font-size: 16px; min-width: 34px; height: 34px; @@ -198,7 +213,7 @@ font-family: "Segoe UI Web (West European)", Segoe UI, -apple-system, BlinkMacSystemFont, Roboto, Helvetica Neue, sans-serif; border: none; - margin-bottom: 2px; + margin-bottom: 0; z-index: 3; -webkit-animation-name: ms-fadeIn, ms-slideUpIn10; animation-name: ms-fadeIn, ms-slideUpIn10; @@ -225,7 +240,7 @@ .ql-active .ql-editor:hover { border-width: 1px; border-style: solid; - border-color: "[theme:neutralTertiary, default:#{$ms-color-neutralTertiary}]"; + border-color: var(--rt-neutralTertiary, #a19f9d); } .ql-active .ql-editor:focus, @@ -233,13 +248,14 @@ .ql-active .ql-editor:active:hover { border-width: 1px; border-style: solid; - border-color: "[theme:themePrimary, default:#{$ms-color-themePrimary}]"; + border-color: var(--rt-themePrimary, #0078d4); } .ql-active .ql-toolbar { display: inline-flex; position: absolute; - top: -28px; + bottom: 100%; + left: 0; opacity: 1; height: 34px; -webkit-box-shadow: 0 0 5px 0 #c8c8c8; @@ -282,12 +298,11 @@ ul { -webkit-font-smoothing: antialiased; line-height: 1.3; - // margin: 0 0 16px; word-wrap: break-word; } blockquote { - border-bottom-color: "[theme:neutralLighter, default:#{$ms-color-neutralLighter}]"; + border-bottom-color: var(--rt-neutralLighter, #edebe9); border-bottom-style: solid; border-bottom-width: 1px; border-left-style: none; @@ -296,7 +311,7 @@ border-right-style: none; border-right-width: 0; border-right-color: transparent; - border-top-color: "[theme:neutralLighter, default:#{$ms-color-neutralLighter}]"; + border-top-color: var(--rt-neutralLighter, #edebe9); border-top-style: solid; border-top-width: 1px; font-size: 20px; @@ -385,7 +400,7 @@ .ql-editor.ql-blank::before { font-style: normal; - color: "[theme:neutralTertiary, default:#{$ms-color-neutralTertiary}]"; + color: var(--rt-neutralTertiary, #a19f9d); font-size: 18px; font-weight: 400; line-height: 1.3; @@ -396,9 +411,9 @@ .ql-editor { border-width: 1px; border-style: solid; - border-color: "[theme:neutralTertiary, default:#{$ms-color-neutralTertiary}]"; + border-color: var(--rt-neutralTertiary, #a19f9d); &:hover { - border-color: "[theme:neutralDark, default:#{$ms-color-neutralDark}]"; + border-color: var(--rt-neutralDark, #201f1e); } &:active::after, &:active:focus::after, &:active:hover::after, &:focus::after { content: ""; @@ -407,7 +422,7 @@ border-style: solid; border-width: 2px; inset: -1px; - border-color: "[theme:themePrimary, default:#{$ms-color-themePrimary}]"; + border-color: var(--rt-themePrimary, #0078d4); border-radius: 2px; } } @@ -416,9 +431,9 @@ // hyperlink color on dark theme .ql-editor { a { - color: "[theme:themePrimary, default: #{$ms-color-themePrimary}]"; + color: var(--rt-themePrimary, #0078d4); &:hover { - color: "[theme:themeDarker, default: #{$ms-color-themeDarker}]"; + color: var(--rt-themeDarker, #004578); } } } @@ -430,23 +445,24 @@ div#DropDownStyles-list, div#DropDownAlign-list, div#DropDownLists-list { .ms-Dropdown-item { - background-color: "[theme:neutralPrimary, default:#{$ms-color-neutralPrimary}]" !important; - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]" !important; + background-color: var(--rt-neutralPrimary, #323130) !important; + color: var(--rt-neutralLighterAlt, #f8f8f8) !important; -webkit-transition: all 0.3s; transition: all 0.3s; -webkit-transition-property: background-color, color; + transition-property: background-color, color; } .ms-Dropdown-item:hover { - background-color: "[theme:neutralTertiary, default:#{$ms-color-neutralTertiary}]" !important; - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]" !important; + background-color: var(--rt-neutralTertiary, #a19f9d) !important; + color: var(--rt-neutralLighterAlt, #f8f8f8) !important; cursor: pointer; } .ms-Dropdown-item.is-selected, .ms-Dropdown-item.is-selected:hover { - background-color: "[theme:themePrimary, default:#{$ms-color-themePrimary}]" !important; - color: "[theme:neutralLighterAlt, default:#{$ms-color-neutralLighterAlt}]" !important; + background-color: var(--rt-themePrimary, #0078d4) !important; + color: var(--rt-neutralLighterAlt, #f8f8f8) !important; } } } diff --git a/src/controls/richText/RichText.tsx b/src/controls/richText/RichText.tsx index ae2fc4ad4..535026890 100644 --- a/src/controls/richText/RichText.tsx +++ b/src/controls/richText/RichText.tsx @@ -5,7 +5,7 @@ import RichTextPropertyPane from './RichTextPropertyPane'; import ReactQuill, { Quill as ReactQuillInstance } from 'react-quill'; import type { Quill } from 'quill'; import styles from './RichText.module.scss'; -import { IRichTextProps, IRichTextState } from './RichText.types'; +import { IRichTextCustomFormattingStyles, IRichTextProps, IRichTextState } from './RichText.types'; import { Guid } from '@microsoft/sp-core-library'; import * as telemetry from '../../common/telemetry'; import isEqual from 'lodash/isEqual'; @@ -20,9 +20,84 @@ import { Icon } from '@fluentui/react/lib/Icon'; import { css, elementContains } from '@fluentui/react/lib/Utilities'; import { initializeIcons } from '@fluentui/react/lib/Icons'; import { Label } from '@fluentui/react/lib/Label'; +import { ThemeColorHelper } from '../../common/utilities/ThemeColorHelper'; -const TOOLBARPADDING: number = 28; const CONTAINER_CLASS: string = 'pnp-richtext-quill-container'; +const CUSTOM_FORMATTING_STYLE_SELECTORS = { + normal: '', + header2: 'h2', + header3: 'h3', + header4: 'h4', + blockQuote: 'blockquote', + div: 'div', + p: 'p', + ul: 'ul', + ol: 'ol', + li: 'li' +} as const satisfies Readonly>; + +const UNITLESS_CSS_PROPERTIES: ReadonlySet = (() => { + const properties = new Set([ + 'animationIterationCount', + 'aspectRatio', + 'borderImageOutset', + 'borderImageSlice', + 'borderImageWidth', + 'boxFlex', + 'boxFlexGroup', + 'boxOrdinalGroup', + 'columnCount', + 'columns', + 'fillOpacity', + 'flex', + 'flexGrow', + 'flexPositive', + 'flexShrink', + 'flexNegative', + 'flexOrder', + 'floodOpacity', + 'fontWeight', + 'gridArea', + 'gridColumn', + 'gridColumnEnd', + 'gridColumnSpan', + 'gridColumnStart', + 'gridRow', + 'gridRowEnd', + 'gridRowSpan', + 'gridRowStart', + 'lineClamp', + 'lineHeight', + 'opacity', + 'order', + 'orphans', + 'scale', + 'stopOpacity', + 'strokeDasharray', + 'strokeDashoffset', + 'strokeMiterlimit', + 'strokeOpacity', + 'strokeWidth', + 'tabSize', + 'widows', + 'zIndex', + 'zoom' + ]); + + const prefixes = ['Webkit', 'ms', 'Moz', 'O']; + const baseProperties = Array.from(properties); + + baseProperties.forEach((propertyName) => { + const capitalizedPropertyName = propertyName.charAt(0).toUpperCase() + propertyName.slice(1); + + prefixes.forEach((prefix) => { + properties.add(`${prefix}${capitalizedPropertyName}`); + }); + }); + + return properties; +})(); + /** * Creates a rich text editing control that mimics the out-of-the-box * SharePoint Rich Text control. @@ -40,6 +115,7 @@ export class RichText extends React.Component { private _propertyPaneRef: RichTextPropertyPane = undefined; private _toolbarId: string = undefined; private _richTextId: string = undefined; + private _customFormattingStylesElement: HTMLStyleElement = undefined; private ddStyleOpts = [ { @@ -136,7 +212,6 @@ export class RichText extends React.Component { insertImageUrl: undefined, selectedText: undefined, selectedUrl: undefined, - wrapperTop: 0, }; // Get a unique toolbar id @@ -152,19 +227,11 @@ export class RichText extends React.Component { public componentDidMount(): void { // If we're in edit mode, attach the mouse down event if (this.props.isEditMode) { - document.addEventListener('click', this.handleClickOutside); + document.addEventListener('mousedown', this.handleClickOutside); document.addEventListener('focus', this.handleClickOutside); - - const domRect: DOMRect = this._wrapperRef.getBoundingClientRect(); - const parentDomRect: DOMRect = - this._wrapperRef.parentElement.getBoundingClientRect(); - const toolbarTop: number = - domRect.top - parentDomRect.top - TOOLBARPADDING; - - this.setState({ - wrapperTop: toolbarTop, - }); } + + this.applyCustomFormattingStyles(); } /** @@ -173,9 +240,11 @@ export class RichText extends React.Component { public componentWillUnmount(): void { // If we were in edit mode, remove the mouse down handler if (this.props.isEditMode) { - document.removeEventListener('click', this.handleClickOutside); + document.removeEventListener('mousedown', this.handleClickOutside); document.removeEventListener('focus', this.handleClickOutside); } + + this.removeCustomFormattingStylesElement(); } /** @@ -188,17 +257,26 @@ export class RichText extends React.Component { prevProps: IRichTextProps, prevState: IRichTextState ): void { + const enteredEditMode = this.props.isEditMode && !prevProps.isEditMode; + // If we're going from non-edit to edit mode - if (this.props.isEditMode && !prevProps.isEditMode) { - document.addEventListener('click', this.handleClickOutside); + if (enteredEditMode) { + document.addEventListener('mousedown', this.handleClickOutside); document.addEventListener('focus', this.handleClickOutside); } // If we're going from edit mode to non-edit mode if (!this.props.isEditMode && prevProps.isEditMode) { - document.removeEventListener('click', this.handleClickOutside); + document.removeEventListener('mousedown', this.handleClickOutside); document.removeEventListener('focus', this.handleClickOutside); } + + if ( + prevProps.customStyles !== this.props.customStyles || + prevProps.isEditMode !== this.props.isEditMode + ) { + this.applyCustomFormattingStyles(); + } } /** @@ -550,6 +628,7 @@ export class RichText extends React.Component { public render(): React.ReactElement { const { text } = this.state; const { isEditMode } = this.props; + const themeStyle = this.getThemeStyle(); const renderLabel: JSX.Element = (this.props.onRenderLabel && this.props.onRenderLabel(this.props)) ?? @@ -558,7 +637,7 @@ export class RichText extends React.Component { // If we're not in edit mode, display read-only version of the html if (!isEditMode) { return ( - <> +
{renderLabel}
{ )} dangerouslySetInnerHTML={{ __html: text }} /> - +
); } @@ -616,6 +695,7 @@ export class RichText extends React.Component { // Add the SharePoint font sizes const sizeClass = ReactQuillInstance.import('formats/size'); sizeClass.whitelist = [ + 'xsmall', 'small', 'medium', 'mediumplus', @@ -635,6 +715,7 @@ export class RichText extends React.Component { className={ css(CONTAINER_CLASS) } + style={themeStyle} >
{ @@ -649,160 +730,162 @@ export class RichText extends React.Component { style={style} > {renderLabel} -
- {showStyles && ( - ( - - )} - selectedKey={this.state.formats.header || 0} - options={this.ddStyleOpts} - onChange={this.onChangeHeading} - onRenderOption={this.onRenderStyleOption} - onRenderTitle={this.onRenderStyleTitle} - /> - )} - {showBold && ( - - +
+ {showStyles && ( + ( + + )} + selectedKey={this.state.formats.header || 0} + options={this.ddStyleOpts} + onChange={this.onChangeHeading} + onRenderOption={this.onRenderStyleOption} + onRenderTitle={this.onRenderStyleTitle} /> - - )} - {showItalic && ( - - - - )} - {showUnderline && ( - - - - )} - {showAlign && ( - ( - + - )} - selectedKey={this.state.formats.align || 'left'} - options={this.ddAlignOpts} - onChange={this.onChangeAlign} - onRenderOption={this.onRenderAlignOption} - onRenderTitle={this.onRenderAlignTitle} - /> - )} - {showList && ( - ( - + )} + {showItalic && ( + + - )} - selectedKey={this.state.formats.list} - options={this.ddListOpts} - // this option is not available yet - notifyOnReselect={true} // allows re-selecting selected item to turn it off - onChange={this.onChangeList} - onRenderOption={this.onRenderListOption} - onRenderTitle={this.onRenderListTitle} - onRenderPlaceholder={this.onRenderListPlaceholder} - /> - )} - {showLink && ( - - - - )} - {showImage && ( - - + )} + {showUnderline && ( + + + + )} + {showAlign && ( + ( + + )} + selectedKey={this.state.formats.align || 'left'} + options={this.ddAlignOpts} + onChange={this.onChangeAlign} + onRenderOption={this.onRenderAlignOption} + onRenderTitle={this.onRenderAlignTitle} /> - - )} - {showMore && ( - - ( + + )} + selectedKey={this.state.formats.list} + options={this.ddListOpts} + // this option is not available yet + notifyOnReselect={true} // allows re-selecting selected item to turn it off + onChange={this.onChangeList} + onRenderOption={this.onRenderListOption} + onRenderTitle={this.onRenderListTitle} + onRenderPlaceholder={this.onRenderListPlaceholder} /> - - )} + )} + {showLink && ( + + + + )} + {showImage && ( + + + + )} + {showMore && ( + + + + )} +
+ +
- - { onClose={this.handleClosePanel} onLink={this.showInsertLinkDialog} customColors={this.props.customColors} + customStyles={this.props.customStyles} /> {this.renderLinkDialog()} @@ -1174,6 +1258,7 @@ export class RichText extends React.Component { // eslint-disable-next-line @typescript-eslint/no-explicit-any private linkQuill = (e: any): void => { this._quillElem = e; + this.applyCustomFormattingStyles(); }; /** @@ -1196,4 +1281,117 @@ export class RichText extends React.Component { return null; }; + + /** + * Build and inject object-based custom formatting styles scoped to this editor instance. + */ + private applyCustomFormattingStyles = (): void => { + const formattingStyles = this.props.customStyles; + // No custom formatting means any previously injected rules should be removed. + if (!formattingStyles) { + this.removeCustomFormattingStylesElement(); + return; + } + + // Read mode uses the id directly on .ql-editor, edit mode renders .ql-editor inside an id'd wrapper. + const baseSelectors = [ + `#${this._richTextId}.ql-editor`, + `#${this._richTextId} .ql-editor` + ]; + const rules: string[] = []; + + (Object.keys(CUSTOM_FORMATTING_STYLE_SELECTORS) as Array).forEach((key) => { + const styleObject = formattingStyles[key]; + const declaration = this.serializeStyleObject(styleObject); + // Skip empty style blocks so we don't emit empty CSS rules. + if (!declaration) { + return; + } + + const selectorSuffix = CUSTOM_FORMATTING_STYLE_SELECTORS[key]; + // Build selectors per key, for example: root h2, root blockquote, root li. + const selector = selectorSuffix + ? baseSelectors.map((base) => `${base} ${selectorSuffix}`).join(', ') + : baseSelectors.join(', '); + rules.push(`${selector} { ${declaration} }`); + }); + + // Store one style sheet text blob for this instance. + const styleText = rules.join('\n'); + if (!styleText) { + this.removeCustomFormattingStylesElement(); + return; + } + + // Create the style element once, then only update its text content. + if (!this._customFormattingStylesElement) { + this._customFormattingStylesElement = document.createElement('style'); + this._customFormattingStylesElement.setAttribute('data-richtext-formatting', this._richTextId); + document.head.appendChild(this._customFormattingStylesElement); + } + + this._customFormattingStylesElement.textContent = styleText; + } + + private removeCustomFormattingStylesElement = (): void => { + // Guard against repeated cleanup calls. + if (this._customFormattingStylesElement?.parentNode) { + this._customFormattingStylesElement.parentNode.removeChild(this._customFormattingStylesElement); + } + + this._customFormattingStylesElement = undefined; + } + + private serializeStyleObject = (style?: React.CSSProperties): string => { + if (!style) { + return ''; + } + + // Convert a React style object into plain CSS declarations. + const declarations = Object.entries(style) + .map(([propertyName, value]) => { + if (value === undefined || value === null || value === '') { + return ''; + } + + // Keep CSS variables intact; convert camelCase names to kebab-case. + const cssProperty = propertyName.startsWith('--') + ? propertyName + : propertyName.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); + + const cssValue = typeof value === 'number' + ? this.convertNumericStyleValue(propertyName, value) + : String(value); + + return `${cssProperty}: ${cssValue};`; + }) + .filter((declaration) => declaration.length > 0); + + return declarations.join(' '); + } + + private convertNumericStyleValue = (_propertyName: string, value: number): string => { + // Keep custom unitless properties untouched; default all others to px. + if (value === 0 || UNITLESS_CSS_PROPERTIES.has(_propertyName)) { + return String(value); + } + + return `${value}px`; + } + + /** + * Resolve theme tokens at runtime to support pre-compiled package CSS. + */ + private getThemeStyle = (): React.CSSProperties => { + return { + '--rt-neutralLighterAlt': ThemeColorHelper.GetThemeColor(styles.NeutralLighterAlt), + '--rt-neutralPrimary': ThemeColorHelper.GetThemeColor(styles.NeutralPrimary), + '--rt-neutralTertiary': ThemeColorHelper.GetThemeColor(styles.NeutralTertiary), + '--rt-themePrimary': ThemeColorHelper.GetThemeColor(styles.ThemePrimary), + '--rt-neutralLighter': ThemeColorHelper.GetThemeColor(styles.NeutralLighter), + '--rt-neutralDark': ThemeColorHelper.GetThemeColor(styles.NeutralDark), + '--rt-themeDarker': ThemeColorHelper.GetThemeColor(styles.ThemeDarker), + '--rt-white': ThemeColorHelper.GetThemeColor(styles.White) + } as React.CSSProperties; + } } diff --git a/src/controls/richText/RichText.types.ts b/src/controls/richText/RichText.types.ts index 5852cf7fc..41f7ffa76 100644 --- a/src/controls/richText/RichText.types.ts +++ b/src/controls/richText/RichText.types.ts @@ -52,6 +52,12 @@ export interface IRichTextProps { */ customColors?: ISwatchColor[]; + /** + * Optional object-based formatting styles applied by the component itself. + * Each key targets a built-in rich text element type. + */ + customStyles?: IRichTextCustomFormattingStyles; + /** * Callback issued when the rich text changes. * Returns the text that will be inserted in the rich text control. @@ -124,6 +130,19 @@ export interface StyleOptions { showUnderline?: boolean; } +export interface IRichTextCustomFormattingStyles { + normal?: React.CSSProperties; + header2?: React.CSSProperties; + header3?: React.CSSProperties; + header4?: React.CSSProperties; + blockQuote?: React.CSSProperties; + div?: React.CSSProperties; + p?: React.CSSProperties; + ul?: React.CSSProperties; + ol?: React.CSSProperties; + li?: React.CSSProperties; +} + export interface IRichTextState { /** * Whether the rich text is currently editing (i.e.: has focus) @@ -183,6 +202,4 @@ export interface IRichTextState { /** The text */ text: string; - - wrapperTop: number; } diff --git a/src/controls/richText/RichTextPropertyPane.tsx b/src/controls/richText/RichTextPropertyPane.tsx index 357a2c429..6e0f385f4 100644 --- a/src/controls/richText/RichTextPropertyPane.tsx +++ b/src/controls/richText/RichTextPropertyPane.tsx @@ -3,6 +3,7 @@ import * as strings from 'ControlStrings'; import styles from './RichTextPropertyPane.module.scss'; import RteColorPicker from './RteColorPicker'; import { IRichTextPropertyPaneProps, IRichTextPropertyPaneState } from './RichTextPropertyPane.types'; +import { ISwatchColor } from './SwatchColorPickerGroup.types'; import { IconButton } from '@fluentui/react/lib/Button'; import { Panel, PanelType } from '@fluentui/react/lib/Panel'; import { TooltipHost } from '@fluentui/react/lib/Tooltip'; @@ -10,7 +11,23 @@ import { Dropdown, IDropdownOption } from '@fluentui/react/lib/Dropdown'; import { ThemeColorHelper } from '../../common/utilities/ThemeColorHelper'; import { RangeStatic } from 'quill'; +const FONT_SIZE_OPTIONS: IDropdownOption[] = [ + { key: 'xsmall', text: '10', data: { px: 10 } }, + { key: 'small', text: '12', data: { px: 12 } }, + { key: 'medium', text: '14', data: { px: 14 } }, + { key: 'mediumplus', text: '16', data: { px: 16 } }, + { key: 'large', text: '18', data: { px: 18 } }, + { key: 'xlarge', text: '20', data: { px: 20 } }, + { key: 'xlargeplus', text: '24', data: { px: 24 } }, + { key: 'xxlarge', text: '28', data: { px: 28 } }, + { key: 'xxxlarge', text: '32', data: { px: 32 } }, + { key: 'xxlargeplus', text: '36', data: { px: 36 } }, + { key: 'super', text: '42', data: { px: 42 } }, + { key: 'superlarge', text: '68', data: { px: 68 } } +]; + export default class RichTextPropertyPane extends React.Component { + private _customSizeValuesPx: Set = new Set(); constructor(props: IRichTextPropertyPaneProps) { super(props); @@ -92,12 +109,95 @@ export default class RichTextPropertyPane extends React.Component { + const quill = this.props.editor; + + // Access Quill's root DOM element; if unavailable we cannot read computed styles. + const editor = quill?.root as HTMLElement; + if (!editor) { + return {}; + } + + // Normalize the selection index to a non-negative value. + const requestedIndex = Math.max(0, range.index || 0); + + // For caret selections (length 0), probe one character back when possible. + // This helps when the caret sits at a boundary where the next node has different styles. + const probeIndex = range.length === 0 && requestedIndex > 0 ? requestedIndex - 1 : requestedIndex; + + // Line-level node is the most reliable source for block formats (h2/h3/h4/blockquote/etc.). + const [line] = quill.getLine(probeIndex); + + // Convert Quill line model node to a DOM element we can inspect. + const lineElement = line?.domNode as HTMLElement; + + // Also inspect the exact piece of content where the cursor/selection is, + // so inline color/highlight styles can still be detected when needed. + const [leaf] = quill.getLeaf(probeIndex); + const leafNode = leaf?.domNode as Node; + + // If leaf is a text node, use its parent element; otherwise use the element directly. + const leafElement = leafNode?.nodeType === Node.TEXT_NODE + ? (leafNode.parentElement as HTMLElement) + : (leafNode as HTMLElement); + + // Choose the best target element for style inspection in priority order: + // 1) line element (best for block styles), + // 2) nearest block-like ancestor from leaf, + // 3) leaf element itself, + // 4) editor root as final fallback. + const targetElement = lineElement + || (leafElement?.closest('h1, h2, h3, h4, h5, h6, blockquote, p, div, li, ul, ol') as HTMLElement) + || leafElement + || editor; + + try { + // Read final, browser-resolved CSS values after all classes/cascades are applied. + const computed = getComputedStyle(targetElement); + + // Parse numeric font size in pixels from values like "18px". + const fontSize = parseInt(computed.fontSize, 10); + + // Resolve to a known Quill size key when it matches one of our supported options. + // Example: 28 -> "xxlarge". + const sizeKey = FONT_SIZE_OPTIONS.find((option) => option.data?.px === fontSize)?.key?.toString(); + + return { + // If size is known, return the Quill key; otherwise return raw px string (display-only fallback). + size: sizeKey || `${fontSize}px`, + color: computed.color, + background: computed.backgroundColor + }; + } catch { + return {}; + } + } + /** * Render the actions group */ @@ -183,26 +283,14 @@ export default class RichTextPropertyPane extends React.Component { // get the selected header const selectedSize = this.state.formats?.size ? this.state.formats.size : 'large'; + const sizeOptions = this.buildSortedSizeOptions(selectedSize); return (
@@ -318,19 +406,31 @@ export default class RichTextPropertyPane extends React.Component { const color: string = this.state.formats.color || ThemeColorHelper.GetThemeColor(styles.NeutralPrimary); const backgroundColor: string = this.state.formats.background || "rgba(0, 0, 0, 0)"; + const customFontColors = this.mergeSwatchColors( + this.props.customColors, + this.getCustomStyleSwatchColors('color') + ); + const customHighlightColors = this.getCustomStyleSwatchColors('backgroundColor'); /** * Add custom colors if passed as a property */ const fontColorGroups = ["themeColors","standardColors"]; - if(this.props.customColors) fontColorGroups.push('customColors'); + if (customFontColors.length > 0) { + fontColorGroups.push('customColors'); + } + + const highlightColorGroups = ["highlightColors"]; + if (customHighlightColors.length > 0) { + highlightColorGroups.push('customColors'); + } return (
this.handleFillColorChanged(undefined)} /> this.handleHighlightColorChanged(undefined)} previewColor={backgroundColor} defaultButtonLabel={strings.NoColorHighlightColor} selectedColor={backgroundColor} + customColors={customHighlightColors} id="highlightColor-propertyPaneButton" />
@@ -563,8 +662,15 @@ export default class RichTextPropertyPane extends React.Component { + const quill = this.props.editor; const newHeadingValue = item.key === 0 ? '' : item.key.toString(); - this.applyFormat("header", newHeadingValue); + + // Reset explicit font-size so heading defaults (including customStyles) can apply. + quill.format('size', false); + quill.format('header', newHeadingValue); + setTimeout(() => { + this.onChangeSelection(quill.getSelection()); + }, 100); } /** @@ -580,10 +686,214 @@ export default class RichTextPropertyPane extends React.Component { + if (typeof item.key === 'string' && item.key.endsWith('px')) { + const quill = this.props.editor; + // This option represents a computed size outside Quill's whitelist. + // Clear inline size so block-level/default styles can drive the rendered size. + quill.format('size', false); + setTimeout(() => { + this.onChangeSelection(quill.getSelection()); + }, 100); + return; + } + const newSizeValue = item.key === 0 ? '' : item.key.toString(); this.applyFormat("size", newSizeValue); } + /** + * Builds the size dropdown list by combining: + * - Quill whitelist sizes, + * - font sizes found in custom style classes, + * - and rendered custom sizes detected in the editor. + * + * Duplicate values are keyed by size and collapsed into a single option. + */ + private buildSortedSizeOptions = (selectedSize?: string): IDropdownOption[] => { + const optionsByKey: Record = {}; + + // Start with standard Quill sizes. + FONT_SIZE_OPTIONS.forEach((option) => { + optionsByKey[option.key.toString()] = option; + }); + + // Include custom sizes declared in customStyles. + this.getCustomStyleFontSizesPx().forEach((px) => { + const key = `${px}px`; + optionsByKey[key] = { + key, + text: String(px), + data: { px } + }; + }); + + // Include custom rendered sizes detected from current document content. + this._customSizeValuesPx.forEach((px) => { + const key = `${px}px`; + optionsByKey[key] = { + key, + text: String(px), + data: { px } + }; + }); + + // Keep currently selected value visible even if it is no longer in collected options. + if (selectedSize && !optionsByKey[selectedSize]) { + optionsByKey[selectedSize] = { + key: selectedSize, + text: selectedSize.endsWith('px') ? selectedSize.replace('px', '') : selectedSize + }; + } + + // Sort numerically so users see a predictable ascending list. + return Object.values(optionsByKey) + .sort((a, b) => this.getSizeOptionPx(a) - this.getSizeOptionPx(b)); + } + + /** + * Resolves a dropdown option to a numeric pixel value for sorting. + */ + private getSizeOptionPx = (option: IDropdownOption): number => { + // Preferred source: explicit numeric metadata. + const value = option.data?.px; + if (typeof value === 'number' && !Number.isNaN(value)) { + return value; + } + + // Fallback: parse displayed text value. + const textValue = parseInt(option.text?.toString() || '', 10); + // Invalid values are pushed to the end of the list. + return Number.isNaN(textValue) ? Number.MAX_SAFE_INTEGER : textValue; + } + + /** + * Extracts distinct font sizes (px) from the custom style configuration. + */ + private getCustomStyleFontSizesPx = (): number[] => { + const customStyles = this.props.customStyles; + if (!customStyles) { + return []; + } + + const fontSizesPx = new Set(); + + Object.values(customStyles).forEach((style) => { + const fontSize = style?.fontSize; + const fontSizePx = this.parseStyleSizePx(fontSize); + + if (fontSizePx !== undefined) { + fontSizesPx.add(fontSizePx); + } + }); + + return Array.from(fontSizesPx); + } + + /** + * Builds a deduplicated color list from custom style declarations for + * either font color or background color. + */ + private getCustomStyleSwatchColors = (styleKey: 'color' | 'backgroundColor'): ISwatchColor[] => { + const customStyles = this.props.customStyles; + if (!customStyles) { + return []; + } + + const colorsByValue: Record = {}; + + Object.values(customStyles).forEach((style) => { + const colorValue = style?.[styleKey]; + if (typeof colorValue !== 'string' || !colorValue.trim()) { + return; + } + + const normalizedColor = colorValue.trim().toLowerCase(); + colorsByValue[normalizedColor] = { + color: colorValue, + id: `custom-style-${styleKey}-${normalizedColor.replace(/[^a-z0-9]+/g, '-')}`, + label: colorValue + }; + }); + + return Object.values(colorsByValue); + } + + /** + * Merges multiple color groups into one list and deduplicates by normalized + * color value, so the picker does not show repeated entries. + */ + private mergeSwatchColors = (...colorGroups: Array): ISwatchColor[] => { + const colorsByValue: Record = {}; + + colorGroups.forEach((group) => { + group?.forEach((color) => { + const normalizedColor = color.color.trim().toLowerCase(); + if (!normalizedColor) { + return; + } + + colorsByValue[normalizedColor] = color; + }); + }); + + return Object.values(colorsByValue); + } + + /** + * Parses a CSS-like font size input into a numeric pixel value. + * Accepts number inputs or strings such as "18px". + */ + private parseStyleSizePx = (fontSize: string | number | undefined): number | undefined => { + if (typeof fontSize === 'number' && !Number.isNaN(fontSize)) { + return fontSize; + } + + if (typeof fontSize === 'string') { + const parsedFontSize = parseInt(fontSize, 10); + if (!Number.isNaN(parsedFontSize)) { + return parsedFontSize; + } + } + + return undefined; + } + + /** + * Scans the rendered editor content and stores non-whitelisted computed + * font sizes. These values are exposed in the size dropdown so current + * formatting can still be represented to the user. + */ + private refreshCustomSizeValuesFromDocument = (): void => { + const quill = this.props.editor; + const root = quill?.root as HTMLElement; + + if (!root) { + return; + } + + const customSizeValuesPx = new Set(); + const fontSizeElements = [root, ...Array.from(root.querySelectorAll('*'))]; + + fontSizeElements.forEach((element) => { + // Read the final computed font size after CSS cascade is applied. + const computedFontSize = getComputedStyle(element).fontSize; + const fontSizePx = parseInt(computedFontSize, 10); + + if (Number.isNaN(fontSizePx)) { + return; + } + + if (FONT_SIZE_OPTIONS.some((option) => option.data?.px === fontSizePx)) { + return; + } + + customSizeValuesPx.add(fontSizePx); + }); + + // Replace the cache atomically to keep updates simple and deterministic. + this._customSizeValuesPx = customSizeValuesPx; + } + /** * Apply the new format * diff --git a/src/controls/richText/RichTextPropertyPane.types.ts b/src/controls/richText/RichTextPropertyPane.types.ts index 22d6e1c94..a36a484bf 100644 --- a/src/controls/richText/RichTextPropertyPane.types.ts +++ b/src/controls/richText/RichTextPropertyPane.types.ts @@ -1,11 +1,13 @@ import type { Quill } from 'quill'; import { ISwatchColor } from './SwatchColorPickerGroup.types'; +import { IRichTextCustomFormattingStyles } from './RichText.types'; export interface IRichTextPropertyPaneProps { className?: string; editor: Quill; isOpen: boolean; customColors?: ISwatchColor[]; + customStyles?: IRichTextCustomFormattingStyles; onClose: () => void; onLink: () => void; } diff --git a/src/controls/richText/RteColorPicker.module.scss b/src/controls/richText/RteColorPicker.module.scss index 3c37aeca9..81af73bc4 100644 --- a/src/controls/richText/RteColorPicker.module.scss +++ b/src/controls/richText/RteColorPicker.module.scss @@ -25,7 +25,8 @@ height: 20px; &.border { - border: 1px solid "[theme: black, default: #{$ms-color-black}]"; + outline: 1px solid "[theme: black, default: #{$ms-color-black}]"; + outline-offset: -1px; } } @@ -41,7 +42,6 @@ .previewIcon { padding-left: 7px; font-size: 7px; - padding-top: 8px; } .pickerCallout { @@ -62,7 +62,8 @@ } &.border { - border: 1px solid "[theme:black, default: #{$ms-color-black}]"; + outline: 1px solid "[theme:black, default: #{$ms-color-black}]"; + outline-offset: -1px; } &.fillDefaultColor { diff --git a/src/webparts/controlsTest/components/ControlsTest.module.scss b/src/webparts/controlsTest/components/ControlsTest.module.scss index b53eab565..c16c41052 100644 --- a/src/webparts/controlsTest/components/ControlsTest.module.scss +++ b/src/webparts/controlsTest/components/ControlsTest.module.scss @@ -137,3 +137,17 @@ $themePrimary: '[theme:themePrimary, default:#0078d7]'; } } } + +.richTextActions { + margin-top: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + + .richTextToggles { + display: flex; + align-items: center; + gap: 12px; + } +} diff --git a/src/webparts/controlsTest/components/ControlsTest.tsx b/src/webparts/controlsTest/components/ControlsTest.tsx index 84a0f0cdf..98b17617a 100644 --- a/src/webparts/controlsTest/components/ControlsTest.tsx +++ b/src/webparts/controlsTest/components/ControlsTest.tsx @@ -154,6 +154,7 @@ import { TermActionsDisplayStyle } from '../../../controls/taxonomyPicker'; import TestCalendarControl from './TestCalendarControl'; import { Text } from '@fluentui/react/lib/Text'; import { TextField } from '@fluentui/react/lib/TextField'; +import { Toggle } from '@fluentui/react/lib/Toggle'; import { TimeDisplayControlType, } from '../../../controls/dateTimePicker/TimeDisplayControlType'; @@ -169,6 +170,7 @@ import { debounce } from 'lodash'; import { mergeStyles } from '@fluentui/react/lib/Styling'; import { sp } from '@pnp/sp'; import styles from './ControlsTest.module.scss'; +import type { IRichTextCustomFormattingStyles } from '../../../RichText'; //#endregion @@ -387,6 +389,18 @@ const filterBarFilters = [{ label: "Field4", value: "Field 4-2" }]; + +const RICH_TEXT_RESET_VALUE = [ + '

10px inline size sample and 18px sample.

', + '

Heading 2 sample with custom root styling

', + '

Heading 3 sample with custom root styling

', + '

Heading 4 sample with custom root styling

', + '
Block quote sample. This line validates quote spacing, border and emphasis.
', + '
Div sample text with a documentation link.
', + '

Paragraph sample with font color, highlight, and combined color/highlight.

', + '
  • Unordered item one
  • Unordered item two with inline color
', + '
  1. Ordered item one
  2. Ordered item two with inline highlight
' +].join(''); /** * Component that can be used to test out the React controls from this project */ @@ -593,6 +607,66 @@ export default class ControlsTest extends React.Component = React.createRef(); private peoplePickerContext: IPeoplePickerContext; private termSetId: string = "8ed8c9ea-7052-4c1d-a4d7-b9c10bffea6f"; + private readonly richTextCustomFormattingStyles: IRichTextCustomFormattingStyles = { + normal: { + color: '#1f1f1f', + backgroundColor: '#fbfcff', + fontSize: 18, + lineHeight: '1.5' + }, + header2: { + color: '#0f548c', + fontSize: 34, + letterSpacing: '0.01em', + marginTop: 18, + marginBottom: 10 + }, + header3: { + color: '#7a2e0b', + fontSize: 28, + marginTop: 16, + marginBottom: 8 + }, + header4: { + color: '#4a3f70', + fontSize: 22, + textTransform: 'uppercase', + letterSpacing: '0.04em', + marginTop: 14, + marginBottom: 8 + }, + blockQuote: { + backgroundColor: '#f7f2ec', + borderTopColor: '#d7cab8', + borderBottomColor: '#d7cab8', + color: '#5b4636', + fontSize: 22, + fontStyle: 'italic' + }, + div: { + borderLeft: '3px solid #b7d6f7', + paddingLeft: 10 + }, + p: { + borderLeft: '3px solid #b7d6f7', + paddingLeft: 10, + marginTop: 10, + marginBottom: 10 + }, + ul: { + backgroundColor: '#f2f8ff', + paddingTop: 6, + paddingBottom: 6 + }, + ol: { + backgroundColor: '#fff8f1', + paddingTop: 6, + paddingBottom: 6 + }, + li: { + marginBottom: 6 + } + }; constructor(props: IControlsTestProps) { super(props); @@ -608,6 +682,8 @@ export default class ControlsTest extends React.Component {/* { this.richTextValue = value; return value; }} /> */} - { this.setState({ richTextValue: value }); return value; }} /> - { this.setState({ richTextValue: 'test' }); }} /> + { + this.setState({ richTextValue: value }); + return value; + }} + /> +
+ { this.setState({ richTextValue: RICH_TEXT_RESET_VALUE }); }} /> +
+ { + this.setState({ richTextEditMode: !!checked }); + }} + onText="On" + offText="Off" + /> + { + this.setState({ richTextUseCustomFormatting: !!checked }); + }} + onText="On" + offText="Off" + /> +
+
} {controlVisibility.Placeholder && diff --git a/src/webparts/controlsTest/components/IControlsTestState.ts b/src/webparts/controlsTest/components/IControlsTestState.ts index 266b6d303..44f63b046 100644 --- a/src/webparts/controlsTest/components/IControlsTestState.ts +++ b/src/webparts/controlsTest/components/IControlsTestState.ts @@ -19,6 +19,8 @@ export interface IControlsTestState { currentProgressActionIndex?: number; dateTimeValue: Date; richTextValue: string; + richTextEditMode: boolean; + richTextUseCustomFormatting: boolean; currentCarouselElement: JSX.Element; canMovePrev: boolean; canMoveNext: boolean;