import * as React from 'react'; import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react'; import { ScrollOptions, ScrollOptionTypes, EditorCommand, NoteBodyEditorProps, ResourceInfos } from '../../utils/types'; import { resourcesStatus, commandAttachFileToBody, handlePasteEvent, processPastedHtml, attachedResources } from '../../utils/resourceHandling'; import useScroll from './utils/useScroll'; import styles_ from './styles'; import CommandService from '@joplin/lib/services/CommandService'; import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils'; import ToggleEditorsButton, { Value as ToggleEditorsButtonValue } from '../../../ToggleEditorsButton/ToggleEditorsButton'; import ToolbarButton from '../../../../gui/ToolbarButton/ToolbarButton'; import usePluginServiceRegistration from '../../utils/usePluginServiceRegistration'; import { utils as pluginUtils } from '@joplin/lib/services/plugins/reducer'; import { _, closestSupportedLocale } from '@joplin/lib/locale'; import useContextMenu from './utils/useContextMenu'; import { copyHtmlToClipboard } from '../../utils/clipboardUtils'; import shim from '@joplin/lib/shim'; import { MarkupToHtml } from '@joplin/renderer'; import { reg } from '@joplin/lib/registry'; import BaseItem from '@joplin/lib/models/BaseItem'; import setupToolbarButtons from './utils/setupToolbarButtons'; import { plainTextToHtml } from '@joplin/lib/htmlUtils'; import openEditDialog from './utils/openEditDialog'; import { MarkupToHtmlOptions } from '../../utils/useMarkupToHtml'; import { themeStyle } from '@joplin/lib/theme'; import { loadScript } from '../../../utils/loadScript'; import bridge from '../../../../services/bridge'; const { clipboard } = require('electron'); const supportedLocales = require('./supportedLocales'); function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions { return { plugins: { checkbox: { checkboxRenderingType: 2, }, link_open: { linkRenderingType: 2, }, }, replaceResourceInternalToExternalLinks: true, ...override, }; } // In TinyMCE 5.2, when setting the body to '
', // it would end up as '

' once rendered // (an additional
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
// 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
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 === '
' ? '

