import * as React from 'react' ; import Icon from '../components/Icon' ; import NavigationBar from '../components/NavigationBar' ; import ToolBar from '../components/ToolBar' ; import i18n from '../i18n' ; import DividerPlugin from '../plugins/divider' ; import Emitter , { globalEmitter } from '../share/emitter' ; import { EditorConfig , EditorEvent , initialSelection , KeyboardEventListener , Selection } from '../share/var' ; import getDecorated from '../utils/decorate' ; import mergeConfig from '../utils/mergeConfig' ; import { getLineAndCol , isKeyMatch , isPromise } from '../utils/tool' ; import getUploadPlaceholder from '../utils/uploadPlaceholder' ; import defaultConfig from './defaultConfig' ; import { HtmlRender , HtmlType } from './preview' ; type Plugin = { comp : any ; config : any ; } ; interface EditorProps extends EditorConfig { id ? : string ; defaultValue ? : string ; value ? : string ; renderHTML : ( text : string ) => HtmlType | Promise < HtmlType > | ( ( ) => HtmlType ) ; style ? : React . CSSProperties ; autoFocus ? : boolean ; placeholder ? : string ; readOnly ? : boolean ; className ? : string ; config ? : any ; plugins ? : string [ ] ; onChange ? : ( data : { text : string ; html : string ; } , event ? : React . ChangeEvent < HTMLTextAreaElement > ) => void ; onFocus ? : ( e : React . FocusEvent < HTMLTextAreaElement > ) => void ; onBlur ? : ( e : React . FocusEvent < HTMLTextAreaElement > ) => void ; onScroll ? : ( e : React . UIEvent < HTMLTextAreaElement | HTMLDivElement > , type : 'md' | 'html' ) => void ; } interface EditorState { text : string ; html : HtmlType ; fullScreen : boolean ; plugins : { [ x ] : React . ReactElement [ ] ; } ; view : { menu : boolean ; md : boolean ; html : boolean ; } ; } class Editor extends React . Component < EditorProps , EditorState > { private static plugins : Plugin [ ] = [ ] ; param { any } comp param { any } config static use ( comp , config = { } ) { for ( let i = 0 ; i < Editor . plugins . length ; i ++ ) { if ( Editor . plugins [ i ] . comp === comp ) { Editor . plugins . splice ( i , 1 , { comp , config } ) ; return ; } } Editor . plugins . push ( { comp , config } ) ; } param { any } comp static unuse ( comp ) { for ( let i = 0 ; i < Editor . plugins . length ; i ++ ) { if ( Editor . plugins [ i ] . comp === comp ) { Editor . plugins . splice ( i , 1 ) ; return ; } } } param { any } comp static unuseAll ( ) { Editor . plugins = [ ] ; } /**
     * Locales
     */ static addLocale = i18n . add . bind ( i18n ) ; static useLocale = i18n . setCurrent . bind ( i18n ) ; static getLocale = i18n . getCurrent . bind ( i18n ) ; private config : EditorConfig ; private emitter : Emitter ; private nodeMdText = React . createRef < HTMLTextAreaElement > ( ) ; private nodeMdPreview = React . createRef < HtmlRender > ( ) ; private nodeMdPreviewWrapper = React . createRef < HTMLDivElement > ( ) ; private hasContentChanged = true ; private composing = false ; private handleInputScroll : ( e : React . UIEvent < HTMLTextAreaElement > ) => void ; private handlePreviewScroll : ( e : React . UIEvent < HTMLDivElement > ) => void ; constructor ( props ) { super ( props ) ; this . emitter = new Emitter ( ) ; this . config = mergeConfig ( defaultConfig , this . props . config , this . props ) ; this . state = { text : ( this . props . value || this . props . defaultValue || '' ) . replace ( /↵/g , '\n' ) , html : '' , view : this . config . view || defaultConfig . view ! , fullScreen : false , plugins : this . getPlugins ( ) , } ; if ( this . config . canView && ! this . config . canView . menu ) { this . state . view . menu = false ; } this . nodeMdText = React . createRef ( ) ; this . nodeMdPreviewWrapper = React . createRef ( ) ; this . handleChange = this . handleChange . bind ( this ) ; this . handlePaste = this . handlePaste . bind ( this ) ; this . handleDrop = this . handleDrop . bind ( this ) ; this . handleToggleMenu = this . handleToggleMenu . bind ( this ) ; this . handleKeyDown = this . handleKeyDown . bind ( this ) ; this . handleEditorKeyDown = this . handleEditorKeyDown . bind ( this ) ; this . handleLocaleUpdate = this . handleLocaleUpdate . bind ( this ) ; this . handleFocus = this . handleFocus . bind ( this ) ; this . handleBlur = this . handleBlur . bind ( this ) ; this . handleInputScroll = this . handleSyncScroll . bind ( this , 'md' ) ; this . handlePreviewScroll = this . handleSyncScroll . bind ( this , 'html' ) ; } componentDidMount ( ) { const { text } = this . state ; this . renderHTML ( text ) ; globalEmitter . on ( globalEmitter . EVENT_LANG_CHANGE , this . handleLocaleUpdate ) ; i18n . setUp ( ) ; } componentWillUnmount ( ) { globalEmitter . off ( globalEmitter . EVENT_LANG_CHANGE , this . handleLocaleUpdate ) ; } componentDidUpdate ( prevProps ) { if ( typeof this . props . value !== 'undefined' && this . props . value !== this . state . text ) { let { value } = this . props ; if ( typeof value !== 'string' ) { value = String ( value ) . toString ( ) ; } value = value . replace ( /↵/g , '\n' ) ; if ( this . state . text !== value ) { this . setState ( { text : value , } ) ; this . renderHTML ( value ) ; } } if ( prevProps . plugins !== this . props . plugins ) { this . setState ( { plugins : this . getPlugins ( ) , } ) ; } } private getPlugins ( ) { let plugins = [ ] ; if ( this . props . plugins ) { const addToPlugins = ( name ) => { if ( name === DividerPlugin . pluginName ) { plugins . push ( { comp : DividerPlugin , config : { } , } ) ; return ; } for ( const it of Editor . plugins ) { if ( it . comp . pluginName === name ) { plugins . push ( it ) ; return ; } } } ; for ( const name of this . props . plugins ) { if ( name === 'fonts' ) { addToPlugins ( 'font-bold' ) ; addToPlugins ( 'font-italic' ) ; addToPlugins ( 'font-underline' ) ; addToPlugins ( 'font-strikethrough' ) ; addToPlugins ( 'list-unordered' ) ; addToPlugins ( 'list-ordered' ) ; addToPlugins ( 'block-quote' ) ; addToPlugins ( 'block-wrap' ) ; addToPlugins ( 'block-code-inline' ) ; addToPlugins ( 'block-code-block' ) ; } else { addToPlugins ( name ) ; } } } else { plugins = [ ... Editor . plugins ] ; } const result = { } ; plugins . forEach ( ( it ) => { if ( typeof result [ it . comp . align ] === 'undefined' ) { result [ it . comp . align ] = [ ] ; } result [ it . comp . align ] . push ( React . createElement ( it . comp , { editor : this , editorConfig : this . config , config : { ... ( it . comp . defaultConfig || { } ) , ... ( it . config || { } ) , } , key : it . comp . pluginName , } ) ) ; } ) ; return result ; } private scrollScale = 1 ; private isSyncingScroll = false ; private shouldSyncScroll : 'md' | 'html' = 'md' ; private handleSyncScroll ( type , e ) { if ( type !== this . shouldSyncScroll ) { return ; } if ( this . props . onScroll ) { this . props . onScroll ( e , type ) ; } this . emitter . emit ( this . emitter . EVENT_SCROLL , e , type ) ; const { syncScrollMode = [ ] } = this . config ; if ( ! syncScrollMode . includes ( type === 'md' ? 'rightFollowLeft' : 'leftFollowRight' ) ) { return ; } if ( this . hasContentChanged && this . nodeMdText . current && this . nodeMdPreviewWrapper . current ) { this . scrollScale = this . nodeMdText . current . scrollHeight / this . nodeMdPreviewWrapper . current . scrollHeight ; this . hasContentChanged = false ; } if ( ! this . isSyncingScroll ) { this . isSyncingScroll = true ; requestAnimationFrame ( ( ) => { if ( this . nodeMdText . current && this . nodeMdPreviewWrapper . current ) { if ( type === 'md' ) { this . nodeMdPreviewWrapper . current . scrollTop = this . nodeMdText . current . scrollTop / this . scrollScale ; } else { this . nodeMdText . current . scrollTop = this . nodeMdPreviewWrapper . current . scrollTop * this . scrollScale ; } } this . isSyncingScroll = false ; } ) ; } } private renderHTML ( markdownText ) { if ( ! this . props . renderHTML ) { console . error ( 'renderHTML props is required!' ) ; return Promise . resolve ( ) ; } const res = this . props . renderHTML ( markdownText ) ; if ( isPromise ( res ) ) { return res . then ( ( r ) => this . setHtml ( r ) ) ; } if ( typeof res === 'function' ) { return this . setHtml ( res ( ) ) ; } return this . setHtml ( res ) ; } private setHtml ( html ) { return new Promise ( ( resolve ) => { this . setState ( { html } , resolve ) ; } ) ; } private handleToggleMenu ( ) { this . setView ( { menu : ! this . state . view . menu , } ) ; } private handleFocus ( e ) { const { onFocus } = this . props ; if ( onFocus ) { onFocus ( e ) ; } this . emitter . emit ( this . emitter . EVENT_FOCUS , e ) ; } private handleBlur ( e ) { const { onBlur } = this . props ; if ( onBlur ) { onBlur ( e ) ; } this . emitter . emit ( this . emitter . EVENT_BLUR , e ) ; } param { React . ChangeEvent } e private handleChange ( e ) { e . persist ( ) ; const { value } = e . target ; this . setText ( value , e ) ; } /**
     * Listen paste event to support paste images
     */ private handlePaste ( e ) { if ( ! this . config . allowPasteImage || ! this . config . onImageUpload ) { return ; } const event = e . nativeEvent as ClipboardEvent ; const items = ( event . clipboardData || window . clipboardData ) . items as DataTransferItemList ; if ( items ) { e . preventDefault ( ) ; this . uploadWithDataTransfer ( items ) ; } } private handleDrop ( e ) { if ( ! this . config . onImageUpload ) { return ; } const event = e . nativeEvent as DragEvent ; if ( ! event . dataTransfer ) { return ; } const { items } = event . dataTransfer ; if ( items ) { e . preventDefault ( ) ; this . uploadWithDataTransfer ( items ) ; } } private handleEditorKeyDown ( e ) { const { keyCode , key , currentTarget } = e ; if ( ( keyCode === 13 || key === 'Enter' ) && this . composing === false ) { const text = e . currentTarget . value ; const curPos = currentTarget . selectionStart ; const lineInfo = getLineAndCol ( text , curPos ) ; const emptyCurrentLine = ( ) => { const newValue = currentTarget . value . substr ( 0 , curPos - lineInfo . curLine . length ) + currentTarget . value . substr ( curPos ) ; this . setText ( newValue , undefined , { start : curPos - lineInfo . curLine . length , end : curPos - lineInfo . curLine . length , } ) ; e . preventDefault ( ) ; } ; const addSymbol = ( symbol ) => { this . insertText ( `\n${ symbol }` , false , { start : symbol . length + 1 , end : symbol . length + 1 , } ) ; e . preventDefault ( ) ; } ; const isSymbol = lineInfo . curLine . match ( /^(\s?)([-*]) / ) ; if ( isSymbol ) { if ( /^(\s?)([-*]) $/ . test ( lineInfo . curLine ) ) { emptyCurrentLine ( ) ; return ; } addSymbol ( isSymbol [ 0 ] ) ; return ; } const isOrderList = lineInfo . curLine . match ( /^(\s?)(\d+)\. / ) ; if ( isOrderList ) { if ( /^(\s?)(\d+)\. $/ . test ( lineInfo . curLine ) ) { emptyCurrentLine ( ) ; return ; } const toInsert = `${ isOrderList [ 1 ] }${ parseInt ( isOrderList [ 2 ] , 10 ) + 1 }. ` ; addSymbol ( toInsert ) ; } } } private handleLocaleUpdate ( ) { this . forceUpdate ( ) ; } /**
     * Get elements
     */ getMdElement ( ) { return this . nodeMdText . current ; } getHtmlElement ( ) { return this . nodeMdPreviewWrapper . current ; } /**
     * Clear selected
     */ clearSelection ( ) { if ( this . nodeMdText . current ) { this . nodeMdText . current . setSelectionRange ( 0 , 0 , 'none' ) ; } } return { Selection } getSelection ( ) { const source = this . nodeMdText . current ; if ( ! source ) { return { ... initialSelection } ; } const start = source . selectionStart ; const end = source . selectionEnd ; const text = ( source . value || '' ) . slice ( start , end ) ; return { start , end , text , } ; } param { Selection } to setSelection ( to ) { if ( this . nodeMdText . current ) { this . nodeMdText . current . setSelectionRange ( to . start , to . end , 'forward' ) ; this . nodeMdText . current . focus ( ) ; } } param type param option insertMarkdown ( type , option = { } ) { const curSelection = this . getSelection ( ) ; let decorateOption = option ? { ... option } : { } ; if ( type === 'image' ) { decorateOption = { ... decorateOption , target : option . target || curSelection . text || '' , imageUrl : option . imageUrl || this . config . imageUrl , } ; } if ( type === 'link' ) { decorateOption = { ... decorateOption , linkUrl : this . config . linkUrl , } ; } if ( type === 'tab' && curSelection . start !== curSelection . end ) { const curLineStart = this . getMdValue ( ) . slice ( 0 , curSelection . start ) . lastIndexOf ( '\n' ) + 1 ; this . setSelection ( { start : curLineStart , end : curSelection . end , } ) ; } const decorate = getDecorated ( curSelection . text , type , decorateOption ) ; let { text } = decorate ; const { selection } = decorate ; if ( decorate . newBlock ) { const startLineInfo = getLineAndCol ( this . getMdValue ( ) , curSelection . start ) ; const { col , curLine } = startLineInfo ; if ( col > 0 && curLine . length > 0 ) { text = `\n${ text }` ; if ( selection ) { selection . start ++ ; selection . end ++ ; } } let { afterText } = startLineInfo ; if ( curSelection . start !== curSelection . end ) { afterText = getLineAndCol ( this . getMdValue ( ) , curSelection . end ) . afterText ; } if ( afterText . trim ( ) !== '' && afterText . substr ( 0 , 2 ) !== '\n\n' ) { if ( afterText . substr ( 0 , 1 ) !== '\n' ) { text += '\n' ; } text += '\n' ; } } this . insertText ( text , true , selection ) ; } param placeholder param wait insertPlaceholder ( placeholder , wait ) { this . insertText ( placeholder , true ) ; wait . then ( ( str ) => { const text = this . getMdValue ( ) . replace ( placeholder , str ) ; this . setText ( text ) ; } ) ; } param { string } value param { boolean } replaceSelected param { Selection } newSelection insertText ( value = '' , replaceSelected = false , newSelection ? ) { const { text } = this . state ; const selection = this . getSelection ( ) ; const beforeContent = text . slice ( 0 , selection . start ) ; const afterContent = text . slice ( replaceSelected ? selection . end : selection . start , text . length ) ; this . setText ( beforeContent + value + afterContent , undefined , newSelection ? { start : newSelection . start + beforeContent . length , end : newSelection . end + beforeContent . length , } : { start : selection . start , end : selection . start , } ) ; } param { string } value param { any } event setText ( value = '' , event ? , newSelection ? ) { const { onChangeTrigger = 'both' } = this . config ; const text = value . replace ( /↵/g , '\n' ) ; if ( this . state . text === value ) { return ; } this . setState ( { text } ) ; if ( this . props . onChange && ( onChangeTrigger === 'both' || onChangeTrigger === 'beforeRender' ) ) { this . props . onChange ( { text , html : this . getHtmlValue ( ) } , event ) ; } this . emitter . emit ( this . emitter . EVENT_CHANGE , value , event , typeof event === 'undefined' ) ; if ( newSelection ) { setTimeout ( ( ) => this . setSelection ( newSelection ) ) ; } if ( ! this . hasContentChanged ) { this . hasContentChanged = true ; } const rendering = this . renderHTML ( text ) ; if ( onChangeTrigger === 'both' || onChangeTrigger === 'afterRender' ) { rendering . then ( ( ) => { if ( this . props . onChange ) { this . props . onChange ( { text : this . state . text , html : this . getHtmlValue ( ) , } , event ) ; } } ) ; } } return { string } getMdValue ( ) { return this . state . text ; } returns { string } getHtmlValue ( ) { if ( typeof this . state . html === 'string' ) { return this . state . html ; } if ( this . nodeMdPreview . current ) { return this . nodeMdPreview . current . getHtml ( ) ; } return '' ; } /**
     * Listen keyboard events
     */ private keyboardListeners : KeyboardEventListener [ ] = [ ] ; param { KeyboardEventListener } data onKeyboard ( data ) { if ( Array . isArray ( data ) ) { data . forEach ( ( it ) => this . onKeyboard ( it ) ) ; return ; } if ( ! this . keyboardListeners . includes ( data ) ) { this . keyboardListeners . push ( data ) ; } } param { KeyboardEventListener } data offKeyboard ( data ) { if ( Array . isArray ( data ) ) { data . forEach ( ( it ) => this . offKeyboard ( it ) ) ; return ; } const index = this . keyboardListeners . indexOf ( data ) ; if ( index >= 0 ) { this . keyboardListeners . splice ( index , 1 ) ; } } private handleKeyDown ( e ) { for ( const it of this . keyboardListeners ) { if ( isKeyMatch ( e , it ) ) { e . preventDefault ( ) ; it . callback ( e ) ; return ; } } this . emitter . emit ( this . emitter . EVENT_KEY_DOWN , e ) ; } private getEventType ( event ) { switch ( event ) { case 'change' : return this . emitter . EVENT_CHANGE ; case 'fullscreen' : return this . emitter . EVENT_FULL_SCREEN ; case 'viewchange' : return this . emitter . EVENT_VIEW_CHANGE ; case 'keydown' : return this . emitter . EVENT_KEY_DOWN ; case 'blur' : return this . emitter . EVENT_BLUR ; case 'focus' : return this . emitter . EVENT_FOCUS ; case 'scroll' : return this . emitter . EVENT_SCROLL ; } } param { EditorEvent } event param { any } cb on ( event , cb ) { const eventType = this . getEventType ( event ) ; if ( eventType ) { this . emitter . on ( eventType , cb ) ; } } param { EditorEvent } event param { any } cb off ( event , cb ) { const eventType = this . getEventType ( event ) ; if ( eventType ) { this . emitter . off ( eventType , cb ) ; } } param { object } to setView ( to ) { const newView = { ... this . state . view , ... to } ; this . setState ( { view : newView , } , ( ) => { this . emitter . emit ( this . emitter . EVENT_VIEW_CHANGE , newView ) ; } ) ; } return { object } getView ( ) { return { ... this . state . view } ; } param { boolean } enable fullScreen ( enable ) { if ( this . state . fullScreen !== enable ) { this . setState ( { fullScreen : enable , } , ( ) => { this . emitter . emit ( this . emitter . EVENT_FULL_SCREEN , enable ) ; } ) ; } } return { boolean } isFullScreen ( ) { return this . state . fullScreen ; } private uploadWithDataTransfer ( items ) { const { onImageUpload } = this . config ; if ( ! onImageUpload ) { return ; } const queue = [ ] ; Array . prototype . forEach . call ( items , ( it ) => { if ( it . kind === 'file' && it . type . includes ( 'image' ) ) { const file = it . getAsFile ( ) ; if ( file ) { const placeholder = getUploadPlaceholder ( file , onImageUpload ) ; queue . push ( Promise . resolve ( placeholder . placeholder ) ) ; placeholder . uploaded . then ( ( str ) => { const text = this . getMdValue ( ) . replace ( placeholder . placeholder , str ) ; const offset = str . length - placeholder . placeholder . length ; const selection = this . getSelection ( ) ; this . setText ( text , undefined , { start : selection . start + offset , end : selection . start + offset , } ) ; } ) ; } } else if ( it . kind === 'string' && it . type === 'text/plain' ) { queue . push ( new Promise ( ( resolve ) => it . getAsString ( resolve ) ) ) ; } } ) ; Promise . all ( queue ) . then ( ( res ) => { const text = res . join ( '' ) ; const selection = this . getSelection ( ) ; this . insertText ( text , true , { start : selection . start === selection . end ? text . length : 0 , end : text . length , } ) ; } ) ; } render ( ) { const { view , fullScreen , text , html } = this . state ; const { id , className = "" , style , name = "textarea" , autoFocus , placeholder , readOnly } = this . props ; const showHideMenu = this . config . canView && this . config . canView . hideMenu && ! this . config . canView . menu ; const getPluginAt = ( at ) => this . state . plugins [ at ] || [ ] ; const isShowMenu = ! ! view . menu ; const editorId = id ? `${ id }_md` : undefined ; const previewerId = id ? `${ id }_html` : undefined ; return ( < div id = { id } className = { `rc-md-editor ${ fullScreen ? 'full' : '' } ${ className }` } style = { style } onKeyDown = { this . handleKeyDown } onDrop = { this . handleDrop } > < NavigationBar visible = { isShowMenu } left = { getPluginAt ( 'left' ) } right = { getPluginAt ( 'right' ) } / > < div className = "editor-container" > { showHideMenu && ( < ToolBar > < span className = "button button-type-menu" title = { isShowMenu ? 'hidden menu' : 'show menu' } onClick = { this . handleToggleMenu } > < Icon type = { `expand-${ isShowMenu ? 'less' : 'more' }` } /> < /span> < /ToolBar>)} < section className = { `section sec-md ${ view . md ? 'visible' : 'in-visible' }` } > < textarea id = { editorId } ref = { this . nodeMdText } name = { name } autoFocus = { autoFocus } placeholder = { placeholder } readOnly = { readOnly } value = { text } className = { `section-container input ${ this . config . markdownClass || '' }` } wrap = "hard" onChange = { this . handleChange } onScroll = { this . handleInputScroll } onMouseOver = { ( ) => ( this . shouldSyncScroll = 'md' ) } onKeyDown = { this . handleEditorKeyDown } onCompositionStart = { ( ) => this . composing = true } onCompositionEnd = { ( ) => this . composing = false } onPaste = { this . handlePaste } onFocus = { this . handleFocus } onBlur = { this . handleBlur } / > < /section> < section className = { `section sec-html ${ view . html ? 'visible' : 'in-visible' }` } > < div id = { previewerId } className = "section-container html-wrap" ref = { this . nodeMdPreviewWrapper } onMouseOver = { ( ) => ( this . shouldSyncScroll = 'html' ) } onScroll = { this . handlePreviewScroll } > < HtmlRender html = { html } className = { this . config . htmlClass } ref = { this . nodeMdPreview } / > < /div> < /section> < /div> < /div>); } } export default Editor ;