2020-03-10 01:24:57 +02:00
import * as React from 'react' ;
import { useState , useEffect , useCallback , useRef , forwardRef , useImperativeHandle } from 'react' ;
2020-05-04 19:31:55 +02:00
import { ScrollOptions , ScrollOptionTypes , EditorCommand , NoteBodyEditorProps } from '../../utils/types' ;
2020-05-11 20:26:04 +02:00
import { resourcesStatus , commandAttachFileToBody , handlePasteEvent } from '../../utils/resourceHandling' ;
2020-05-04 19:31:55 +02:00
import useScroll from './utils/useScroll' ;
2020-05-09 20:18:41 +02:00
import { menuItems , ContextMenuOptions , ContextMenuItemType } from '../../utils/contextMenu' ;
2020-07-03 23:32:39 +02:00
import CommandService from '../../../../lib/services/CommandService' ;
2020-03-10 01:24:57 +02:00
const { MarkupToHtml } = require ( 'lib/joplin-renderer' ) ;
const taboverride = require ( 'taboverride' ) ;
const { reg } = require ( 'lib/registry.js' ) ;
2020-05-11 19:59:23 +02:00
const { _ , closestSupportedLocale } = require ( 'lib/locale' ) ;
2020-04-10 19:59:51 +02:00
const BaseItem = require ( 'lib/models/BaseItem' ) ;
2020-05-09 20:18:41 +02:00
const Resource = require ( 'lib/models/Resource' ) ;
2020-06-10 23:08:59 +02:00
const { themeStyle , buildStyle } = require ( 'lib/theme' ) ;
2020-05-05 19:52:06 +02:00
const { clipboard } = require ( 'electron' ) ;
2020-05-11 19:59:23 +02:00
const supportedLocales = require ( './supportedLocales' ) ;
2020-03-10 01:24:57 +02:00
2020-04-10 19:59:51 +02:00
function markupRenderOptions ( override :any = null ) {
return {
plugins : {
checkbox : {
renderingType : 2 ,
} ,
link_open : {
linkRenderingType : 2 ,
} ,
} ,
2020-05-02 17:41:07 +02:00
replaceResourceInternalToExternalLinks : true ,
2020-04-10 19:59:51 +02:00
. . . override ,
} ;
}
2020-03-10 01:24:57 +02:00
function findBlockSource ( node :any ) {
const sources = node . getElementsByClassName ( 'joplin-source' ) ;
if ( ! sources . length ) throw new Error ( 'No source for node' ) ;
const source = sources [ 0 ] ;
return {
openCharacters : source.getAttribute ( 'data-joplin-source-open' ) ,
closeCharacters : source.getAttribute ( 'data-joplin-source-close' ) ,
content : source.textContent ,
node : source ,
2020-04-09 18:47:12 +02:00
language : source.getAttribute ( 'data-joplin-language' ) || '' ,
} ;
}
function newBlockSource ( language :string = '' , content :string = '' ) : any {
const fence = language === 'katex' ? '$$' : '```' ;
2020-04-13 00:54:42 +02:00
const fenceLanguage = language === 'katex' ? '' : language ;
2020-04-09 18:47:12 +02:00
return {
2020-04-13 00:54:42 +02:00
openCharacters : ` \ n ${ fence } ${ fenceLanguage } \ n ` ,
2020-04-09 18:47:12 +02:00
closeCharacters : ` \ n ${ fence } \ n ` ,
content : content ,
node : null ,
language : language ,
2020-03-10 01:24:57 +02:00
} ;
}
function findEditableContainer ( node :any ) : any {
while ( node ) {
if ( node . classList && node . classList . contains ( 'joplin-editable' ) ) return node ;
node = node . parentNode ;
}
return null ;
}
function editableInnerHtml ( html :string ) : string {
const temp = document . createElement ( 'div' ) ;
temp . innerHTML = html ;
const editable = temp . getElementsByClassName ( 'joplin-editable' ) ;
if ( ! editable . length ) throw new Error ( ` Invalid joplin-editable: ${ html } ` ) ;
return editable [ 0 ] . innerHTML ;
}
function dialogTextArea_keyDown ( event :any ) {
if ( event . key === 'Tab' ) {
window . requestAnimationFrame ( ( ) = > event . target . focus ( ) ) ;
}
}
// Allows pressing tab in a textarea to input an actual tab (instead of changing focus)
// taboverride will take care of actually inserting the tab character, while the keydown
// event listener will override the default behaviour, which is to focus the next field.
function enableTextAreaTab ( enable :boolean ) {
const textAreas = document . getElementsByClassName ( 'tox-textarea' ) ;
for ( const textArea of textAreas ) {
taboverride . set ( textArea , enable ) ;
if ( enable ) {
textArea . addEventListener ( 'keydown' , dialogTextArea_keyDown ) ;
} else {
textArea . removeEventListener ( 'keydown' , dialogTextArea_keyDown ) ;
}
}
}
interface TinyMceCommand {
name : string ,
value? : any ,
ui? : boolean
}
interface JoplinCommandToTinyMceCommands {
[ key :string ] : TinyMceCommand ,
}
const joplinCommandToTinyMceCommands :JoplinCommandToTinyMceCommands = {
'textBold' : { name : 'mceToggleFormat' , value : 'bold' } ,
'textItalic' : { name : 'mceToggleFormat' , value : 'italic' } ,
'textLink' : { name : 'mceLink' } ,
'search' : { name : 'SearchReplace' } ,
} ;
2020-05-02 17:41:07 +02:00
function styles_ ( props :NoteBodyEditorProps ) {
2020-04-02 19:16:11 +02:00
return buildStyle ( 'TinyMCE' , props . theme , ( /* theme:any */ ) = > {
return {
disabledOverlay : {
zIndex : 10 ,
position : 'absolute' ,
backgroundColor : 'white' ,
opacity : 0.7 ,
height : '100%' ,
display : 'flex' ,
flexDirection : 'column' ,
alignItems : 'center' ,
padding : 20 ,
paddingTop : 50 ,
textAlign : 'center' ,
2020-05-10 17:28:22 +02:00
width : '100%' ,
2020-04-02 19:16:11 +02:00
} ,
rootStyle : {
position : 'relative' ,
. . . props . style ,
} ,
} ;
} ) ;
}
2020-05-21 01:57:59 +02:00
let loadedCssFiles_ :string [ ] = [ ] ;
let loadedJsFiles_ :string [ ] = [ ] ;
2020-03-10 01:24:57 +02:00
let dispatchDidUpdateIID_ :any = null ;
let changeId_ :number = 1 ;
2020-05-02 17:41:07 +02:00
const TinyMCE = ( props :NoteBodyEditorProps , ref :any ) = > {
2020-03-10 01:24:57 +02:00
const [ editor , setEditor ] = useState ( null ) ;
const [ scriptLoaded , setScriptLoaded ] = useState ( false ) ;
2020-04-03 20:12:14 +02:00
const [ editorReady , setEditorReady ] = useState ( false ) ;
2020-05-10 17:28:22 +02:00
const [ draggingStarted , setDraggingStarted ] = useState ( false ) ;
2020-03-10 01:24:57 +02:00
2020-05-04 19:31:55 +02:00
const props_onMessage = useRef ( null ) ;
props_onMessage . current = props . onMessage ;
2020-05-10 17:28:22 +02:00
const props_onDrop = useRef ( null ) ;
props_onDrop . current = props . onDrop ;
2020-05-09 20:18:41 +02:00
const contextMenuActionOptions = useRef < ContextMenuOptions > ( null ) ;
2020-03-10 01:24:57 +02:00
const markupToHtml = useRef ( null ) ;
markupToHtml . current = props . markupToHtml ;
2020-05-30 14:25:05 +02:00
const lastOnChangeEventInfo = useRef < any > ( {
content : null ,
resourceInfos : null ,
2020-07-23 21:56:53 +02:00
contentKey : null ,
2020-05-30 14:25:05 +02:00
} ) ;
2020-05-03 19:44:49 +02:00
2020-03-10 01:24:57 +02:00
const rootIdRef = useRef < string > ( ` tinymce- ${ Date . now ( ) } ${ Math . round ( Math . random ( ) * 10000 ) } ` ) ;
2020-04-03 20:12:14 +02:00
const editorRef = useRef < any > ( null ) ;
editorRef . current = editor ;
2020-03-10 01:24:57 +02:00
2020-04-02 19:16:11 +02:00
const styles = styles_ ( props ) ;
const theme = themeStyle ( props . theme ) ;
2020-05-04 19:31:55 +02:00
const { scrollToPercent } = useScroll ( { editor , onScroll : props.onScroll } ) ;
2020-03-10 01:24:57 +02:00
const dispatchDidUpdate = ( editor :any ) = > {
if ( dispatchDidUpdateIID_ ) clearTimeout ( dispatchDidUpdateIID_ ) ;
dispatchDidUpdateIID_ = setTimeout ( ( ) = > {
dispatchDidUpdateIID_ = null ;
2020-04-10 19:12:41 +02:00
if ( editor && editor . getDoc ( ) ) editor . getDoc ( ) . dispatchEvent ( new Event ( 'joplin-noteDidUpdate' ) ) ;
2020-03-10 01:24:57 +02:00
} , 10 ) ;
} ;
2020-05-10 17:28:22 +02:00
const insertResourcesIntoContent = useCallback ( async ( filePaths :string [ ] = null , options :any = null ) = > {
const resourceMd = await commandAttachFileToBody ( '' , filePaths , options ) ;
2020-06-04 10:08:13 +02:00
if ( ! resourceMd ) return ;
2020-05-10 17:28:22 +02:00
const result = await props . markupToHtml ( MarkupToHtml . MARKUP_LANGUAGE_MARKDOWN , resourceMd , markupRenderOptions ( { bodyOnly : true } ) ) ;
editor . insertContent ( result . html ) ;
// editor.fire('joplinChange');
// dispatchDidUpdate(editor);
} , [ props . markupToHtml , editor ] ) ;
const insertResourcesIntoContentRef = useRef ( null ) ;
insertResourcesIntoContentRef . current = insertResourcesIntoContent ;
2020-03-10 01:24:57 +02:00
const onEditorContentClick = useCallback ( ( event :any ) = > {
2020-03-27 20:26:52 +02:00
const nodeName = event . target ? event . target . nodeName : '' ;
if ( nodeName === 'INPUT' && event . target . getAttribute ( 'type' ) === 'checkbox' ) {
2020-03-10 01:24:57 +02:00
editor . fire ( 'joplinChange' ) ;
dispatchDidUpdate ( editor ) ;
}
2020-03-27 20:26:52 +02:00
2020-04-02 20:58:25 +02:00
if ( nodeName === 'A' && ( event . ctrlKey || event . metaKey ) ) {
2020-03-27 20:26:52 +02:00
const href = event . target . getAttribute ( 'href' ) ;
2020-05-03 19:44:49 +02:00
2020-05-02 17:41:07 +02:00
if ( href . indexOf ( '#' ) === 0 ) {
2020-03-27 20:26:52 +02:00
const anchorName = href . substr ( 1 ) ;
const anchor = editor . getDoc ( ) . getElementById ( anchorName ) ;
if ( anchor ) {
anchor . scrollIntoView ( ) ;
} else {
reg . logger ( ) . warn ( 'TinyMce: could not find anchor with ID ' , anchorName ) ;
}
} else {
2020-05-03 19:44:49 +02:00
props . onMessage ( { channel : href } ) ;
2020-03-27 20:26:52 +02:00
}
}
} , [ editor , props . onMessage ] ) ;
2020-03-10 01:24:57 +02:00
useImperativeHandle ( ref , ( ) = > {
return {
2020-05-02 17:41:07 +02:00
content : async ( ) = > {
if ( ! editorRef . current ) return '' ;
return prop_htmlToMarkdownRef . current ( props . contentMarkupLanguage , editorRef . current . getContent ( ) , props . contentOriginalCss ) ;
} ,
resetScroll : ( ) = > {
2020-05-04 19:31:55 +02:00
if ( editor ) editor . getWin ( ) . scrollTo ( 0 , 0 ) ;
2020-05-02 17:41:07 +02:00
} ,
2020-05-04 19:31:55 +02:00
scrollTo : ( options :ScrollOptions ) = > {
if ( ! editor ) return ;
if ( options . type === ScrollOptionTypes . Hash ) {
const anchor = editor . getDoc ( ) . getElementById ( options . value ) ;
if ( ! anchor ) {
console . warn ( 'Cannot find hash' , options ) ;
return ;
}
anchor . scrollIntoView ( ) ;
} else if ( options . type === ScrollOptionTypes . Percent ) {
scrollToPercent ( options . value ) ;
} else {
throw new Error ( ` Unsupported scroll options: ${ options . type } ` ) ;
}
2020-05-02 17:41:07 +02:00
} ,
2020-05-03 19:44:49 +02:00
supportsCommand : ( name :string ) = > {
// TODO: should also handle commands that are not in this map (insertText, focus, etc);
return ! ! joplinCommandToTinyMceCommands [ name ] ;
} ,
2020-03-10 01:24:57 +02:00
execCommand : async ( cmd :EditorCommand ) = > {
if ( ! editor ) return false ;
reg . logger ( ) . debug ( 'TinyMce: execCommand' , cmd ) ;
let commandProcessed = true ;
if ( cmd . name === 'insertText' ) {
const result = await markupToHtml . current ( MarkupToHtml . MARKUP_LANGUAGE_MARKDOWN , cmd . value , { bodyOnly : true } ) ;
editor . insertContent ( result . html ) ;
} else if ( cmd . name === 'focus' ) {
editor . focus ( ) ;
2020-05-10 17:28:22 +02:00
} else if ( cmd . name === 'dropItems' ) {
if ( cmd . value . type === 'notes' ) {
const result = await markupToHtml . current ( MarkupToHtml . MARKUP_LANGUAGE_MARKDOWN , cmd . value . markdownTags . join ( '\n' ) , markupRenderOptions ( { bodyOnly : true } ) ) ;
editor . insertContent ( result . html ) ;
} else if ( cmd . value . type === 'files' ) {
insertResourcesIntoContentRef . current ( cmd . value . paths , { createFileURL : ! ! cmd . value . createFileURL } ) ;
} else {
reg . logger ( ) . warn ( 'AceEditor: unsupported drop item: ' , cmd ) ;
}
2020-03-10 01:24:57 +02:00
} else {
commandProcessed = false ;
}
if ( commandProcessed ) return true ;
if ( ! joplinCommandToTinyMceCommands [ cmd . name ] ) {
reg . logger ( ) . warn ( 'TinyMCE: unsupported Joplin command: ' , cmd ) ;
return false ;
}
const tinyMceCmd :TinyMceCommand = { . . . joplinCommandToTinyMceCommands [ cmd . name ] } ;
if ( ! ( 'ui' in tinyMceCmd ) ) tinyMceCmd . ui = false ;
if ( ! ( 'value' in tinyMceCmd ) ) tinyMceCmd . value = null ;
editor . execCommand ( tinyMceCmd . name , tinyMceCmd . ui , tinyMceCmd . value ) ;
return true ;
} ,
} ;
2020-05-02 17:41:07 +02:00
} , [ editor , props . contentMarkupLanguage , props . contentOriginalCss ] ) ;
2020-03-10 01:24:57 +02:00
// -----------------------------------------------------------------------------------------
// Load the TinyMCE library. The lib loads additional JS and CSS files on startup
// (for themes), and so it needs to be loaded via <script> tag. Requiring it from the
// module would not load these extra files.
// -----------------------------------------------------------------------------------------
2020-03-23 02:47:25 +02:00
const loadScript = async ( script :any ) = > {
return new Promise ( ( resolve ) = > {
let element :any = document . createElement ( 'script' ) ;
if ( script . src . indexOf ( '.css' ) >= 0 ) {
element = document . createElement ( 'link' ) ;
element . rel = 'stylesheet' ;
element . href = script . src ;
} else {
element . src = script . src ;
if ( script . attrs ) {
for ( const attr in script . attrs ) {
element [ attr ] = script . attrs [ attr ] ;
}
}
}
element . id = script . id ;
element . onload = ( ) = > {
resolve ( ) ;
} ;
document . getElementsByTagName ( 'head' ) [ 0 ] . appendChild ( element ) ;
} ) ;
} ;
2020-03-10 01:24:57 +02:00
useEffect ( ( ) = > {
2020-03-23 02:47:25 +02:00
let cancelled = false ;
async function loadScripts() {
const scriptsToLoad :any [ ] = [
{
src : 'node_modules/tinymce/tinymce.min.js' ,
id : 'tinyMceScript' ,
loaded : false ,
} ,
{
2020-05-02 17:41:07 +02:00
src : 'gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js' ,
2020-03-23 02:47:25 +02:00
id : 'tinyMceListsPluginScript' ,
loaded : false ,
} ,
] ;
for ( const s of scriptsToLoad ) {
if ( document . getElementById ( s . id ) ) {
s . loaded = true ;
continue ;
}
console . info ( 'Loading script' , s . src ) ;
await loadScript ( s ) ;
if ( cancelled ) return ;
s . loaded = true ;
}
2020-03-10 01:24:57 +02:00
setScriptLoaded ( true ) ;
}
2020-03-23 02:47:25 +02:00
loadScripts ( ) ;
2020-03-10 01:24:57 +02:00
return ( ) = > {
cancelled = true ;
} ;
} , [ ] ) ;
2020-04-03 20:12:14 +02:00
useEffect ( ( ) = > {
if ( ! editorReady ) return ( ) = > { } ;
const element = document . createElement ( 'style' ) ;
element . setAttribute ( 'id' , 'tinyMceStyle' ) ;
document . head . appendChild ( element ) ;
element . appendChild ( document . createTextNode ( `
. tox . tox - toolbar ,
. tox . tox - toolbar__overflow ,
. tox . tox - toolbar__primary ,
. tox - editor - header . tox - toolbar__primary ,
. tox . tox - toolbar - overlord ,
. tox . tox - tinymce - aux . tox - toolbar__overflow ,
2020-04-09 18:47:12 +02:00
. tox . tox - statusbar ,
. tox . tox - dialog__header ,
. tox . tox - dialog ,
. tox textarea ,
. tox input ,
. tox . tox - dialog__footer {
background - color : $ { theme . backgroundColor } ! important ;
2020-04-03 20:12:14 +02:00
}
. tox . tox - editor - header {
2020-05-17 15:01:42 +02:00
border - top : 1px solid $ { theme . dividerColor } ;
2020-04-03 20:12:14 +02:00
border - bottom : 1px solid $ { theme . dividerColor } ;
}
. tox . tox - tbtn ,
2020-04-09 18:47:12 +02:00
. tox . tox - tbtn svg ,
. tox . tox - dialog__header ,
. tox . tox - button -- icon . tox - icon svg ,
. tox . tox - button . tox - button -- icon . tox - icon svg ,
. tox textarea ,
. tox input ,
. tox . tox - label ,
. tox . tox - toolbar - label {
2020-05-17 16:34:42 +02:00
color : $ { theme . iconColor } ! important ;
fill : $ { theme . iconColor } ! important ;
2020-04-03 20:12:14 +02:00
}
. tox . tox - statusbar a ,
. tox . tox - statusbar__path - item ,
. tox . tox - statusbar__wordcount ,
. tox . tox - statusbar__path - divider {
color : $ { theme . color } ;
fill : $ { theme . color } ;
opacity : 0.7 ;
}
. tox . tox - tbtn -- enabled ,
. tox . tox - tbtn -- enabled :hover {
background - color : $ { theme . selectedColor } ;
}
2020-04-09 18:47:12 +02:00
. tox . tox - button -- naked :hover : not ( : disabled ) {
background - color : $ { theme . backgroundColor } ! important ;
}
2020-04-03 20:12:14 +02:00
. tox . tox - tbtn :hover {
background - color : $ { theme . backgroundHover } ;
color : $ { theme . colorHover } ;
fill : $ { theme . colorHover } ;
}
. tox . tox - toolbar__primary ,
. tox . tox - toolbar__overflow {
background : none ;
}
. tox - tinymce ,
. tox . tox - toolbar__group ,
2020-04-09 18:47:12 +02:00
. tox . tox - tinymce - aux . tox - toolbar__overflow ,
. tox . tox - dialog__footer {
2020-04-03 20:12:14 +02:00
border - color : $ { theme . dividerColor } ! important ;
}
2020-05-03 19:44:49 +02:00
. tox - tinymce {
border - top : none ! important ;
}
2020-04-03 20:12:14 +02:00
` ));
return ( ) = > {
document . head . removeChild ( element ) ;
} ;
2020-05-21 01:57:59 +02:00
} , [ editorReady , props . theme ] ) ;
2020-04-03 20:12:14 +02:00
2020-03-10 01:24:57 +02:00
// -----------------------------------------------------------------------------------------
// Enable or disable the editor
// -----------------------------------------------------------------------------------------
useEffect ( ( ) = > {
if ( ! editor ) return ;
editor . setMode ( props . disabled ? 'readonly' : 'design' ) ;
} , [ editor , props . disabled ] ) ;
// -----------------------------------------------------------------------------------------
// Create and setup the editor
// -----------------------------------------------------------------------------------------
useEffect ( ( ) = > {
if ( ! scriptLoaded ) return ;
2020-05-21 01:57:59 +02:00
loadedCssFiles_ = [ ] ;
loadedJsFiles_ = [ ] ;
2020-03-10 01:24:57 +02:00
2020-05-09 20:18:41 +02:00
function contextMenuItemNameWithNamespace ( name :string ) {
// For unknown reasons, TinyMCE converts all context menu names to
// lowercase when setting them in the init method, so we need to
// make them lowercase too, to make sure that the update() method
// addContextMenu is triggered.
return ( ` joplin ${ name } ` ) . toLowerCase ( ) ;
}
2020-03-10 01:24:57 +02:00
const loadEditor = async ( ) = > {
2020-05-09 20:18:41 +02:00
const contextMenuItems = menuItems ( ) ;
const contextMenuItemNames = [ ] ;
for ( const name in contextMenuItems ) contextMenuItemNames . push ( contextMenuItemNameWithNamespace ( name ) ) ;
2020-05-11 19:59:23 +02:00
const language = closestSupportedLocale ( props . locale , true , supportedLocales ) ;
2020-03-10 01:24:57 +02:00
const editors = await ( window as any ) . tinymce . init ( {
selector : ` # ${ rootIdRef . current } ` ,
width : '100%' ,
2020-04-10 20:22:17 +02:00
body_class : 'jop-tinymce' ,
2020-03-10 01:24:57 +02:00
height : '100%' ,
resize : false ,
2020-04-09 18:49:56 +02:00
icons : 'Joplin' ,
2020-05-02 17:41:07 +02:00
icons_url : 'gui/NoteEditor/NoteBody/TinyMCE/icons.js' ,
2020-04-10 20:22:17 +02:00
plugins : 'noneditable link joplinLists hr searchreplace codesample table' ,
2020-03-10 01:24:57 +02:00
noneditable_noneditable_class : 'joplin-editable' , // Can be a regex too
valid_elements : '*[*]' , // We already filter in sanitize_html
menubar : false ,
2020-04-14 00:55:24 +02:00
relative_urls : false ,
2020-03-10 01:24:57 +02:00
branding : false ,
2020-04-09 20:05:07 +02:00
target_list : false ,
2020-04-10 20:22:17 +02:00
table_resize_bars : false ,
2020-05-11 19:59:23 +02:00
language : [ 'en_US' , 'en_GB' ] . includes ( language ) ? undefined : language ,
2020-05-04 01:55:41 +02:00
toolbar : 'bold italic | link joplinInlineCode joplinCodeBlock joplinAttach | numlist bullist joplinChecklist | h1 h2 h3 hr blockquote table joplinInsertDateTime' ,
localization_function : _ ,
2020-05-09 20:18:41 +02:00
contextmenu : contextMenuItemNames.join ( ' ' ) ,
2020-03-10 01:24:57 +02:00
setup : ( editor :any ) = > {
function openEditDialog ( editable :any ) {
2020-04-09 18:47:12 +02:00
const source = editable ? findBlockSource ( editable ) : newBlockSource ( ) ;
2020-03-10 01:24:57 +02:00
editor . windowManager . open ( {
2020-05-04 01:55:41 +02:00
title : _ ( 'Edit' ) ,
2020-03-10 01:24:57 +02:00
size : 'large' ,
initialData : {
codeTextArea : source.content ,
2020-04-09 18:47:12 +02:00
languageInput : source.language ,
2020-03-10 01:24:57 +02:00
} ,
onSubmit : async ( dialogApi :any ) = > {
2020-04-09 18:47:12 +02:00
const newSource = newBlockSource ( dialogApi . getData ( ) . languageInput , dialogApi . getData ( ) . codeTextArea ) ;
const md = ` ${ newSource . openCharacters } ${ newSource . content . trim ( ) } ${ newSource . closeCharacters } ` ;
2020-03-10 01:24:57 +02:00
const result = await markupToHtml . current ( MarkupToHtml . MARKUP_LANGUAGE_MARKDOWN , md , { bodyOnly : true } ) ;
// markupToHtml will return the complete editable HTML, but we only
// want to update the inner HTML, so as not to break additional props that
// are added by TinyMCE on the main node.
2020-04-09 18:47:12 +02:00
if ( editable ) {
editable . innerHTML = editableInnerHtml ( result . html ) ;
} else {
editor . insertContent ( result . html ) ;
}
2020-03-10 01:24:57 +02:00
dialogApi . close ( ) ;
editor . fire ( 'joplinChange' ) ;
dispatchDidUpdate ( editor ) ;
} ,
onClose : ( ) = > {
enableTextAreaTab ( false ) ;
} ,
body : {
type : 'panel' ,
items : [
2020-04-09 18:47:12 +02:00
{
type : 'input' ,
name : 'languageInput' ,
label : 'Language' ,
// Katex is a special case with special opening/closing tags
// and we don't currently handle switching the language in this case.
disabled : source.language === 'katex' ,
} ,
2020-03-10 01:24:57 +02:00
{
type : 'textarea' ,
name : 'codeTextArea' ,
value : source.content ,
} ,
] ,
} ,
buttons : [
{
type : 'submit' ,
text : 'OK' ,
} ,
] ,
} ) ;
window . requestAnimationFrame ( ( ) = > {
enableTextAreaTab ( true ) ;
} ) ;
}
2020-03-23 02:47:25 +02:00
editor . ui . registry . addButton ( 'joplinAttach' , {
2020-05-04 01:55:41 +02:00
tooltip : _ ( 'Attach file' ) ,
2020-04-09 18:49:56 +02:00
icon : 'paperclip' ,
2020-03-10 01:24:57 +02:00
onAction : async function ( ) {
2020-05-10 17:28:22 +02:00
insertResourcesIntoContentRef . current ( ) ;
2020-03-10 01:24:57 +02:00
} ,
} ) ;
2020-04-09 18:47:12 +02:00
editor . ui . registry . addButton ( 'joplinCodeBlock' , {
2020-05-04 01:55:41 +02:00
tooltip : _ ( 'Code Block' ) ,
2020-04-09 18:47:12 +02:00
icon : 'code-sample' ,
onAction : async function ( ) {
openEditDialog ( null ) ;
} ,
} ) ;
editor . ui . registry . addToggleButton ( 'joplinInlineCode' , {
2020-05-04 01:55:41 +02:00
tooltip : _ ( 'Inline Code' ) ,
2020-04-09 18:47:12 +02:00
icon : 'sourcecode' ,
onAction : function ( ) {
editor . execCommand ( 'mceToggleFormat' , false , 'code' , { class : 'inline-code' } ) ;
} ,
onSetup : function ( api :any ) {
api . setActive ( editor . formatter . match ( 'code' ) ) ;
const unbind = editor . formatter . formatChanged ( 'code' , api . setActive ) . unbind ;
return function ( ) {
if ( unbind ) unbind ( ) ;
} ;
} ,
} ) ;
2020-05-04 01:55:41 +02:00
editor . ui . registry . addButton ( 'joplinInsertDateTime' , {
tooltip : _ ( 'Insert Date Time' ) ,
icon : 'insert-time' ,
onAction : function ( ) {
2020-07-03 23:32:39 +02:00
CommandService . instance ( ) . execute ( 'insertDateTime' ) ;
2020-05-04 01:55:41 +02:00
} ,
} ) ;
2020-05-09 20:18:41 +02:00
for ( const itemName in contextMenuItems ) {
const item = contextMenuItems [ itemName ] ;
const itemNameNS = contextMenuItemNameWithNamespace ( itemName ) ;
editor . ui . registry . addMenuItem ( itemNameNS , {
text : item.label ,
onAction : ( ) = > {
item . onAction ( contextMenuActionOptions . current ) ;
} ,
} ) ;
editor . ui . registry . addContextMenu ( itemNameNS , {
update : function ( element :any ) {
let itemType :ContextMenuItemType = ContextMenuItemType . None ;
let resourceId = '' ;
let textToCopy = '' ;
if ( element . nodeName === 'IMG' ) {
itemType = ContextMenuItemType . Image ;
resourceId = Resource . pathToId ( element . src ) ;
} else if ( element . nodeName === 'A' ) {
resourceId = Resource . pathToId ( element . href ) ;
itemType = resourceId ? ContextMenuItemType.Resource : ContextMenuItemType.Link ;
} else {
itemType = ContextMenuItemType . Text ;
textToCopy = editor . selection . getContent ( { format : 'text' } ) ;
}
contextMenuActionOptions . current = { itemType , resourceId , textToCopy } ;
return item . isActive ( itemType ) ? itemNameNS : '' ;
} ,
} ) ;
}
2020-03-10 01:24:57 +02:00
// TODO: remove event on unmount?
editor . on ( 'DblClick' , ( event :any ) = > {
const editable = findEditableContainer ( event . target ) ;
if ( editable ) openEditDialog ( editable ) ;
} ) ;
2020-05-10 17:28:22 +02:00
// This is triggered when an external file is dropped on the editor
editor . on ( 'drop' , ( event :any ) = > {
props_onDrop . current ( event ) ;
} ) ;
2020-03-10 01:24:57 +02:00
editor . on ( 'ObjectResized' , function ( event :any ) {
if ( event . target . nodeName === 'IMG' ) {
editor . fire ( 'joplinChange' ) ;
dispatchDidUpdate ( editor ) ;
}
} ) ;
2020-04-03 20:12:14 +02:00
editor . on ( 'init' , ( ) = > {
setEditorReady ( true ) ;
} ) ;
2020-05-04 19:31:55 +02:00
editor . on ( 'SetContent' , ( ) = > {
props_onMessage . current ( { channel : 'noteRenderComplete' } ) ;
} ) ;
2020-03-10 01:24:57 +02:00
} ,
} ) ;
setEditor ( editors [ 0 ] ) ;
} ;
loadEditor ( ) ;
} , [ scriptLoaded ] ) ;
// -----------------------------------------------------------------------------------------
// Set the initial content and load the plugin CSS and JS files
// -----------------------------------------------------------------------------------------
2020-03-23 02:47:25 +02:00
const loadDocumentAssets = ( editor :any , pluginAssets :any [ ] ) = > {
2020-05-21 01:57:59 +02:00
// Note: The way files are cached is not correct because it assumes there's only one version
// of each file. However, when the theme change, a new CSS file, specific to the theme, is
// created. That file should not be loaded on top of the previous one, but as a replacement.
// Otherwise it would do this:
// - Try to load CSS for theme 1 => OK
// - Try to load CSS for theme 2 => OK
// - Try to load CSS for theme 1 => Skip because the file is in cache. As a result, theme 2
// incorrectly stay.
// The fix would be to make allAssets() return a name and a version for each asset. Then the loading
// code would check this and either append the CSS or replace.
2020-05-21 18:12:18 +02:00
let docHead_ :any = null ;
2020-05-21 01:57:59 +02:00
function docHead() {
if ( docHead_ ) return docHead_ ;
docHead_ = editor . getDoc ( ) . getElementsByTagName ( 'head' ) [ 0 ] ;
return docHead_ ;
}
2020-04-09 18:47:12 +02:00
const cssFiles = [
2020-05-17 16:34:42 +02:00
'node_modules/@fortawesome/fontawesome-free/css/all.min.css' ,
2020-04-09 18:47:12 +02:00
` gui/note-viewer/pluginAssets/highlight.js/ ${ theme . codeThemeCss } ` ,
] . concat (
2020-03-29 21:06:13 +02:00
pluginAssets
. filter ( ( a :any ) = > a . mime === 'text/css' )
. map ( ( a :any ) = > a . path )
2020-05-21 01:57:59 +02:00
) . filter ( ( path :string ) = > ! loadedCssFiles_ . includes ( path ) ) ;
2020-03-29 21:06:13 +02:00
2020-05-05 20:03:13 +02:00
const jsFiles = [ ] . concat (
2020-03-29 21:06:13 +02:00
pluginAssets
. filter ( ( a :any ) = > a . mime === 'application/javascript' )
. map ( ( a :any ) = > a . path )
2020-05-21 01:57:59 +02:00
) . filter ( ( path :string ) = > ! loadedJsFiles_ . includes ( path ) ) ;
2020-03-10 01:24:57 +02:00
2020-05-21 01:57:59 +02:00
for ( const cssFile of cssFiles ) loadedCssFiles_ . push ( cssFile ) ;
for ( const jsFile of jsFiles ) loadedJsFiles_ . push ( jsFile ) ;
2020-03-10 01:24:57 +02:00
2020-07-23 21:56:53 +02:00
// console.info('loadDocumentAssets: files to load', cssFiles, jsFiles);
2020-03-23 02:47:25 +02:00
2020-05-21 01:57:59 +02:00
if ( cssFiles . length ) {
for ( const cssFile of cssFiles ) {
const script = editor . dom . create ( 'link' , {
rel : 'stylesheet' ,
type : 'text/css' ,
href : cssFile ,
class : 'jop-tinymce-css' ,
} ) ;
docHead ( ) . appendChild ( script ) ;
}
}
2020-03-23 02:47:25 +02:00
if ( jsFiles . length ) {
const editorElementId = editor . dom . uniqueId ( ) ;
2020-03-10 01:24:57 +02:00
2020-03-23 02:47:25 +02:00
for ( const jsFile of jsFiles ) {
const script = editor . dom . create ( 'script' , {
id : editorElementId ,
type : 'text/javascript' ,
src : jsFile ,
} ) ;
2020-03-10 01:24:57 +02:00
2020-05-21 01:57:59 +02:00
docHead ( ) . appendChild ( script ) ;
2020-03-23 02:47:25 +02:00
}
}
} ;
2020-03-10 01:24:57 +02:00
2020-03-23 02:47:25 +02:00
useEffect ( ( ) = > {
if ( ! editor ) return ( ) = > { } ;
2020-03-10 01:24:57 +02:00
2020-05-02 17:41:07 +02:00
if ( resourcesStatus ( props . resourceInfos ) !== 'ready' ) {
2020-04-02 19:16:11 +02:00
editor . setContent ( '' ) ;
return ( ) = > { } ;
}
2020-03-23 02:47:25 +02:00
let cancelled = false ;
2020-03-10 01:24:57 +02:00
2020-03-23 02:47:25 +02:00
const loadContent = async ( ) = > {
2020-05-30 14:25:05 +02:00
if ( lastOnChangeEventInfo . current . content !== props . content || lastOnChangeEventInfo . current . resourceInfos !== props . resourceInfos ) {
2020-05-10 17:28:22 +02:00
const result = await props . markupToHtml ( props . contentMarkupLanguage , props . content , markupRenderOptions ( { resourceInfos : props.resourceInfos } ) ) ;
if ( cancelled ) return ;
2020-05-30 14:25:05 +02:00
2020-07-23 21:56:53 +02:00
editor . setContent ( result . html ) ;
if ( lastOnChangeEventInfo . current . contentKey !== props . contentKey ) {
// Need to clear UndoManager to avoid this problem:
// - Load note 1
// - Make a change
// - Load note 2
// - Undo => content is that of note 1
//
// The doc is not very clear what's the different between
// clear() and reset() but it seems reset() works best, in
// particular for the onPaste bug.
//
// It seems the undo manager must be reset after having
// set the initial content (not before). Otherwise undoing multiple
// times would result in an empty note.
// https://github.com/laurent22/joplin/issues/3534
editor . undoManager . reset ( ) ;
}
2020-05-30 14:25:05 +02:00
lastOnChangeEventInfo . current = {
content : props.content ,
resourceInfos : props.resourceInfos ,
2020-07-23 21:56:53 +02:00
contentKey : props.contentKey ,
2020-05-30 14:25:05 +02:00
} ;
2020-05-10 17:28:22 +02:00
}
2020-03-10 01:24:57 +02:00
2020-05-02 17:41:07 +02:00
await loadDocumentAssets ( editor , await props . allAssets ( props . contentMarkupLanguage ) ) ;
2020-03-10 01:24:57 +02:00
dispatchDidUpdate ( editor ) ;
} ;
loadContent ( ) ;
return ( ) = > {
cancelled = true ;
2020-05-03 19:44:49 +02:00
} ;
2020-07-23 21:56:53 +02:00
} , [ editor , props . markupToHtml , props . allAssets , props . content , props . resourceInfos , props . contentKey ] ) ;
2020-06-21 15:37:04 +02:00
2020-05-03 19:44:49 +02:00
useEffect ( ( ) = > {
if ( ! editor ) return ( ) = > { } ;
editor . getDoc ( ) . addEventListener ( 'click' , onEditorContentClick ) ;
return ( ) = > {
2020-03-10 01:24:57 +02:00
editor . getDoc ( ) . removeEventListener ( 'click' , onEditorContentClick ) ;
} ;
2020-05-03 19:44:49 +02:00
} , [ editor , onEditorContentClick ] ) ;
2020-03-10 01:24:57 +02:00
2020-05-10 17:28:22 +02:00
// This is to handle dropping notes on the editor. In this case, we add an
// overlay over the editor, which makes it a valid drop target. This in
// turn makes NoteEditor get the drop event and dispatch it.
useEffect ( ( ) = > {
if ( ! editor ) return ( ) = > { } ;
function onDragStart() {
setDraggingStarted ( true ) ;
}
function onDrop() {
setDraggingStarted ( false ) ;
}
function onDragEnd() {
setDraggingStarted ( false ) ;
}
document . addEventListener ( 'dragstart' , onDragStart ) ;
document . addEventListener ( 'drop' , onDrop ) ;
document . addEventListener ( 'dragend' , onDragEnd ) ;
return ( ) = > {
document . removeEventListener ( 'dragstart' , onDragStart ) ;
document . removeEventListener ( 'drop' , onDrop ) ;
document . removeEventListener ( 'dragend' , onDragEnd ) ;
} ;
} , [ editor ] ) ;
2020-03-10 01:24:57 +02:00
// -----------------------------------------------------------------------------------------
// Handle onChange event
// -----------------------------------------------------------------------------------------
// Need to save the onChange handler to a ref to make sure
// we call the current one from setTimeout.
// https://github.com/facebook/react/issues/14010#issuecomment-433788147
const props_onChangeRef = useRef < Function > ( ) ;
props_onChangeRef . current = props . onChange ;
2020-05-02 17:41:07 +02:00
const prop_htmlToMarkdownRef = useRef < Function > ( ) ;
prop_htmlToMarkdownRef . current = props . htmlToMarkdown ;
2020-07-24 01:55:01 +02:00
const nextOnChangeEventInfo = useRef < any > ( null ) ;
2020-03-10 01:24:57 +02:00
2020-07-24 01:55:01 +02:00
async function execOnChangeEvent() {
const info = nextOnChangeEventInfo . current ;
if ( ! info ) return ;
2020-03-10 01:24:57 +02:00
2020-07-24 01:55:01 +02:00
nextOnChangeEventInfo . current = null ;
const contentMd = await prop_htmlToMarkdownRef . current ( info . contentMarkupLanguage , info . editor . getContent ( ) , info . contentOriginalCss ) ;
2020-03-10 01:24:57 +02:00
2020-07-24 01:55:01 +02:00
lastOnChangeEventInfo . current . content = contentMd ;
2020-03-10 01:24:57 +02:00
2020-07-24 01:55:01 +02:00
props_onChangeRef . current ( {
changeId : info.changeId ,
content : contentMd ,
} ) ;
2020-03-10 01:24:57 +02:00
2020-07-24 01:55:01 +02:00
dispatchDidUpdate ( info . editor ) ;
}
2020-05-02 17:41:07 +02:00
2020-07-24 01:55:01 +02:00
// When the component unmount, we dispatch the change event
// that was scheduled so that the parent component can save
// the note.
useEffect ( ( ) = > {
return ( ) = > {
execOnChangeEvent ( ) ;
} ;
} , [ ] ) ;
2020-03-10 01:24:57 +02:00
2020-07-24 01:55:01 +02:00
const onChangeHandlerTimeoutRef = useRef < any > ( null ) ;
2020-05-03 19:44:49 +02:00
2020-07-24 01:55:01 +02:00
useEffect ( ( ) = > {
if ( ! editor ) return ( ) = > { } ;
function onChangeHandler() {
// First this component notifies the parent that a change is going to happen.
// Then the actual onChange event is fired after a timeout or when this
// component gets unmounted.
const changeId = changeId_ ++ ;
props . onWillChange ( { changeId : changeId } ) ;
if ( onChangeHandlerTimeoutRef . current ) clearTimeout ( onChangeHandlerTimeoutRef . current ) ;
nextOnChangeEventInfo . current = {
changeId : changeId ,
editor : editor ,
contentMarkupLanguage : props.contentMarkupLanguage ,
contentOriginalCss : props.contentOriginalCss ,
} ;
2020-03-10 01:24:57 +02:00
2020-07-24 01:55:01 +02:00
onChangeHandlerTimeoutRef . current = setTimeout ( async ( ) = > {
onChangeHandlerTimeoutRef . current = null ;
execOnChangeEvent ( ) ;
2020-03-10 01:24:57 +02:00
} , 1000 ) ;
2020-04-10 19:59:51 +02:00
}
2020-03-10 01:24:57 +02:00
2020-04-10 19:59:51 +02:00
function onExecCommand ( event :any ) {
2020-03-10 01:24:57 +02:00
const c :string = event . command ;
if ( ! c ) return ;
// We need to dispatch onChange for these commands:
//
// InsertHorizontalRule
// InsertOrderedList
// InsertUnorderedList
// mceInsertContent
// mceToggleFormat
//
// Any maybe others, so to catch them all we only check the prefix
2020-03-23 02:47:25 +02:00
const changeCommands = [ 'mceBlockQuote' , 'ToggleJoplinChecklistItem' ] ;
2020-03-10 01:24:57 +02:00
if ( changeCommands . includes ( c ) || c . indexOf ( 'Insert' ) === 0 || c . indexOf ( 'mceToggle' ) === 0 || c . indexOf ( 'mceInsert' ) === 0 ) {
onChangeHandler ( ) ;
}
2020-04-10 19:59:51 +02:00
}
2020-03-10 01:24:57 +02:00
// Keypress means that a printable key (letter, digit, etc.) has been
// pressed so we want to always trigger onChange in this case
2020-04-10 19:59:51 +02:00
function onKeypress() {
2020-03-10 01:24:57 +02:00
onChangeHandler ( ) ;
2020-04-10 19:59:51 +02:00
}
2020-03-10 01:24:57 +02:00
// KeyUp is triggered for any keypress, including Control, Shift, etc.
// so most of the time we don't want to trigger onChange. We trigger
// it however for the keys that might change text, such as Delete or
// Backspace. It's not completely accurate though because if user presses
// Backspace at the beginning of a note or Delete at the end, we trigger
// onChange even though nothing is changed. The alternative would be to
// check the content before and after, but this is too slow, so let's
// keep it this way for now.
2020-04-10 19:59:51 +02:00
function onKeyUp ( event :any ) {
2020-03-10 01:24:57 +02:00
if ( [ 'Backspace' , 'Delete' , 'Enter' , 'Tab' ] . includes ( event . key ) ) {
onChangeHandler ( ) ;
}
2020-04-10 19:59:51 +02:00
}
async function onPaste ( event :any ) {
2020-05-11 20:26:04 +02:00
const resourceMds = await handlePasteEvent ( event ) ;
if ( resourceMds . length ) {
const result = await markupToHtml . current ( MarkupToHtml . MARKUP_LANGUAGE_MARKDOWN , resourceMds . join ( '\n' ) , markupRenderOptions ( { bodyOnly : true } ) ) ;
2020-04-10 19:59:51 +02:00
editor . insertContent ( result . html ) ;
2020-05-11 20:26:04 +02:00
} else {
const pastedText = event . clipboardData . getData ( 'text' ) ;
if ( BaseItem . isMarkdownTag ( pastedText ) ) { // Paste a link to a note
event . preventDefault ( ) ;
const result = await markupToHtml . current ( MarkupToHtml . MARKUP_LANGUAGE_MARKDOWN , pastedText , markupRenderOptions ( { bodyOnly : true } ) ) ;
editor . insertContent ( result . html ) ;
} else { // Paste regular text
// HACK: TinyMCE doesn't add an undo step when pasting, for unclear reasons
// so we manually add it here. We also can't do it immediately it seems, or
// else nothing is added to the stack, so do it on the next frame.
window . requestAnimationFrame ( ( ) = > editor . undoManager . add ( ) ) ;
onChangeHandler ( ) ;
}
2020-04-10 19:59:51 +02:00
}
}
2020-03-10 01:24:57 +02:00
2020-05-05 19:52:06 +02:00
function onKeyDown ( event :any ) {
// Handle "paste as text". Note that when pressing CtrlOrCmd+Shift+V it's going
// to trigger the "keydown" event but not the "paste" event, so it's ok to process
// it here and we don't need to do anything special in onPaste
if ( ( event . metaKey || event . ctrlKey ) && event . shiftKey && event . code === 'KeyV' ) {
const pastedText = clipboard . readText ( ) ;
if ( pastedText ) editor . insertContent ( pastedText ) ;
}
}
2020-03-10 01:24:57 +02:00
editor . on ( 'keyup' , onKeyUp ) ;
2020-05-05 19:52:06 +02:00
editor . on ( 'keydown' , onKeyDown ) ;
2020-03-10 01:24:57 +02:00
editor . on ( 'keypress' , onKeypress ) ;
2020-04-10 19:59:51 +02:00
editor . on ( 'paste' , onPaste ) ;
2020-05-08 01:16:04 +02:00
// `compositionend` means that a user has finished entering a Chinese
// (or other languages that require IME) character.
editor . on ( 'compositionend' , onChangeHandler ) ;
2020-03-10 01:24:57 +02:00
editor . on ( 'cut' , onChangeHandler ) ;
editor . on ( 'joplinChange' , onChangeHandler ) ;
2020-05-02 17:41:07 +02:00
editor . on ( 'Undo' , onChangeHandler ) ;
editor . on ( 'Redo' , onChangeHandler ) ;
2020-03-10 01:24:57 +02:00
editor . on ( 'ExecCommand' , onExecCommand ) ;
return ( ) = > {
try {
editor . off ( 'keyup' , onKeyUp ) ;
2020-05-05 19:52:06 +02:00
editor . off ( 'keydown' , onKeyDown ) ;
2020-03-10 01:24:57 +02:00
editor . off ( 'keypress' , onKeypress ) ;
2020-04-10 19:59:51 +02:00
editor . off ( 'paste' , onPaste ) ;
2020-05-08 01:16:04 +02:00
editor . off ( 'compositionend' , onChangeHandler ) ;
2020-03-10 01:24:57 +02:00
editor . off ( 'cut' , onChangeHandler ) ;
editor . off ( 'joplinChange' , onChangeHandler ) ;
2020-05-02 17:41:07 +02:00
editor . off ( 'Undo' , onChangeHandler ) ;
editor . off ( 'Redo' , onChangeHandler ) ;
2020-03-10 01:24:57 +02:00
editor . off ( 'ExecCommand' , onExecCommand ) ;
} catch ( error ) {
console . warn ( 'Error removing events' , error ) ;
}
} ;
2020-05-02 17:41:07 +02:00
} , [ props . onWillChange , props . onChange , props . contentMarkupLanguage , props . contentOriginalCss , editor ] ) ;
2020-03-10 01:24:57 +02:00
2020-04-03 20:12:14 +02:00
// -----------------------------------------------------------------------------------------
// Destroy the editor when unmounting
// Note that this effect must always be last, otherwise other effects that access the
// editor in their clean up function will get an invalid reference.
// -----------------------------------------------------------------------------------------
useEffect ( ( ) = > {
return ( ) = > {
if ( editorRef . current ) editorRef . current . remove ( ) ;
} ;
} , [ ] ) ;
2020-05-02 17:41:07 +02:00
// Currently we don't handle resource "auto" and "manual" mode with TinyMCE
// as it is quite complex and probably rarely used.
2020-04-02 19:16:11 +02:00
function renderDisabledOverlay() {
2020-05-02 17:41:07 +02:00
const status = resourcesStatus ( props . resourceInfos ) ;
2020-05-10 17:28:22 +02:00
if ( status === 'ready' && ! draggingStarted ) return null ;
2020-04-02 19:16:11 +02:00
2020-05-10 17:28:22 +02:00
const message = draggingStarted ? _ ( 'Drop notes or files here' ) : _ ( 'Please wait for all attachments to be downloaded and decrypted. You may also switch to %s to edit the note.' , _ ( 'Code View' ) ) ;
const statusComp = draggingStarted ? null : < p style = { theme . textStyleMinor } > { ` Status: ${ status } ` } < / p > ;
2020-04-02 19:16:11 +02:00
return (
< div style = { styles . disabledOverlay } >
< p style = { theme . textStyle } > { message } < / p >
2020-05-10 17:28:22 +02:00
{ statusComp }
2020-04-02 19:16:11 +02:00
< / div >
) ;
}
return (
< div style = { styles . rootStyle } >
{ renderDisabledOverlay ( ) }
< div style = { { width : '100%' , height : '100%' } } id = { rootIdRef . current } / >
< / div >
) ;
2020-03-10 01:24:57 +02:00
} ;
export default forwardRef ( TinyMCE ) ;