' : html; } function findEditableContainer(node: any): any { while (node) { if (node.classList && node.classList.contains('joplin-editable')) return node; node = node.parentNode; } return null; } let markupToHtml_ = new MarkupToHtml(); function stripMarkup(markupLanguage: number, markup: string, options: any = null) { if (!markupToHtml_) markupToHtml_ = new MarkupToHtml(); return markupToHtml_.stripMarkup(markupLanguage, markup, options); } interface TinyMceCommand { name: string; value?: any; ui?: boolean; } interface JoplinCommandToTinyMceCommands { [key: string]: TinyMceCommand; } const joplinCommandToTinyMceCommands: JoplinCommandToTinyMceCommands = { 'textBold': { name: 'mceToggleFormat', value: 'bold' }, 'textItalic': { name: 'mceToggleFormat', value: 'italic' }, 'textLink': { name: 'mceLink' }, 'search': { name: 'SearchReplace' }, }; interface LastOnChangeEventInfo { content: string; resourceInfos: ResourceInfos; contentKey: string; } let loadedCssFiles_: string[] = []; let loadedJsFiles_: string[] = []; let dispatchDidUpdateIID_: any = null; let changeId_: number = 1; const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { const [editor, setEditor] = useState(null); const [scriptLoaded, setScriptLoaded] = useState(false); const [editorReady, setEditorReady] = useState(false); const [draggingStarted, setDraggingStarted] = useState(false); const props_onMessage = useRef(null); props_onMessage.current = props.onMessage; const props_onDrop = useRef(null); props_onDrop.current = props.onDrop; const markupToHtml = useRef(null); markupToHtml.current = props.markupToHtml; const lastOnChangeEventInfo = useRef({ content: null, resourceInfos: null, contentKey: null, }); const rootIdRef = useRef(`tinymce-${Date.now()}${Math.round(Math.random() * 10000)}`); const editorRef = useRef(null); editorRef.current = editor; const styles = styles_(props); // const theme = themeStyle(props.themeId); const { scrollToPercent } = useScroll({ editor, onScroll: props.onScroll }); usePluginServiceRegistration(ref); useContextMenu(editor, props.plugins, props.dispatch); const dispatchDidUpdate = (editor: any) => { if (dispatchDidUpdateIID_) shim.clearTimeout(dispatchDidUpdateIID_); dispatchDidUpdateIID_ = shim.setTimeout(() => { dispatchDidUpdateIID_ = null; if (editor && editor.getDoc()) editor.getDoc().dispatchEvent(new Event('joplin-noteDidUpdate')); }, 10); }; const insertResourcesIntoContent = useCallback(async (filePaths: string[] = null, options: any = null) => { const resourceMd = await commandAttachFileToBody('', filePaths, options); if (!resourceMd) return; 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; const onEditorContentClick = useCallback((event: any) => { const nodeName = event.target ? event.target.nodeName : ''; if (nodeName === 'INPUT' && event.target.getAttribute('type') === 'checkbox') { editor.fire('joplinChange'); dispatchDidUpdate(editor); } if (nodeName === 'A' && (event.ctrlKey || event.metaKey)) { const href = event.target.getAttribute('href'); if (href.indexOf('#') === 0) { 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 { props.onMessage({ channel: href }); } } }, [editor, props.onMessage]); useImperativeHandle(ref, () => { return { content: async () => { if (!editorRef.current) return ''; return prop_htmlToMarkdownRef.current(props.contentMarkupLanguage, editorRef.current.getContent(), props.contentOriginalCss); }, resetScroll: () => { if (editor) editor.getWin().scrollTo(0,0); }, scrollTo: (options: ScrollOptions) => { if (!editor) return; if (options.type === ScrollOptionTypes.Hash) { const anchor = editor.getDoc().getElementById(options.value); if (!anchor) { console.warn('Cannot find hash', options); return; } anchor.scrollIntoView(); } else if (options.type === ScrollOptionTypes.Percent) { scrollToPercent(options.value); } else { throw new Error(`Unsupported scroll options: ${options.type}`); } }, supportsCommand: (name: string) => { // TODO: should also handle commands that are not in this map (insertText, focus, etc); return !!joplinCommandToTinyMceCommands[name]; }, execCommand: async (cmd: EditorCommand) => { if (!editor) return false; reg.logger().debug('TinyMce: execCommand', cmd); let commandProcessed = true; if (cmd.name === 'insertText') { const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, cmd.value, { bodyOnly: true }); editor.insertContent(result.html); } else if (cmd.name === 'editor.focus') { editor.focus(); } 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); } else if (cmd.name === 'dropItems') { if (cmd.value.type === 'notes') { const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, cmd.value.markdownTags.join('\n'), markupRenderOptions({ bodyOnly: true })); editor.insertContent(result.html); } else if (cmd.value.type === 'files') { insertResourcesIntoContentRef.current(cmd.value.paths, { createFileURL: !!cmd.value.createFileURL }); } else { reg.logger().warn('TinyMCE: unsupported drop item: ', cmd); } } else { commandProcessed = false; } if (commandProcessed) return true; const additionalCommands: any = { selectedText: () => { return stripMarkup(MarkupToHtml.MARKUP_LANGUAGE_HTML, editor.selection.getContent()); }, selectedHtml: () => { return editor.selection.getContent(); }, replaceSelection: (value: any) => { editor.selection.setContent(value); editor.fire('joplinChange'); dispatchDidUpdate(editor); // 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()); }, }; if (additionalCommands[cmd.name]) { return additionalCommands[cmd.name](cmd.value); } if (!joplinCommandToTinyMceCommands[cmd.name]) { reg.logger().warn('TinyMCE: unsupported Joplin command: ', cmd); return false; } const tinyMceCmd: TinyMceCommand = { ...joplinCommandToTinyMceCommands[cmd.name] }; if (!('ui' in tinyMceCmd)) tinyMceCmd.ui = false; if (!('value' in tinyMceCmd)) tinyMceCmd.value = null; editor.execCommand(tinyMceCmd.name, tinyMceCmd.ui, tinyMceCmd.value); return true; }, }; // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied }, [editor, props.contentMarkupLanguage, props.contentOriginalCss]); // ----------------------------------------------------------------------------------------- // Load the TinyMCE library. The lib loads additional JS and CSS files on startup // (for themes), and so it needs to be loaded via