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' ;
2023-08-21 19:37:33 +02:00
import { resourcesStatus , commandAttachFileToBody , getResourcesFromPasteEvent , 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' ;
2023-12-29 18:08:09 +02:00
import { MarkupLanguage , 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' ;
2023-02-15 15:59:32 +02:00
import { TinyMceEditorEvents } from './utils/types' ;
2023-06-10 18:08:15 +02:00
import type { Editor } from 'tinymce' ;
2023-06-14 15:56:14 +02:00
import { joplinCommandToTinyMceCommands , TinyMceCommand } from './utils/joplinCommandToTinyMceCommands' ;
2023-11-17 18:47:05 +02:00
import shouldPasteResources from './utils/shouldPasteResources' ;
2023-12-29 18:08:09 +02:00
import lightTheme from '@joplin/lib/themes/light' ;
import { Options as NoteStyleOptions } from '@joplin/renderer/noteStyle' ;
const md5 = require ( 'md5' ) ;
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 ) ;
}
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 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 ) = > {
2023-07-27 12:52:41 +02:00
const [ editor , setEditor ] = useState < Editor | null > ( null ) ;
2020-03-10 01:24:57 +02:00
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' ) {
2023-02-15 15:59:32 +02:00
editor . fire ( TinyMceEditorEvents . JoplinChange ) ;
2020-03-10 01:24:57 +02:00
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' ) {
2023-11-07 14:07:42 +02:00
const result = await markupToHtml . current ( MarkupToHtml . MARKUP_LANGUAGE_MARKDOWN , cmd . value , markupRenderOptions ( { bodyOnly : true } ) ) ;
2020-03-10 01:24:57 +02:00
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 ) ;
2023-02-15 15:59:32 +02:00
editor . fire ( TinyMceEditorEvents . JoplinChange ) ;
2020-11-13 19:55:35 +02:00
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
} ,
2023-02-15 15:59:32 +02:00
pasteAsText : ( ) = > editor . fire ( TinyMceEditorEvents . PasteAsText ) ,
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 ;
}
2023-06-14 15:56:14 +02:00
if ( joplinCommandToTinyMceCommands [ cmd . name ] === true ) {
// Already handled in useWindowCommandHandlers.ts
} else if ( joplinCommandToTinyMceCommands [ cmd . name ] === false ) {
// Explicitely not supported
} else {
const tinyMceCmd : TinyMceCommand = { . . . ( joplinCommandToTinyMceCommands [ cmd . name ] as TinyMceCommand ) } ;
if ( ! ( 'ui' in tinyMceCmd ) ) tinyMceCmd . ui = false ;
if ( ! ( 'value' in tinyMceCmd ) ) tinyMceCmd . value = null ;
2020-03-10 01:24:57 +02:00
2023-06-14 15:56:14 +02:00
editor . execCommand ( tinyMceCmd . name , tinyMceCmd . ui , tinyMceCmd . value ) ;
}
2020-03-10 01:24:57 +02:00
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 ;
}
2023-02-16 12:55:24 +02:00
// eslint-disable-next-line no-console
2020-03-23 02:47:25 +02:00
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 ) ;
2023-12-29 18:08:09 +02:00
const backgroundColor = props . whiteBackgroundNoteRendering ? lightTheme.backgroundColor : theme.backgroundColor ;
2020-09-15 15:01:07 +02:00
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 ,
2024-01-04 15:51:26 +02:00
. tox . tox - menu ,
2020-04-09 18:47:12 +02:00
. 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 } ;
}
2024-01-04 15:51:26 +02:00
. tox . tox - menu {
/ * E n s u r e s t h a t p o p o v e r m e n u s ( t h e c o l o r s w a t c h m e n u ) h a s a v i s i b l e b o r d e r
even in dark mode . * /
border : 1px solid rgba ( 140 , 140 , 140 , 0.3 ) ;
}
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 ,
2024-01-04 15:51:26 +02:00
. tox . tox - menu button > svg ,
. tox . tox - split - button ,
2020-04-09 18:47:12 +02:00
. 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 ,
2024-01-04 15:51:26 +02:00
. tox . tox - tbtn -- enabled :hover ,
. tox . tox - menu button :hover ,
. tox . tox - split - button {
2020-04-03 20:12:14 +02:00
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
2024-01-04 15:51:26 +02:00
. tox . tox - tbtn :focus ,
. tox . tox - split - button :focus {
2021-03-16 12:07:20 +02:00
background - color : $ { theme . backgroundColor3 }
}
2024-01-04 15:51:26 +02:00
. tox . tox - tbtn :hover ,
. tox . tox - menu button :hover > svg {
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 {
height : $ { 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
}
2024-01-04 15:51:26 +02:00
. tox . tox - split - button :hover {
box - shadow : none ;
}
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
2023-07-21 21:49:49 +02:00
/ * O v e r r i d e t h e T i n y M C E f o n t s t y l e s w i t h m o r e s p e c i f i c C S S s e l e c t o r s .
Without this , the built - in FontAwesome styles are not applied because
they are overridden by TinyMCE . * /
. plugin - icon . fa , . plugin - icon . far , . plugin - icon . fas {
font - family : "Font Awesome 5 Free" ;
font - size : $ { theme . toolbarHeight - theme . toolbarPadding } px ;
}
. plugin - icon . fa , . plugin - icon . fas {
font - weight : 900 ;
}
. plugin - icon . fab , . plugin - icon . far {
font - weight : 400 ;
}
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 {
2023-12-29 18:08:09 +02:00
background - color : $ { backgroundColor } ! important ;
2021-12-03 14:23:31 +02:00
}
. 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
2023-12-29 18:08:09 +02:00
} , [ editorReady , props . themeId , lightTheme , props . whiteBackgroundNoteRendering ] ) ;
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 ;
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 ( ' ' ) } ` : '' ;
2023-11-13 14:12:45 +02:00
// The toolbar is going to wrap based on groups of buttons
// (delimited by |). It means that if we leave large groups of
// buttons towards the end of the toolbar it's going to needlessly
// hide many buttons even when there is space. So this is why below,
// we create small groups of just one button towards the end.
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' , '|' ,
2023-11-13 14:12:45 +02:00
'h1' , 'h2' , 'h3' , '|' ,
'hr' , '|' ,
'blockquote' , '|' ,
'table' , '|' ,
` joplinInsertDateTime ${ toolbarPluginButtons } ` ,
2020-10-21 11:39:53 +02:00
] ;
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
2023-07-27 12:52:41 +02:00
// #p: Pad empty paragraphs with to prevent them from being removed.
// *[*]: Allow all elements and attributes -- we already filter in sanitize_html
// See https://www.tiny.cloud/docs/configure/content-filtering/#controlcharacters
valid_elements : '#p,*[*]' ,
2020-03-10 01:24:57 +02:00
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 ,
2023-06-10 18:08:15 +02:00
// Handle the first table row as table header.
// https://www.tiny.cloud/docs/plugins/table/#table_header_type
table_header_type : 'sectionCells' ,
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' } ,
2023-07-23 16:59:51 +02:00
code : { inline : 'code' , remove : 'all' , attributes : { spellcheck : false } } ,
2024-01-04 15:51:26 +02:00
forecolor : { inline : 'span' , styles : { color : '%value' } } ,
2021-03-16 21:39:35 +02:00
} ,
2023-06-10 18:08:15 +02:00
setup : ( editor : Editor ) = > {
2023-06-14 15:56:14 +02:00
editor . addCommand ( 'joplinAttach' , ( ) = > {
insertResourcesIntoContentRef . current ( ) ;
} ) ;
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 ( ) {
2023-06-14 15:56:14 +02:00
editor . execCommand ( 'joplinAttach' ) ;
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' } ) ;
} ,
2023-06-10 18:08:15 +02:00
onSetup : function ( api ) {
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 ) {
2023-07-21 21:49:49 +02:00
const iconClassName = CommandService . instance ( ) . iconName ( pluginCommandName ) ;
// Only allow characters that appear in Font Awesome class names: letters, spaces, and dashes.
const safeIconClassName = iconClassName . replace ( /[^a-z0-9 -]/g , '' ) ;
editor . ui . registry . addIcon ( pluginCommandName , ` <i class="plugin-icon ${ safeIconClassName } "></i> ` ) ;
2020-10-09 19:35:46 +02:00
editor . ui . registry . addButton ( pluginCommandName , {
tooltip : CommandService.instance ( ) . label ( pluginCommandName ) ,
2023-07-21 21:49:49 +02:00
icon : pluginCommandName ,
2020-10-09 19:35:46 +02:00
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?
2023-06-10 18:08:15 +02:00
editor . on ( 'DblClick' , ( event ) = > {
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
2023-06-10 18:08:15 +02:00
editor . on ( 'drop' , ( event ) = > {
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 ) ;
} ) ;
2023-06-10 18:08:15 +02:00
editor . on ( 'ObjectResized' , ( event ) = > {
2020-03-10 01:24:57 +02:00
if ( event . target . nodeName === 'IMG' ) {
2023-02-15 15:59:32 +02:00
editor . fire ( TinyMceEditorEvents . JoplinChange ) ;
2020-03-10 01:24:57 +02:00
dispatchDidUpdate ( editor ) ;
}
} ) ;
2020-04-03 20:12:14 +02:00
editor . on ( 'init' , ( ) = > {
setEditorReady ( true ) ;
} ) ;
2020-05-04 19:31:55 +02:00
2023-07-23 16:59:51 +02:00
const preprocessContent = ( ) = > {
// Disable spellcheck for all inline code blocks.
const codeElements = editor . dom . doc . querySelectorAll ( 'code.inline-code' ) ;
for ( const code of codeElements ) {
code . setAttribute ( 'spellcheck' , 'false' ) ;
}
} ;
2020-05-04 19:31:55 +02:00
editor . on ( 'SetContent' , ( ) = > {
2023-07-23 16:59:51 +02:00
preprocessContent ( ) ;
2020-05-04 19:31:55 +02:00
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
// -----------------------------------------------------------------------------------------
2023-12-29 18:08:09 +02:00
const loadDocumentAssets = ( themeId : number , editor : any , pluginAssets : any [ ] ) = > {
const theme = themeStyle ( themeId ) ;
2020-09-15 15:01:07 +02:00
2023-12-29 18:08:09 +02:00
let docHead_ : HTMLHeadElement = null ;
2020-05-21 01:57:59 +02:00
function docHead() {
if ( docHead_ ) return docHead_ ;
docHead_ = editor . getDoc ( ) . getElementsByTagName ( 'head' ) [ 0 ] ;
return docHead_ ;
}
2023-07-23 17:00:30 +02:00
const allCssFiles = [
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' )
2023-08-22 12:58:53 +02:00
. map ( ( a : any ) = > a . path ) ,
2023-07-23 17:00:30 +02:00
) ;
2020-03-29 21:06:13 +02:00
2023-07-23 17:00:30 +02:00
const allJsFiles = [ ] . 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' )
2023-08-22 12:58:53 +02:00
. map ( ( a : any ) = > a . path ) ,
2023-07-23 17:00:30 +02:00
) ;
2023-12-29 18:08:09 +02:00
const filePathToElementId = ( path : string ) = > {
return ` jop-tiny-mce- ${ md5 ( escape ( path ) ) } ` ;
} ;
2020-03-10 01:24:57 +02:00
2023-12-29 18:08:09 +02:00
const existingElements = Array . from ( docHead ( ) . getElementsByClassName ( 'jop-tinymce-css' ) ) . concat ( Array . from ( docHead ( ) . getElementsByClassName ( 'jop-tinymce-js' ) ) ) ;
2023-07-23 17:00:30 +02:00
2023-12-29 18:08:09 +02:00
const existingIds : string [ ] = [ ] ;
for ( const e of existingElements ) existingIds . push ( e . getAttribute ( 'id' ) ) ;
2023-07-23 17:00:30 +02:00
2023-12-29 18:08:09 +02:00
const processedIds : string [ ] = [ ] ;
2020-03-10 01:24:57 +02:00
2023-12-29 18:08:09 +02:00
for ( const cssFile of allCssFiles ) {
const elementId = filePathToElementId ( cssFile ) ;
processedIds . push ( elementId ) ;
if ( existingIds . includes ( elementId ) ) continue ;
2020-03-23 02:47:25 +02:00
2023-12-29 18:08:09 +02:00
const style = editor . dom . create ( 'link' , {
id : elementId ,
rel : 'stylesheet' ,
type : 'text/css' ,
href : cssFile ,
class : 'jop-tinymce-css' ,
} ) ;
2020-05-21 01:57:59 +02:00
2023-12-29 18:08:09 +02:00
docHead ( ) . appendChild ( style ) ;
2020-05-21 01:57:59 +02:00
}
2020-03-23 02:47:25 +02:00
2023-12-29 18:08:09 +02:00
for ( const jsFile of allJsFiles ) {
const elementId = filePathToElementId ( jsFile ) ;
processedIds . push ( elementId ) ;
if ( existingIds . includes ( elementId ) ) continue ;
2020-03-10 01:24:57 +02:00
2023-12-29 18:08:09 +02:00
const script = editor . dom . create ( 'script' , {
id : filePathToElementId ( jsFile ) ,
type : 'text/javascript' ,
class : 'jop-tinymce-js' ,
src : jsFile ,
} ) ;
2020-03-10 01:24:57 +02:00
2023-12-29 18:08:09 +02:00
docHead ( ) . appendChild ( script ) ;
}
// Remove all previously loaded files that aren't in the assets this time.
// Note: This is important to ensure that we properly change themes.
// See https://github.com/laurent22/joplin/issues/8520
for ( const existingId of existingIds ) {
if ( ! processedIds . includes ( existingId ) ) {
const element = existingElements . find ( e = > e . getAttribute ( 'id' ) === existingId ) ;
if ( element ) docHead ( ) . removeChild ( element ) ;
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 ) {
2023-12-06 21:17:16 +02:00
const result = await props . markupToHtml (
props . contentMarkupLanguage ,
props . content ,
markupRenderOptions ( {
resourceInfos : props.resourceInfos ,
// Allow file:// URLs that point to the resource directory.
// This prevents HTML-style resource URLs (e.g. <a href="file://path/to/resource/.../"></a>)
// from being discarded.
allowedFilePrefixes : [ props . resourceDirectory ] ,
} ) ,
) ;
2020-05-10 17:28:22 +02:00
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
2023-12-29 18:08:09 +02:00
const allAssetsOptions : NoteStyleOptions = {
contentMaxWidthTarget : '.mce-content-body' ,
themeId : props.contentMarkupLanguage === MarkupLanguage . Html ? 1 : null ,
whiteBackgroundNoteRendering : props.whiteBackgroundNoteRendering ,
} ;
const allAssets = await props . allAssets ( props . contentMarkupLanguage , allAssetsOptions ) ;
await loadDocumentAssets ( props . themeId , editor , allAssets ) ;
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
2023-12-29 18:08:09 +02:00
} , [ editor , props . themeId , props . markupToHtml , props . allAssets , props . content , props . resourceInfos , props . contentKey , props . contentMarkupLanguage , props . whiteBackgroundNoteRendering ] ) ;
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
2023-06-30 11:30:29 +02:00
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
2020-03-10 01:24:57 +02:00
const props_onChangeRef = useRef < Function > ( ) ;
props_onChangeRef . current = props . onChange ;
2023-06-30 11:30:29 +02:00
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
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
2024-01-04 15:51:26 +02:00
const changeCommands = [
'mceBlockQuote' ,
'ToggleJoplinChecklistItem' ,
'Bold' ,
'Italic' ,
'Underline' ,
'Paragraph' ,
'mceApplyTextcolor' ,
] ;
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
2023-02-16 11:01:50 +02:00
const onSetAttrib = ( event : any ) = > {
// Dispatch onChange when a link is edited
if ( event . attrElm [ 0 ] . nodeName === 'A' ) {
if ( event . attrName === 'title' || event . attrName === 'href' || event . attrName === 'rel' ) {
onChangeHandler ( ) ;
}
}
} ;
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
}
2023-02-13 21:16:33 +02:00
async function onPaste ( event : ClipboardEvent ) {
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 ( ) ;
2023-08-21 19:37:33 +02:00
const pastedText = event . clipboardData . getData ( 'text/plain' ) ;
2020-05-11 20:26:04 +02:00
2023-08-21 19:37:33 +02:00
// event.clipboardData.getData('text/html') wraps the
// content with <html><body></body></html>, which seems to
// be not supported in editor.insertContent().
//
// 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 ( ) : '' ;
2023-11-17 18:47:05 +02:00
const resourceMds = await getResourcesFromPasteEvent ( event ) ;
2023-08-21 19:37:33 +02:00
2023-11-17 18:47:05 +02:00
if ( shouldPasteResources ( pastedText , pastedHtml , resourceMds ) ) {
2023-08-21 19:37:33 +02:00
if ( resourceMds . length ) {
const result = await markupToHtml . current ( MarkupToHtml . MARKUP_LANGUAGE_MARKDOWN , resourceMds . join ( '\n' ) , markupRenderOptions ( { bodyOnly : true } ) ) ;
editor . insertContent ( result . html ) ;
}
} else {
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
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
}
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 ) ) ;
}
}
2023-03-10 14:53:48 +02:00
async 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
2023-03-10 14:53:48 +02:00
// While "paste as text" functionality is handled by Windows and Linux, if we
// want to allow the user to customize the shortcut we need to prevent when it
// has the default value so it doesn't paste the content twice
// (one by the system and the other by our code)
if ( ( event . metaKey || event . ctrlKey ) && event . shiftKey && event . code === 'KeyV' ) {
event . preventDefault ( ) ;
pasteAsPlainText ( null ) ;
2021-01-01 14:38:17 +02:00
}
2020-05-05 19:52:06 +02:00
}
2023-03-10 14:53:48 +02:00
function onPasteAsText() {
2023-06-26 13:00:47 +02:00
// clipboard.readText returns Markdown instead of text when copying content from
// the Rich Text Editor. When the user "Paste as text" he does not expect to see
// anything besides text, that is why we are stripping here before pasting
// https://github.com/laurent22/joplin/pull/8351
const clipboardWithoutMarkdown = stripMarkup ( MarkupToHtml . MARKUP_LANGUAGE_MARKDOWN , clipboard . readText ( ) ) ;
pasteAsPlainText ( clipboardWithoutMarkdown ) ;
2023-02-13 21:16:33 +02:00
}
2023-02-15 15:59:32 +02:00
editor . on ( TinyMceEditorEvents . KeyUp , onKeyUp ) ;
editor . on ( TinyMceEditorEvents . KeyDown , onKeyDown ) ;
editor . on ( TinyMceEditorEvents . KeyPress , onKeypress ) ;
editor . on ( TinyMceEditorEvents . Paste , onPaste ) ;
editor . on ( TinyMceEditorEvents . PasteAsText , onPasteAsText ) ;
editor . on ( TinyMceEditorEvents . 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.
2023-02-15 15:59:32 +02:00
editor . on ( TinyMceEditorEvents . CompositionEnd , onChangeHandler ) ;
editor . on ( TinyMceEditorEvents . Cut , onCut ) ;
editor . on ( TinyMceEditorEvents . JoplinChange , onChangeHandler ) ;
editor . on ( TinyMceEditorEvents . Undo , onChangeHandler ) ;
editor . on ( TinyMceEditorEvents . Redo , onChangeHandler ) ;
editor . on ( TinyMceEditorEvents . ExecCommand , onExecCommand ) ;
2023-02-16 11:01:50 +02:00
editor . on ( TinyMceEditorEvents . SetAttrib , onSetAttrib ) ;
2020-03-10 01:24:57 +02:00
return ( ) = > {
try {
2023-02-15 15:59:32 +02:00
editor . off ( TinyMceEditorEvents . KeyUp , onKeyUp ) ;
editor . off ( TinyMceEditorEvents . KeyDown , onKeyDown ) ;
editor . off ( TinyMceEditorEvents . KeyPress , onKeypress ) ;
editor . off ( TinyMceEditorEvents . Paste , onPaste ) ;
editor . off ( TinyMceEditorEvents . PasteAsText , onPasteAsText ) ;
editor . off ( TinyMceEditorEvents . Copy , onCopy ) ;
editor . off ( TinyMceEditorEvents . CompositionEnd , onChangeHandler ) ;
editor . off ( TinyMceEditorEvents . Cut , onCut ) ;
editor . off ( TinyMceEditorEvents . JoplinChange , onChangeHandler ) ;
editor . off ( TinyMceEditorEvents . Undo , onChangeHandler ) ;
editor . off ( TinyMceEditorEvents . Redo , onChangeHandler ) ;
editor . off ( TinyMceEditorEvents . ExecCommand , onExecCommand ) ;
2023-02-16 11:01:50 +02:00
editor . off ( TinyMceEditorEvents . SetAttrib , onSetAttrib ) ;
2020-03-10 01:24:57 +02:00
} 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 ) ;