import * as React from 'react' ; import * as strings from 'ControlStrings' ; import 'react-quill/dist/quill.snow.css' ; import RichTextPropertyPane from './RichTextPropertyPane' ; import ReactQuill , { Quill } from 'react-quill' ; import styles from './RichText.module.scss' ; import { IRichTextProps , IRichTextState } from './RichText.types' ; import { IconButton } from 'office-ui-fabric-react/lib/Button' ; import { Guid } from '@microsoft/sp-core-library' ; import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip' ; import { Dialog , DialogType , DialogFooter } from 'office-ui-fabric-react/lib/Dialog' ; import { TextField } from 'office-ui-fabric-react/lib/TextField' ; import { Link } from 'office-ui-fabric-react/lib/Link' ; import { PrimaryButton , DefaultButton } from 'office-ui-fabric-react/lib/Button' ; import { Dropdown , IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown' ; import { Icon } from 'office-ui-fabric-react/lib/Icon' ; import { elementContains } from 'office-ui-fabric-react/lib/Utilities' ; import * as telemetry from '../../common/telemetry' ; import { initializeIcons } from 'office-ui-fabric-react/lib/Icons' ; import isEqual from 'lodash/isEqual' ; const TOOLBARPADDING = 28 ; /**
 * Creates a rich text editing control that mimics the out-of-the-box
 * SharePoint Rich Text control.
 * NOTE:
 * Quill.js has a few quirks that we can't work around
 * - Block quotes only work on single lines. This is a frequently-requested feature with Quill that isn't available yet.
 * - Tables aren't supported yet. I'll gladly add table formatting support if users request it.
 */ initializeIcons ( ) ; export class RichText extends React . Component < IRichTextProps , IRichTextState > { private _quillElem : ReactQuill = undefined ; private _wrapperRef : HTMLDivElement = undefined ; private _propertyPaneRef : RichTextPropertyPane = undefined ; private _toolbarId = undefined ; private ddStyleOpts = [ { key : 0 , text : strings . HeaderNormalText , data : { } } , { key : 2 , text : strings . HeaderH2 , data : { className : styles . toolbarButtonH2 } } , { key : 3 , text : strings . HeaderH3 , data : { className : styles . toolbarButtonH3 } } , { key : 4 , text : strings . HeaderH4 , data : { className : styles . toolbarButtonH4 } } , { key : 7 , text : strings . HeaderBlockQuote , data : { className : styles . toolbarButtonBlockQuote } } ] ; private ddAlignOpts = [ { key : 'left' , text : strings . AlignLeft , data : { icon : 'AlignLeft' } } , { key : 'center' , text : strings . AlignCenter , data : { icon : 'AlignCenter' } } , { key : 'right' , text : strings . AlignRight , data : { icon : 'AlignRight' } } ] ; private ddListOpts = [ { key : 'bullet' , text : strings . ListBullet , data : { icon : 'BulletedList' } } , { key : 'ordered' , text : strings . ListNumbered , data : { icon : 'NumberedList' } } ] ; /**
     * Sets default properties
     */ public static defaultProps : Partial < IRichTextProps > = { isEditMode : true , styleOptions : { showStyles : true , showBold : true , showItalic : true , showUnderline : true , showAlign : true , showList : true , showLink : true , showImage : true , showMore : true } } ; constructor ( props ) { super ( props ) ; telemetry . track ( 'ReactRichText' , { className : ! ! props . className } ) ; this . state = { text : this . props . value , editing : false , morePaneVisible : false , hideDialog : true , hideImageDialog : true , formats : { } , insertUrl : undefined , insertUrlText : undefined , insertImageUrl : undefined , selectedText : undefined , selectedUrl : undefined , wrapperTop : 0 } ; this . _toolbarId = "toolbar_" + Guid . newGuid ( ) . toString ( ) ; } /**
     * Attaches to mouse down events to determine if we clicked outside
     */ public componentDidMount ( ) { if ( this . props . isEditMode ) { document . addEventListener ( 'click' , this . handleClickOutside ) ; document . addEventListener ( 'focus' , this . handleClickOutside ) ; const clientRect = this . _wrapperRef . getBoundingClientRect ( ) ; const parentClientRect = this . _wrapperRef . parentElement . getBoundingClientRect ( ) ; const toolbarTop = clientRect . top - parentClientRect . top - TOOLBARPADDING ; this . setState ( { wrapperTop : toolbarTop } ) ; } } /**
     * Removes the mouse down event
     */ public componentWillUnmount ( ) { if ( this . props . isEditMode ) { document . removeEventListener ( 'click' , this . handleClickOutside ) ; document . removeEventListener ( 'focus' , this . handleClickOutside ) ; } } param prevProps param prevState public componentDidUpdate ( prevProps , prevState ) { if ( this . props . isEditMode && ! prevProps . isEditMode ) { document . addEventListener ( 'click' , this . handleClickOutside ) ; document . addEventListener ( 'focus' , this . handleClickOutside ) ; } if ( ! this . props . isEditMode && prevProps . isEditMode ) { document . removeEventListener ( 'click' , this . handleClickOutside ) ; document . removeEventListener ( 'focus' , this . handleClickOutside ) ; } } param nextProps param nextState public shouldComponentUpdate ( nextProps , nextState ) { if ( isEqual ( nextState , this . state ) && isEqual ( nextProps , this . props ) ) { return false ; } return true ; } public componentWillReceiveProps ( nextProps ) { if ( nextProps . value !== this . props . value && nextProps . value !== this . state . text ) { this . setState ( { text : nextProps . value } ) ; } } /**
     * Returns a handle to the Quill editor
     */ public getEditor = ( ) : Quill => { try { return this . _quillElem ! . getEditor ( ) ; } catch ( error ) { return undefined ; } } ; param option private onRenderStyleOption ( option ) { return ( < TooltipHost content = { option . text } id = { `${ option . text }-toolbarButton` } calloutProps = { { gapSpace : 0 } } > < div className = { `${ styles . toolbarDropDownOption } ${ option . data ! . className ? option . data ! . className : "" }` } aria - describedby = { `${ option . text }-toolbarButton` } > < span > { option . text } < /span> < /div> < /TooltipHost>); } private onRenderStyleTitle ( options ) { const option = options [ 0 ] ; return ( < TooltipHost content = { option . text } id = { `${ option . text }-dropDownTitle` } calloutProps = { { gapSpace : 0 } } > < div className = { styles . toolbarSubmenuDisplayButton } aria - describedby = { `${ option . text }-dropDownTitle` } > < span > { option . text } < /span> < /div> < /TooltipHost>); } private onRenderAlignOption ( option ) { return ( < TooltipHost content = { option . text } id = { `${ option . text }-toolbarButton` } calloutProps = { { gapSpace : 0 } } > < div className = { `${ styles . toolbarDropDownOption } ${ option . data ! . className ? option . data ! . className : "" }` } aria - describedby = { `${ option . text }-toolbarButton` } > < Icon className = { styles . toolbarDropDownIcon } iconName = { option . data . icon } aria - hidden = "true" / > < /div> < /TooltipHost>); } private onRenderListTitle ( options ) { const option = options [ 0 ] ; return ( < TooltipHost content = { option . text } id = { `${ option . text }-dropDownTitle` } calloutProps = { { gapSpace : 0 } } > < div className = { styles . toolbarSubmenuDisplayButton } aria - describedby = { `${ option . text }-dropDownTitle` } > < Icon className = { styles . toolbarDropDownTitleIcon } iconName = { option . data . icon } aria - hidden = "true" / > < /div> < /TooltipHost>); } private onRenderAlignTitle ( options ) { const option = options [ 0 ] ; return ( < TooltipHost content = { option . text } id = { `${ option . text }-dropDownTitle` } calloutProps = { { gapSpace : 0 } } > < div className = { styles . toolbarSubmenuDisplayButton } aria - describedby = { `${ option . text }-dropDownTitle` } > < Icon className = { styles . toolbarDropDownTitleIcon } iconName = { option . data . icon } aria - hidden = "true" / > < /div> < /TooltipHost>); } private onRenderListOption ( option ) { return ( < TooltipHost content = { option . text } id = { `${ option . text }-toolbarButton` } calloutProps = { { gapSpace : 0 } } > < div className = { `${ styles . toolbarDropDownOption } ${ option . data ! . className ? option . data ! . className : "" }` } aria - describedby = { `${ option . text }-toolbarButton` } > < Icon className = { styles . toolbarDropDownIcon } iconName = { option . data . icon } aria - hidden = "true" / > < /div> < /TooltipHost>); } private onRenderListPlaceholder ( ) { return ( < TooltipHost content = { "Placeholder" } id = { `Placeholder-dropDownTitle` } calloutProps = { { gapSpace : 0 } } > < div className = { styles . toolbarSubmenuDisplayButton } aria - describedby = { `Placeholder-dropDownTitle` } > < Icon className = { styles . toolbarDropDownTitleIcon } iconName = { 'BulletedList' } aria - hidden = "true" / > < /div> < /TooltipHost>); } private renderLinkDialog = ( ) : JSX . Element => { return ( < Dialog hidden = { this . state . hideDialog } onDismiss = { this . closeDialog } dialogContentProps = { { type : DialogType . normal , title : strings . InsertLinkTitle , } } modalProps = { { className : styles . insertLinkDialog , isBlocking : true , containerClassName : 'ms-dialogMainOverride' } } > < TextField label = { strings . AddressFieldLabel } value = { this . state . insertUrl !== undefined ? this . state . insertUrl : "https://" } onChange = { ( e , newValue ? ) => { this . setState ( { insertUrl : newValue } ) ; } } /> < TextField label = { strings . TextToDisplayLabel } value = { this . state . insertUrlText } onChange = { ( e , newValue ? ) => { if ( newValue !== this . state . insertUrlText ) { this . setState ( { insertUrlText : newValue } ) ; } } } /> < DialogFooter className = { styles . actions } > < div className = { `ms-Dialog-actionsRight ${ styles . actionsRight }` } > { this . state . selectedUrl && ( < Link className = { `${ styles . action } ${ styles . unlinkButton }` } onClick = { this . handleRemoveLink } > { strings . RemoveLinkLabel } < /Link>)} < PrimaryButton className = { styles . action } onClick = { this . handleCreateLink } text = { strings . SaveButtonLabel } disabled = { this . checkLinkUrl ( ) } / > < DefaultButton className = { styles . action } onClick = { this . closeDialog } text = { strings . CancelButtonLabel } / > < /div> < /DialogFooter> < /Dialog>); } ; private renderImageDialog = ( ) : JSX . Element => { return ( < Dialog hidden = { this . state . hideImageDialog } onDismiss = { this . closeImageDialog } dialogContentProps = { { type : DialogType . normal , title : strings . InsertImageTitle , } } modalProps = { { className : styles . insertLinkDialog , isBlocking : true , containerClassName : 'ms-dialogMainOverride' } } > < TextField label = { strings . AddressFieldLabel } value = { this . state . insertImageUrl !== undefined ? this . state . insertImageUrl : "" } onChange = { ( e , newValue ? ) => { this . setState ( { insertImageUrl : newValue } ) ; } } /> < DialogFooter className = { styles . actions } > < div className = { `ms-Dialog-actionsRight ${ styles . actionsRight }` } > < PrimaryButton className = { styles . action } onClick = { this . handleInsertImage } text = { strings . SaveButtonLabel } disabled = { this . checkImageLinkUrl ( ) } / > < DefaultButton className = { styles . action } onClick = { this . closeImageDialog } text = { strings . CancelButtonLabel } / > < /div> < /DialogFooter> < /Dialog>); } ; public render ( ) { const { text } = this . state ; const { isEditMode } = this . props ; if ( ! isEditMode ) { return ( < div className = { `ql-editor ${ styles . richtext } ${ this . props . className || '' }` } dangerouslySetInnerHTML = { { __html : text } } > < /div>); } const { placeholder , styleOptions : { showStyles , showBold , showItalic , showUnderline , showAlign , showList , showLink , showMore , showImage } } = this . props ; const modules = { toolbar : { container : "#" + this . _toolbarId , handlers : [ "link" ] } , clipboard : { matchVisual : false } } ; let font = Quill . import ( 'formats/font' ) ; font . whitelist = [ 'Segoe UI' ] ; Quill . register ( font , true ) ; let header = Quill . import ( 'formats/header' ) ; header . tagName = [ 'H1' , 'H2' , 'H3' , 'H4' , 'H5' , 'H6' , 'blockquote' ] ; Quill . register ( header , true ) ; let SizeClass = Quill . import ( 'formats/size' ) ; SizeClass . whitelist = [ 'small' , 'medium' , 'mediumplus' , 'large' , 'xlarge' , 'xlargeplus' , 'xxlarge' , 'xxxlarge' , 'xxlargeplus' , 'super' ] ; Quill . register ( SizeClass , true ) ; return ( < div ref = { ( ref ) => this . _wrapperRef = ref } className = { `${ styles . richtext && this . state . editing ? 'ql-active' : '' } ${ this . props . className }` } > < div id = { this . _toolbarId } style = { { top : this . state . wrapperTop } } > { showStyles && ( < Dropdown id = "DropDownStyles" className = { `${ styles . headerDropDown } ${ styles . toolbarDropDown }` } onRenderCaretDown = { ( ) => < Icon className = { styles . toolbarSubmenuCaret } iconName = "CaretDownSolid8" / > } selectedKey = { this . state . formats . header || 0 } options = { this . ddStyleOpts } onChanged = { this . onChangeHeading } onRenderOption = { this . onRenderStyleOption } onRenderTitle = { this . onRenderStyleTitle } / > ) } { showBold && ( < TooltipHost content = { strings . BoldTitle } id = "bold-richtextbutton" calloutProps = { { gapSpace : 0 } } > < IconButton iconProps = { { iconName : 'Bold' } } aria - describedby = "bold-richtextbutton" checked = { this . state . formats . bold } onClick = { this . onChangeBold } / > < /TooltipHost>)} { showItalic && ( < TooltipHost content = { strings . ItalicTitle } id = "italic-richtextbutton" calloutProps = { { gapSpace : 0 } } > < IconButton iconProps = { { iconName : 'Italic' } } aria - describedby = "italic-richtextbutton" checked = { this . state . formats . italic } onClick = { this . onChangeItalic } / > < /TooltipHost>)} { showUnderline && ( < TooltipHost content = { strings . UnderlineTitle } id = "underline-richtextbutton" calloutProps = { { gapSpace : 0 } } > < IconButton iconProps = { { iconName : 'Underline' } } aria - describedby = "underline-richtextbutton" checked = { this . state . formats . underline } onClick = { this . onChangeUnderline } / > < /TooltipHost>)} { showAlign && ( < Dropdown className = { `${ styles . toolbarDropDown }` } id = "DropDownAlign" onRenderCaretDown = { ( ) => < Icon className = { styles . toolbarSubmenuCaret } iconName = "CaretDownSolid8" / > } selectedKey = { this . state . formats . align || 'left' } options = { this . ddAlignOpts } onChanged = { this . onChangeAlign } onRenderOption = { this . onRenderAlignOption } onRenderTitle = { this . onRenderAlignTitle } / > ) } { showList && ( < Dropdown className = { styles . toolbarDropDown } id = "DropDownLists" onRenderCaretDown = { ( ) => < Icon className = { styles . toolbarSubmenuCaret } iconName = "CaretDownSolid8" / > } selectedKey = { this . state . formats . list } options = { this . ddListOpts } notifyOnReselect = { true } onChanged = { this . onChangeList } onRenderOption = { this . onRenderListOption } onRenderTitle = { this . onRenderListTitle } onRenderPlaceHolder = { this . onRenderListPlaceholder } / > ) } { showLink && ( < TooltipHost content = { strings . LinkTitle } id = "link-richtextbutton" calloutProps = { { gapSpace : 0 } } > < IconButton checked = { this . state . formats ! . link !== undefined } onClick = { this . showInsertLinkDialog } aria - describedby = "link-richtextbutton" iconProps = { { iconName : 'Link' } } /> < /TooltipHost>)} { showImage && ( < TooltipHost content = { strings . ImageTitle } id = "image-richtextbutton" calloutProps = { { gapSpace : 0 } } > < IconButton onClick = { this . showInsertImageDialog } aria - describedby = "image-richtextbutton" iconProps = { { iconName : 'PictureFill' } } /> < /TooltipHost>)} { showMore && ( < TooltipHost content = { strings . MoreTitle } id = "more-richtextbutton" calloutProps = { { gapSpace : 0 } } > < IconButton iconProps = { { iconName : 'More' } } aria - describedby = "more-richtextbutton" onClick = { this . handleShowMore } / > < /TooltipHost>)} < /div> < ReactQuill ref = { this . linkQuill } placeholder = { placeholder } modules = { modules } value = { text || '' } onChange = { this . handleChange } onChangeSelection = { this . handleChangeSelection } onFocus = { this . handleOnFocus } / > < RichTextPropertyPane ref = { this . linkPropertyPane } editor = { this . getEditor ( ) } isOpen = { this . state . morePaneVisible } onClose = { this . handleClosePanel } onLink = { this . showInsertLinkDialog } customColors = { this . props . customColors } / > { this . renderLinkDialog ( ) } { this . renderImageDialog ( ) } < /div>); } private onChangeBold = ( ) : void => { const newBoldValue = ! this . state . formats . bold ; this . applyFormat ( "bold" , newBoldValue ) ; } ; private onChangeItalic = ( ) : void => { const newValue = ! this . state . formats . italic ; this . applyFormat ( "italic" , newValue ) ; } ; private onChangeUnderline = ( ) : void => { const newValue = ! this . state . formats . underline ; this . applyFormat ( "underline" , newValue ) ; } ; private onChangeHeading = ( item ) : void => { const newHeadingValue = item . key === 0 ? '' : item . key . toString ( ) ; this . applyFormat ( "header" , newHeadingValue ) ; } ; private onChangeAlign = ( item ) : void => { const newAlignValue = item . key === 'left' ? false : item . key . toString ( ) ; this . applyFormat ( "align" , newAlignValue ) ; } ; private onChangeList = ( item ) : void => { const key = item . key ; const newAlignValue = ( key === 'bullet' && this . state . formats . list === 'bullet' ) || ( key === 'numbered' && this . state . formats . list === 'numbered' ) ? false : key ; this . applyFormat ( "list" , newAlignValue ) ; } ; private showInsertLinkDialog = ( ) => { const quill = this . getEditor ( ) ; const range = quill . getSelection ( ) ; let linkText = this . state . selectedText ; if ( this . state . selectedUrl !== undefined && this . state . selectedText === "" ) { const { text } = this . state ; const urlStartIndex = text . indexOf ( this . state . selectedUrl ) ; const startTextIndex = text . indexOf ( ">" , urlStartIndex ) + 1 ; const endTextIndex = text . indexOf ( "<" , startTextIndex ) ; const realLength = endTextIndex - startTextIndex ; linkText = text . substr ( startTextIndex , realLength ) ; const editorText = quill . getText ( ) ; const linkStart = editorText . indexOf ( linkText ) ; range . index = linkStart ; range . length = linkText . length ; } this . setState ( { hideDialog : false , insertUrlText : linkText , insertUrl : this . state . selectedUrl , selectedRange : range } ) ; } ; private closeDialog = ( ) => { this . setState ( { hideDialog : true } ) ; } ; private showInsertImageDialog = ( ) => { const quill = this . getEditor ( ) ; const range = quill . getSelection ( ) ; this . setState ( { hideImageDialog : false , selectedRange : range } ) ; } ; private closeImageDialog = ( ) => { this . setState ( { hideImageDialog : true , insertImageUrl : undefined , } ) ; } ; private handleOnFocus = ( range , source , editor ) => { if ( ! this . state . editing ) { this . setState ( { editing : true } ) ; } } ; private handleRemoveLink = ( ) => { const quill = this . getEditor ( ) ; quill . format ( 'link' , false ) ; this . closeDialog ( ) ; } ; private handleCreateLink = ( ) => { const quill = this . getEditor ( ) ; const range = this . state . selectedRange ; const cursorPosition = range ! . index ; if ( range . length > 0 ) { quill . deleteText ( range . index , range . length ) ; } if ( cursorPosition > - 1 ) { const textToInsert = ( this . state . insertUrlText !== undefined && this . state . insertUrlText !== "" ) ? this . state . insertUrlText : this . state . insertUrl ; const urlToInsert = this . state . insertUrl ; quill . insertText ( cursorPosition , textToInsert ) ; quill . setSelection ( cursorPosition , textToInsert . length ) ; quill . formatText ( cursorPosition , textToInsert . length , 'link' , urlToInsert ) ; } this . setState ( { hideDialog : true , insertUrl : undefined , insertUrlText : undefined } ) ; } ; private handleInsertImage = ( ) => { const { insertImageUrl , selectedRange } = this . state ; try { const quill = this . getEditor ( ) ; const cursorPosition = selectedRange ! . index ; quill . insertEmbed ( cursorPosition , 'image' , insertImageUrl , "user" ) ; this . setState ( { insertImageUrl : undefined , hideImageDialog : true , } ) ; } catch { this . setState ( { insertImageUrl : undefined , hideImageDialog : true , } ) ; } } ; private checkLinkUrl = ( ) => { if ( this . state . insertUrl !== undefined && this . state . insertUrl != "" ) { return false ; } return true ; } ; private checkImageLinkUrl = ( ) => { if ( this . state . insertImageUrl !== undefined && this . state . insertImageUrl != "" ) { return false ; } return true ; } ; private applyFormat ( name , value ) { const quill = this . getEditor ( ) ; quill . format ( name , value ) ; setTimeout ( ( ) => { this . handleChangeSelection ( quill . getSelection ( ) , undefined , undefined ) ; } , 100 ) ; } private handleChangeSelection = ( range , oldRange , source ) => { const quill = this . getEditor ( ) ; try { if ( quill ) { const selectedText = quill . getText ( range ) ; const formats = quill . getFormat ( range ) ; const selectedUrl = formats . link ? formats . link : undefined ; this . setState ( { selectedText : selectedText , selectedUrl : selectedUrl , formats : formats } ) ; if ( this . _propertyPaneRef && this . state . morePaneVisible ) { this . _propertyPaneRef . onChangeSelection ( range , oldRange , source ) ; } } } catch ( error ) { } } ; private handleClosePanel = ( ) : void => { this . closePanel ( ) ; } ; private closePanel = ( ) : void => { this . setState ( { morePaneVisible : false } ) ; } ; private handleShowMore = ( ) => { this . setState ( { morePaneVisible : ! this . state . morePaneVisible } , ( ) => { this . getEditor ( ) . focus ( ) ; } ) ; } ; private handleChange = ( value ) => { const { onChange } = this . props ; const newState = { } ; const quill = this . getEditor ( ) ; if ( quill ) { const range = quill . getSelection ( ) ; if ( range ) { const formats = quill . getFormat ( range ) ; if ( ! isEqual ( formats , this . state . formats ) ) { console . log ( `current format: ${ formats . list }` ) ; newState . formats = formats ; } } } if ( onChange ) { let newText = onChange ( value ) ; newState . text = newText ; } else { newState . text = value ; } this . setState ( { ... newState } ) ; } ; private handleClickOutside = ( event ) => { let outside = ! elementContains ( this . _wrapperRef , event . target ) ; if ( outside ) { if ( this . state . editing ) { this . setState ( { editing : false } ) ; } } else { if ( ! this . state . editing ) { this . setState ( { editing : true } ) ; } } } ; private linkQuill = ( e ) => { this . _quillElem = e ; } ; private linkPropertyPane = ( e ) => { this . _propertyPaneRef = e ; } ; }