2020-03-10 01:24:57 +02:00
import * as React from 'react' ;
import { useState , useEffect , useCallback , useRef , forwardRef , useImperativeHandle } from 'react' ;
2021-05-20 19:13:35 +02:00
import { ScrollOptions , ScrollOptionTypes , EditorCommand , NoteBodyEditorProps , ResourceInfos } from '../../utils/types' ;
import { resourcesStatus , commandAttachFileToBody , handlePasteEvent , processPastedHtml , attachedResources } from '../../utils/resourceHandling' ;
2020-05-04 19:31:55 +02:00
import useScroll from './utils/useScroll' ;
2020-09-15 15:01:07 +02:00
import styles_ from './styles' ;
2020-11-07 17:59:37 +02:00
import CommandService from '@joplin/lib/services/CommandService' ;
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils' ;
2020-09-15 15:01:07 +02:00
import ToggleEditorsButton , { Value as ToggleEditorsButtonValue } from '../../../ToggleEditorsButton/ToggleEditorsButton' ;
import ToolbarButton from '../../../../gui/ToolbarButton/ToolbarButton' ;
2020-10-09 19:35:46 +02:00
import usePluginServiceRegistration from '../../utils/usePluginServiceRegistration' ;
2020-11-07 17:59:37 +02:00
import { utils as pluginUtils } from '@joplin/lib/services/plugins/reducer' ;
import { _ , closestSupportedLocale } from '@joplin/lib/locale' ;
2020-11-14 02:02:17 +02:00
import useContextMenu from './utils/useContextMenu' ;
2021-04-08 11:30:12 +02:00
import { copyHtmlToClipboard } from '../../utils/clipboardUtils' ;
2020-11-14 02:02:17 +02:00
import shim from '@joplin/lib/shim' ;
2021-08-14 13:19:53 +02:00
import { MarkupToHtml } from '@joplin/renderer' ;
2021-01-29 20:45:11 +02:00
import { reg } from '@joplin/lib/registry' ;
2021-01-22 19:41:11 +02:00
import BaseItem from '@joplin/lib/models/BaseItem' ;
2021-03-17 11:48:01 +02:00
import setupToolbarButtons from './utils/setupToolbarButtons' ;
2021-05-20 18:08:59 +02:00
import { plainTextToHtml } from '@joplin/lib/htmlUtils' ;
2021-07-26 15:50:31 +02:00
import openEditDialog from './utils/openEditDialog' ;
2021-08-14 13:19:53 +02:00
import { MarkupToHtmlOptions } from '../../utils/useMarkupToHtml' ;
import { themeStyle } from '@joplin/lib/theme' ;
2021-11-15 19:19:51 +02:00
import { loadScript } from '../../../utils/loadScript' ;
2021-12-23 13:04:09 +02:00
import bridge from '../../../../services/bridge' ;
2021-01-01 14:38:17 +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
2021-08-14 13:19:53 +02:00
function markupRenderOptions ( override : MarkupToHtmlOptions = null ) : MarkupToHtmlOptions {
2020-04-10 19:59:51 +02:00
return {
plugins : {
checkbox : {
2020-10-21 01:23:55 +02:00
checkboxRenderingType : 2 ,
2020-04-10 19:59:51 +02:00
} ,
link_open : {
linkRenderingType : 2 ,
} ,
} ,
2020-05-02 17:41:07 +02:00
replaceResourceInternalToExternalLinks : true ,
2020-04-10 19:59:51 +02:00
. . . override ,
} ;
}
2021-05-17 20:30:48 +02:00
// In TinyMCE 5.2, when setting the body to '<div id="rendered-md"></div>',
// it would end up as '<div id="rendered-md"><br/></div>' once rendered
// (an additional <br/> was inserted).
//
// This behaviour was "fixed" later on, possibly in 5.6, which has this change:
//
// - Fixed getContent with text format returning a new line when the editor is empty #TINY-6281
//
// The problem is that the list plugin was, unknown to me, relying on this <br/>
// being present. Without it, trying to add a bullet point or checkbox on an
// empty document, does nothing. The exact reason for this is unclear
// so as a workaround we manually add this <br> for empty documents,
// which fixes the issue.
//
// Perhaps upgrading the list plugin (which is a fork of TinyMCE own list plugin)
// would help?
function awfulBrHack ( html : string ) : string {
return html === '<div id="rendered-md"></div>' ? '<div id="rendered-md"><br/></div>' : html ;
}
2020-11-12 21:13:28 +02:00
function findEditableContainer ( node : any ) : any {
2020-03-10 01:24:57 +02:00
while ( node ) {
if ( node . classList && node . classList . contains ( 'joplin-editable' ) ) return node ;
node = node . parentNode ;
}
return null ;
}
2020-10-09 19:35:46 +02:00
let markupToHtml_ = new MarkupToHtml ( ) ;
2020-11-12 21:13:28 +02:00
function stripMarkup ( markupLanguage : number , markup : string , options : any = null ) {
2020-10-09 19:35:46 +02:00
if ( ! markupToHtml_ ) markupToHtml_ = new MarkupToHtml ( ) ;
return markupToHtml_ . stripMarkup ( markupLanguage , markup , options ) ;
}
2020-03-10 01:24:57 +02:00
interface TinyMceCommand {
2020-11-12 21:29:22 +02:00
name : string ;
value? : any ;
ui? : boolean ;
2020-03-10 01:24:57 +02:00
}
interface JoplinCommandToTinyMceCommands {
2020-11-12 21:29:22 +02:00
[ key : string ] : TinyMceCommand ;
2020-03-10 01:24:57 +02:00
}
2020-11-12 21:13:28 +02:00
const joplinCommandToTinyMceCommands : JoplinCommandToTinyMceCommands = {
2020-03-10 01:24:57 +02:00
'textBold' : { name : 'mceToggleFormat' , value : 'bold' } ,
'textItalic' : { name : 'mceToggleFormat' , value : 'italic' } ,
'textLink' : { name : 'mceLink' } ,
'search' : { name : 'SearchReplace' } ,
} ;
2021-05-20 19:13:35 +02:00
interface LastOnChangeEventInfo {
content : string ;
resourceInfos : ResourceInfos ;
contentKey : string ;
}
2020-11-12 21:13:28 +02:00
let loadedCssFiles_ : string [ ] = [ ] ;
let loadedJsFiles_ : string [ ] = [ ] ;
let dispatchDidUpdateIID_ : any = null ;
2023-02-05 13:32:28 +02:00
let changeId_ = 1 ;
2020-03-10 01:24:57 +02:00
2020-11-12 21:13:28 +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-03-10 01:24:57 +02:00
const markupToHtml = useRef ( null ) ;
markupToHtml . current = props . markupToHtml ;
2021-05-20 19:13:35 +02:00
const lastOnChangeEventInfo = useRef < LastOnChangeEventInfo > ( {
2020-05-30 14:25:05 +02:00
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 ) ;
2020-09-15 15:01:07 +02:00
// const theme = themeStyle(props.themeId);
2020-04-02 19:16:11 +02:00
2020-05-04 19:31:55 +02:00
const { scrollToPercent } = useScroll ( { editor , onScroll : props.onScroll } ) ;
2020-10-09 19:35:46 +02:00
usePluginServiceRegistration ( ref ) ;
2021-05-13 10:34:03 +02:00
useContextMenu ( editor , props . plugins , props . dispatch ) ;
2020-10-09 19:35:46 +02:00
2020-11-12 21:13:28 +02:00
const dispatchDidUpdate = ( editor : any ) = > {
2020-10-09 19:35:46 +02:00
if ( dispatchDidUpdateIID_ ) shim . clearTimeout ( dispatchDidUpdateIID_ ) ;
dispatchDidUpdateIID_ = shim . setTimeout ( ( ) = > {
2020-03-10 01:24:57 +02:00
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-11-12 21:13:28 +02:00
const insertResourcesIntoContent = useCallback ( async ( filePaths : string [ ] = null , options : any = null ) = > {
2020-05-10 17:28:22 +02:00
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 ) ;
} , [ props . markupToHtml , editor ] ) ;
const insertResourcesIntoContentRef = useRef ( null ) ;
insertResourcesIntoContentRef . current = insertResourcesIntoContent ;
2020-11-12 21:13:28 +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 : ( ) = > {
2023-01-11 20:37:22 +02:00
if ( editor ) editor . getWin ( ) . scrollTo ( 0 , 0 ) ;
2020-05-02 17:41:07 +02:00
} ,
2020-11-12 21:13:28 +02:00
scrollTo : ( options : ScrollOptions ) = > {
2020-05-04 19:31:55 +02:00
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-11-12 21:13:28 +02:00
supportsCommand : ( name : string ) = > {
2020-05-03 19:44:49 +02:00
// TODO: should also handle commands that are not in this map (insertText, focus, etc);
return ! ! joplinCommandToTinyMceCommands [ name ] ;
} ,
2020-11-12 21:13:28 +02:00
execCommand : async ( cmd : EditorCommand ) = > {
2020-03-10 01:24:57 +02:00
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 ) ;
2020-12-02 12:36:00 +02:00
} else if ( cmd . name === 'editor.focus' ) {
2020-03-10 01:24:57 +02:00
editor . focus ( ) ;
2021-02-06 18:01:06 +02:00
} else if ( cmd . name === 'editor.execCommand' ) {
if ( ! ( 'ui' in cmd . value ) ) cmd . value . ui = false ;
if ( ! ( 'value' in cmd . value ) ) cmd . value . value = null ;
if ( ! ( 'args' in cmd . value ) ) cmd . value . args = { } ;
editor . execCommand ( cmd . value . name , cmd . value . ui , cmd . value . value , cmd . value . args ) ;
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 {
2020-09-06 17:28:23 +02:00
reg . logger ( ) . warn ( 'TinyMCE: unsupported drop item: ' , cmd ) ;
2020-05-10 17:28:22 +02:00
}
2020-03-10 01:24:57 +02:00
} else {
commandProcessed = false ;
}
if ( commandProcessed ) return true ;
2020-11-12 21:13:28 +02:00
const additionalCommands : any = {
2020-10-09 19:35:46 +02:00
selectedText : ( ) = > {
return stripMarkup ( MarkupToHtml . MARKUP_LANGUAGE_HTML , editor . selection . getContent ( ) ) ;
} ,
selectedHtml : ( ) = > {
return editor . selection . getContent ( ) ;
} ,
2020-11-12 21:13:28 +02:00
replaceSelection : ( value : any ) = > {
2020-10-09 19:35:46 +02:00
editor . selection . setContent ( value ) ;
2020-11-13 19:55:35 +02:00
editor . fire ( 'joplinChange' ) ;
dispatchDidUpdate ( editor ) ;
2020-11-15 17:59:47 +02:00
// It doesn't make sense but it seems calling setContent
// doesn't create an undo step so we need to call it
// manually.
// https://github.com/tinymce/tinymce/issues/3745
window . requestAnimationFrame ( ( ) = > editor . undoManager . add ( ) ) ;
2020-10-09 19:35:46 +02:00
} ,
} ;
if ( additionalCommands [ cmd . name ] ) {
return additionalCommands [ cmd . name ] ( cmd . value ) ;
}
2020-03-10 01:24:57 +02:00
if ( ! joplinCommandToTinyMceCommands [ cmd . name ] ) {
reg . logger ( ) . warn ( 'TinyMCE: unsupported Joplin command: ' , cmd ) ;
return false ;
}
2020-11-12 21:13:28 +02:00
const tinyMceCmd : TinyMceCommand = { . . . joplinCommandToTinyMceCommands [ cmd . name ] } ;
2020-03-10 01:24:57 +02:00
if ( ! ( 'ui' in tinyMceCmd ) ) tinyMceCmd . ui = false ;
if ( ! ( 'value' in tinyMceCmd ) ) tinyMceCmd . value = null ;
editor . execCommand ( tinyMceCmd . name , tinyMceCmd . ui , tinyMceCmd . value ) ;
return true ;
} ,
} ;
2022-08-19 13:10:04 +02:00
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
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.
// -----------------------------------------------------------------------------------------
2021-11-15 19:19:51 +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(null);
// };
// document.getElementsByTagName('head')[0].appendChild(element);
// });
// };
2020-03-23 02:47:25 +02:00
2020-03-10 01:24:57 +02:00
useEffect ( ( ) = > {
2020-03-23 02:47:25 +02:00
let cancelled = false ;
async function loadScripts() {
2020-11-12 21:13:28 +02:00
const scriptsToLoad : any [ ] = [
2020-03-23 02:47:25 +02:00
{
2021-12-23 13:04:09 +02:00
src : ` ${ bridge ( ) . vendorDir ( ) } /lib/tinymce/tinymce.min.js ` ,
2020-03-23 02:47:25 +02:00
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-11-25 16:40:25 +02:00
void loadScripts ( ) ;
2020-03-23 02:47:25 +02:00
2020-03-10 01:24:57 +02:00
return ( ) = > {
cancelled = true ;
} ;
} , [ ] ) ;
2020-04-03 20:12:14 +02:00
useEffect ( ( ) = > {
2020-09-15 15:01:07 +02:00
const theme = themeStyle ( props . themeId ) ;
2020-04-03 20:12:14 +02:00
const element = document . createElement ( 'style' ) ;
element . setAttribute ( 'id' , 'tinyMceStyle' ) ;
document . head . appendChild ( element ) ;
element . appendChild ( document . createTextNode ( `
2020-09-15 15:01:07 +02:00
. joplin - tinymce . tox - editor - header {
padding - left : $ { styles . leftExtraToolbarContainer . width + styles . leftExtraToolbarContainer . padding * 2 } px ;
padding - right : $ { styles . rightExtraToolbarContainer . width + styles . rightExtraToolbarContainer . padding * 2 } px ;
}
2020-04-03 20:12:14 +02:00
. 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
}
2021-11-17 21:32:27 +02:00
. tox . tox - dialog__body - content {
color : $ { theme . color } ;
}
2021-06-23 13:35:00 +02:00
/ *
When creating dialogs , TinyMCE doesn ' t seem to offer a way to style the components or to assign classes to them .
We want the code dialog box text area to be monospace , and since we can ' t target this precisely , we apply the style
to all textareas of all dialogs . As I think only the code dialog displays a textarea that should be fine .
* /
. tox . tox - dialog textarea {
font - family : Menlo , Monaco , Consolas , "Courier New" , monospace ;
}
2021-10-08 16:18:26 +02:00
. tox . tox - dialog - wrap__backdrop {
background - color : $ { theme . backgroundColor } ! important ;
opacity :0.7
}
2020-04-03 20:12:14 +02:00
. tox . tox - editor - header {
2020-09-15 15:01:07 +02:00
border : none ;
2020-04-03 20:12:14 +02:00
}
. 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-09-15 15:01:07 +02:00
color : $ { theme . color3 } ! important ;
fill : $ { theme . color3 } ! 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 ;
}
2021-03-16 12:07:20 +02:00
. tox . tox - tbtn :focus {
background - color : $ { theme . backgroundColor3 }
}
2020-04-03 20:12:14 +02:00
. tox . tox - tbtn :hover {
2020-09-15 15:01:07 +02:00
color : $ { theme . colorHover3 } ! important ;
fill : $ { theme . colorHover3 } ! important ;
background - color : $ { theme . backgroundColorHover3 }
2021-03-16 12:07:20 +02:00
}
2020-09-15 15:01:07 +02:00
. tox . tox - tbtn {
width : $ { theme . toolbarHeight } px ;
height : $ { theme . toolbarHeight } px ;
min - width : $ { theme . toolbarHeight } px ;
min - height : $ { theme . toolbarHeight } px ;
margin : 0 ;
}
. tox . tox - tbtn [ aria - haspopup = true ] {
width : $ { theme . toolbarHeight + 15 } px ;
min - width : $ { theme . toolbarHeight + 15 } px ;
}
. tox . tox - tbtn > span ,
. tox . tox - tbtn :active > span ,
. tox . tox - tbtn :hover > span {
transform : scale ( 0.8 ) ;
2020-04-03 20:12:14 +02:00
}
. tox . tox - toolbar__primary ,
. tox . tox - toolbar__overflow {
background : none ;
2020-09-15 15:01:07 +02:00
background - color : $ { theme . backgroundColor3 } ! important ;
2020-04-03 20:12:14 +02:00
}
. 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-09-15 15:01:07 +02:00
border : none ! important ;
2020-04-03 20:12:14 +02:00
}
2020-05-03 19:44:49 +02:00
. tox - tinymce {
border - top : none ! important ;
}
2020-09-15 15:01:07 +02:00
. joplin - tinymce . tox - toolbar__group {
background - color : $ { theme . backgroundColor3 } ;
padding - top : $ { theme . toolbarPadding } px ;
padding - bottom : $ { theme . toolbarPadding } px ;
}
2021-12-03 14:23:31 +02:00
. joplin - tinymce . tox . tox - edit - area__iframe {
background - color : $ { theme . backgroundColor } ! important ;
}
. joplin - tinymce . tox . tox - toolbar__primary {
/ * T h i s c o m p o n e n t s e t s a n e m p t y s v g w i t h a w h i t e b a c k g r o u n d a s t h e b a c k g r o u n d
* which needs to be cleared to prevent it from flashing white in dark themes * /
background : none ;
background - color : $ { theme . backgroundColor3 } ! important ;
}
2020-04-03 20:12:14 +02:00
` ));
return ( ) = > {
document . head . removeChild ( element ) ;
} ;
2021-12-03 14:23:31 +02:00
// editorReady is here because TinyMCE starts by initializing a blank iframe, which needs to be
// styled by us, otherwise users in dark mode get a bright white flash. During initialization
// our styling is overwritten which causes some elements to have the wrong styling. Removing the
// style and re-applying it on editorReady gives our styles precedence and prevents any flashing
//
// tl;dr: editorReady is used here because the css needs to be re-applied after TinyMCE init
2022-08-19 13:10:04 +02:00
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
2020-09-15 15:01:07 +02:00
} , [ editorReady , props . themeId ] ) ;
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
const loadEditor = async ( ) = > {
2020-05-11 19:59:23 +02:00
const language = closestSupportedLocale ( props . locale , true , supportedLocales ) ;
2020-11-12 21:13:28 +02:00
const pluginCommandNames : string [ ] = [ ] ;
2020-10-09 19:35:46 +02:00
const infos = pluginUtils . viewInfosByType ( props . plugins , 'toolbarButton' ) ;
for ( const info of infos ) {
const view = info . view ;
if ( view . location !== 'editorToolbar' ) continue ;
pluginCommandNames . push ( view . commandName ) ;
}
const toolbarPluginButtons = pluginCommandNames . length ? ` | ${ pluginCommandNames . join ( ' ' ) } ` : '' ;
2020-10-21 11:39:53 +02:00
const toolbar = [
2021-03-17 11:48:01 +02:00
'bold' , 'italic' , 'joplinHighlight' , 'joplinStrikethrough' , 'formattingExtras' , '|' ,
2020-10-21 11:39:53 +02:00
'link' , 'joplinInlineCode' , 'joplinCodeBlock' , 'joplinAttach' , '|' ,
'bullist' , 'numlist' , 'joplinChecklist' , '|' ,
'h1' , 'h2' , 'h3' , 'hr' , 'blockquote' , 'table' , ` joplinInsertDateTime ${ toolbarPluginButtons } ` ,
] ;
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-09-15 15:01:07 +02:00
statusbar : false ,
2020-04-09 20:05:07 +02:00
target_list : false ,
2020-04-10 20:22:17 +02:00
table_resize_bars : false ,
2021-12-23 13:04:09 +02:00
language_url : [ 'en_US' , 'en_GB' ] . includes ( language ) ? undefined : ` ${ bridge ( ) . vendorDir ( ) } /lib/tinymce/langs/ ${ language } ` ,
2020-10-21 11:39:53 +02:00
toolbar : toolbar.join ( ' ' ) ,
2020-05-04 01:55:41 +02:00
localization_function : _ ,
2020-11-05 18:58:23 +02:00
contextmenu : false ,
browser_spellcheck : true ,
2021-03-16 21:39:35 +02:00
formats : {
2021-03-17 11:48:01 +02:00
joplinHighlight : { inline : 'mark' , remove : 'all' } ,
joplinStrikethrough : { inline : 's' , remove : 'all' } ,
joplinInsert : { inline : 'ins' , remove : 'all' } ,
joplinSub : { inline : 'sub' , remove : 'all' } ,
joplinSup : { inline : 'sup' , remove : 'all' } ,
2021-03-16 21:39:35 +02:00
} ,
2020-11-12 21:13:28 +02:00
setup : ( editor : any ) = > {
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
} ,
} ) ;
2021-03-17 11:48:01 +02:00
setupToolbarButtons ( editor ) ;
2021-03-16 21:39:35 +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 ( ) {
2021-07-26 15:50:31 +02:00
openEditDialog ( editor , markupToHtml , dispatchDidUpdate , null ) ;
2020-04-09 18:47:12 +02:00
} ,
} ) ;
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' } ) ;
} ,
2020-11-12 21:13:28 +02:00
onSetup : function ( api : any ) {
2020-04-09 18:47:12 +02:00
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' , {
2022-10-30 20:37:58 +02:00
tooltip : _ ( 'Insert time' ) ,
2020-05-04 01:55:41 +02:00
icon : 'insert-time' ,
onAction : function ( ) {
2020-11-25 16:40:25 +02:00
void CommandService . instance ( ) . execute ( 'insertDateTime' ) ;
2020-05-04 01:55:41 +02:00
} ,
} ) ;
2020-10-09 19:35:46 +02:00
for ( const pluginCommandName of pluginCommandNames ) {
editor . ui . registry . addButton ( pluginCommandName , {
tooltip : CommandService.instance ( ) . label ( pluginCommandName ) ,
icon : CommandService.instance ( ) . iconName ( pluginCommandName , 'tinymce' ) ,
onAction : function ( ) {
2020-11-25 16:40:25 +02:00
void CommandService . instance ( ) . execute ( pluginCommandName ) ;
2020-10-09 19:35:46 +02:00
} ,
} ) ;
}
2021-07-09 11:48:50 +02:00
editor . addShortcut ( 'Meta+Shift+7' , '' , ( ) = > editor . execCommand ( 'InsertOrderedList' ) ) ;
editor . addShortcut ( 'Meta+Shift+8' , '' , ( ) = > editor . execCommand ( 'InsertUnorderedList' ) ) ;
editor . addShortcut ( 'Meta+Shift+9' , '' , ( ) = > editor . execCommand ( 'InsertJoplinChecklist' ) ) ;
2020-03-10 01:24:57 +02:00
// TODO: remove event on unmount?
2020-11-12 21:13:28 +02:00
editor . on ( 'DblClick' , ( event : any ) = > {
2020-03-10 01:24:57 +02:00
const editable = findEditableContainer ( event . target ) ;
2021-07-26 15:50:31 +02:00
if ( editable ) openEditDialog ( editor , markupToHtml , dispatchDidUpdate , editable ) ;
2020-03-10 01:24:57 +02:00
} ) ;
2020-05-10 17:28:22 +02:00
// This is triggered when an external file is dropped on the editor
2020-11-12 21:13:28 +02:00
editor . on ( 'drop' , ( event : any ) = > {
2021-08-05 13:09:21 +02:00
// Prevent the message "Dropped file type is not
// supported" to show up. It was added in a recent
// TinyMCE version and doesn't apply since we do support
// the file type.
// https://stackoverflow.com/questions/64782955/tinymce-inline-drag-and-drop-image-upload-not-working
event . preventDefault ( ) ;
2020-05-10 17:28:22 +02:00
props_onDrop . current ( event ) ;
} ) ;
2020-11-12 21:13:28 +02:00
editor . on ( 'ObjectResized' , function ( event : any ) {
2020-03-10 01:24:57 +02:00
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 ] ) ;
} ;
2020-11-25 16:40:25 +02:00
void loadEditor ( ) ;
2022-08-19 13:10:04 +02:00
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
2020-03-10 01:24:57 +02:00
} , [ scriptLoaded ] ) ;
// -----------------------------------------------------------------------------------------
// Set the initial content and load the plugin CSS and JS files
// -----------------------------------------------------------------------------------------
2020-11-12 21:13:28 +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-09-15 15:01:07 +02:00
const theme = themeStyle ( props . themeId ) ;
2020-11-12 21:13:28 +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 = [
2021-12-23 13:04:09 +02:00
` ${ bridge ( ) . vendorDir ( ) } /lib/@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
2020-11-12 21:13:28 +02:00
. filter ( ( a : any ) = > a . mime === 'text/css' )
. map ( ( a : any ) = > a . path )
) . 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
2020-11-12 21:13:28 +02:00
. filter ( ( a : any ) = > a . mime === 'application/javascript' )
. map ( ( a : any ) = > a . path )
) . 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
2021-05-20 19:13:35 +02:00
function resourceInfosEqual ( ri1 : ResourceInfos , ri2 : ResourceInfos ) : boolean {
if ( ri1 && ! ri2 || ! ri1 && ri2 ) return false ;
if ( ! ri1 && ! ri2 ) return true ;
const keys1 = Object . keys ( ri1 ) ;
const keys2 = Object . keys ( ri2 ) ;
if ( keys1 . length !== keys2 . length ) return false ;
// The attachedResources() call that generates the ResourceInfos object
// uses cache for the resource objects, so we can use strict equality
// for comparison.
for ( const k of keys1 ) {
if ( ri1 [ k ] !== ri2 [ k ] ) return false ;
}
return true ;
}
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 ( ) = > {
2021-05-20 19:13:35 +02:00
const resourcesEqual = resourceInfosEqual ( lastOnChangeEventInfo . current . resourceInfos , props . resourceInfos ) ;
if ( lastOnChangeEventInfo . current . content !== props . content || ! resourcesEqual ) {
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
2021-05-17 20:30:48 +02:00
editor . setContent ( awfulBrHack ( result . html ) ) ;
2020-07-23 21:56:53 +02:00
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
2021-09-19 13:03:16 +02:00
await loadDocumentAssets ( editor , await props . allAssets ( props . contentMarkupLanguage , { contentMaxWidthTarget : '.mce-content-body' } ) ) ;
2020-03-10 01:24:57 +02:00
dispatchDidUpdate ( editor ) ;
} ;
2020-11-25 16:40:25 +02:00
void loadContent ( ) ;
2020-03-10 01:24:57 +02:00
return ( ) = > {
cancelled = true ;
2020-05-03 19:44:49 +02:00
} ;
2022-08-19 13:10:04 +02:00
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
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 ;
2021-05-20 19:13:35 +02:00
lastOnChangeEventInfo . current . resourceInfos = await attachedResources ( 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 ( ) = > {
2020-11-25 16:40:25 +02:00
void execOnChangeEvent ( ) ;
2020-07-24 01:55:01 +02:00
} ;
2022-08-19 13:10:04 +02:00
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
2020-07-24 01:55:01 +02:00
} , [ ] ) ;
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 } ) ;
2020-10-09 19:35:46 +02:00
if ( onChangeHandlerTimeoutRef . current ) shim . clearTimeout ( onChangeHandlerTimeoutRef . current ) ;
2020-07-24 01:55:01 +02:00
nextOnChangeEventInfo . current = {
changeId : changeId ,
editor : editor ,
contentMarkupLanguage : props.contentMarkupLanguage ,
contentOriginalCss : props.contentOriginalCss ,
} ;
2020-03-10 01:24:57 +02:00
2020-10-09 19:35:46 +02:00
onChangeHandlerTimeoutRef . current = shim . setTimeout ( async ( ) = > {
2020-07-24 01:55:01 +02:00
onChangeHandlerTimeoutRef . current = null ;
2020-11-25 16:40:25 +02:00
void 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-11-12 21:13:28 +02:00
function onExecCommand ( event : any ) {
const c : string = event . command ;
2020-03-10 01:24:57 +02:00
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
2021-01-21 20:33:33 +02:00
const changeCommands = [ 'mceBlockQuote' , 'ToggleJoplinChecklistItem' , 'Bold' , 'Italic' , 'Underline' , 'Paragraph' ] ;
2020-03-10 01:24:57 +02:00
2020-11-30 20:20:27 +02:00
if (
changeCommands . includes ( c ) ||
c . indexOf ( 'Insert' ) === 0 ||
2021-01-21 20:33:33 +02:00
c . indexOf ( 'Header' ) === 0 ||
2020-11-30 20:20:27 +02:00
c . indexOf ( 'mceToggle' ) === 0 ||
c . indexOf ( 'mceInsert' ) === 0 ||
c . indexOf ( 'mceTable' ) === 0
) {
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
// 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-11-12 21:13:28 +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
}
2020-11-12 21:13:28 +02:00
async function onPaste ( event : any ) {
2021-05-20 18:08:59 +02:00
// We do not use the default pasting behaviour because the input has
// to be processed in various ways.
event . preventDefault ( ) ;
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 {
2021-05-20 17:42:04 +02:00
const pastedText = event . clipboardData . getData ( 'text/plain' ) ;
2020-05-11 20:26:04 +02:00
if ( BaseItem . isMarkdownTag ( pastedText ) ) { // Paste a link to a note
const result = await markupToHtml . current ( MarkupToHtml . MARKUP_LANGUAGE_MARKDOWN , pastedText , markupRenderOptions ( { bodyOnly : true } ) ) ;
editor . insertContent ( result . html ) ;
} else { // Paste regular text
2022-10-01 16:35:54 +02:00
// event.clipboardData.getData('text/html') wraps the content with <html><body></body></html>,
// which seems to be not supported in editor.insertContent().
2022-11-14 14:29:07 +02:00
//
// when pasting text with Ctrl+Shift+V, the format should be ignored.
// In this case, event.clopboardData.getData('text/html') returns an empty string, but the clipboard.readHTML() still returns the formatted text.
const pastedHtml = event . clipboardData . getData ( 'text/html' ) ? clipboard . readHTML ( ) : '' ;
2021-05-20 18:08:59 +02:00
if ( pastedHtml ) { // Handles HTML
2021-05-03 16:13:51 +02:00
const modifiedHtml = await processPastedHtml ( pastedHtml ) ;
editor . insertContent ( modifiedHtml ) ;
2021-05-20 18:08:59 +02:00
} else { // Handles plain text
pasteAsPlainText ( pastedText ) ;
2021-05-03 16:13:51 +02:00
}
2021-05-20 18:08:59 +02:00
// This code before was necessary to get undo working after
// pasting but it seems it's no longer necessary, so
// removing it for now. We also couldn'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-05-11 20:26:04 +02:00
}
2020-04-10 19:59:51 +02:00
}
}
2020-03-10 01:24:57 +02:00
2021-03-29 10:40:50 +02:00
async function onCopy ( event : any ) {
const copiedContent = editor . selection . getContent ( ) ;
2021-04-08 11:30:12 +02:00
copyHtmlToClipboard ( copiedContent ) ;
2021-03-29 10:40:50 +02:00
event . preventDefault ( ) ;
}
2021-04-08 11:30:12 +02:00
async function onCut ( event : any ) {
const selectedContent = editor . selection . getContent ( ) ;
copyHtmlToClipboard ( selectedContent ) ;
editor . insertContent ( '' ) ;
event . preventDefault ( ) ;
onChangeHandler ( ) ;
}
2021-05-20 18:08:59 +02:00
function pasteAsPlainText ( text : string = null ) {
const pastedText = text === null ? clipboard . readText ( ) : text ;
if ( pastedText ) {
editor . insertContent ( plainTextToHtml ( pastedText ) ) ;
}
}
2021-01-01 14:38:17 +02:00
function onKeyDown ( event : any ) {
2021-07-06 15:03:17 +02:00
// It seems "paste as text" is handled automatically on Windows and Linux,
// so we need to run the below code only on macOS. If we were to run this
// on Windows/Linux, we would have this double-paste issue:
2020-12-23 22:03:38 +02:00
// https://github.com/laurent22/joplin/issues/4243
2020-05-05 19:52:06 +02:00
// 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
2021-07-06 15:03:17 +02:00
if ( ! shim . isWindows ( ) && ! shim . isLinux ( ) ) {
2021-01-01 14:38:17 +02:00
if ( ( event . metaKey || event . ctrlKey ) && event . shiftKey && event . code === 'KeyV' ) {
2021-05-20 18:08:59 +02:00
pasteAsPlainText ( ) ;
2021-01-01 14:38:17 +02:00
}
}
2020-05-05 19:52:06 +02:00
}
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 ) ;
2021-03-29 10:40:50 +02:00
editor . on ( 'copy' , onCopy ) ;
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 ) ;
2021-04-08 11:30:12 +02:00
editor . on ( 'cut' , onCut ) ;
2020-03-10 01:24:57 +02:00
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 ) ;
2021-03-29 10:40:50 +02:00
editor . off ( 'copy' , onCopy ) ;
2020-05-08 01:16:04 +02:00
editor . off ( 'compositionend' , onChangeHandler ) ;
2021-04-08 11:30:12 +02:00
editor . off ( 'cut' , onCut ) ;
2020-03-10 01:24:57 +02:00
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 ) ;
}
} ;
2022-08-19 13:10:04 +02:00
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
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-11-12 21:13:28 +02:00
function renderExtraToolbarButton ( key : string , info : ToolbarButtonInfo ) {
2020-09-15 15:01:07 +02:00
return < ToolbarButton
key = { key }
themeId = { props . themeId }
toolbarButtonInfo = { info }
/ > ;
}
2020-10-10 14:32:30 +02:00
const leftButtonCommandNames = [ 'historyBackward' , 'historyForward' , 'toggleExternalEditing' ] ;
2020-09-15 15:01:07 +02:00
function renderLeftExtraToolbarButtons() {
const buttons = [ ] ;
2020-10-09 19:35:46 +02:00
for ( const info of props . noteToolbarButtonInfos ) {
if ( ! leftButtonCommandNames . includes ( info . name ) ) continue ;
buttons . push ( renderExtraToolbarButton ( info . name , info ) ) ;
2020-09-15 15:01:07 +02:00
}
return (
< div style = { styles . leftExtraToolbarContainer } >
{ buttons }
< / div >
) ;
}
function renderRightExtraToolbarButtons() {
const buttons = [ ] ;
2020-10-09 19:35:46 +02:00
for ( const info of props . noteToolbarButtonInfos ) {
if ( leftButtonCommandNames . includes ( info . name ) ) continue ;
2020-09-15 15:01:07 +02:00
2020-10-09 19:35:46 +02:00
if ( info . name === 'toggleEditors' ) {
2020-09-15 15:01:07 +02:00
buttons . push ( < ToggleEditorsButton
2020-10-09 19:35:46 +02:00
key = { info . name }
2020-09-15 15:01:07 +02:00
value = { ToggleEditorsButtonValue . RichText }
themeId = { props . themeId }
toolbarButtonInfo = { info }
/ > ) ;
} else {
2020-10-09 19:35:46 +02:00
buttons . push ( renderExtraToolbarButton ( info . name , info ) ) ;
2020-09-15 15:01:07 +02:00
}
}
return (
< div style = { styles . rightExtraToolbarContainer } >
{ buttons }
< / div >
) ;
}
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-09-15 15:01:07 +02:00
const theme = themeStyle ( props . themeId ) ;
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 (
2020-09-15 15:01:07 +02:00
< div style = { styles . rootStyle } className = "joplin-tinymce" >
2020-04-02 19:16:11 +02:00
{ renderDisabledOverlay ( ) }
2020-09-15 15:01:07 +02:00
{ renderLeftExtraToolbarButtons ( ) }
{ renderRightExtraToolbarButtons ( ) }
2020-04-02 19:16:11 +02:00
< div style = { { width : '100%' , height : '100%' } } id = { rootIdRef . current } / >
< / div >
) ;
2020-03-10 01:24:57 +02:00
} ;
export default forwardRef ( TinyMCE ) ;