import * as React from 'react';
import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle, useMemo, Ref } from 'react';
import { ScrollOptions, ScrollOptionTypes, EditorCommand, NoteBodyEditorProps, ResourceInfos, HtmlToMarkdownHandler, ScrollToTextValue, NoteBodyEditorRef } from '../../utils/types';
import { resourcesStatus, commandAttachFileToBody, getResourcesFromPasteEvent, processPastedHtml } from '../../utils/resourceHandling';
import attachedResources from '@joplin/lib/utils/attachedResources';
import useScroll from './utils/useScroll';
import styles_ from './styles';
import CommandService from '@joplin/lib/services/CommandService';
import { ToolbarItem } 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 { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
import BaseItem from '@joplin/lib/models/BaseItem';
import Resource from '@joplin/lib/models/Resource';
import setupToolbarButtons from './utils/setupToolbarButtons';
import { plainTextToHtml } from '@joplin/lib/htmlUtils';
import { themeStyle } from '@joplin/lib/theme';
import { loadScript } from '../../../utils/loadScript';
import bridge from '../../../../services/bridge';
import { TinyMceEditorEvents } from './utils/types';
import type { Bookmark, Editor, EditorEvent } from 'tinymce';
import { joplinCommandToTinyMceCommands, TinyMceCommand } from './utils/joplinCommandToTinyMceCommands';
import shouldPasteResources from './utils/shouldPasteResources';
import lightTheme from '@joplin/lib/themes/light';
import { Options as NoteStyleOptions } from '@joplin/renderer/noteStyle';
import markupRenderOptions from '../../utils/markupRenderOptions';
import { DropHandler } from '../../utils/useDropHandler';
import Logger from '@joplin/utils/Logger';
import useWebViewApi from './utils/useWebViewApi';
import useLinkTooltips from './utils/useLinkTooltips';
import { blur, focus } from '@joplin/lib/utils/focusHandler';
import useHighlightedSearchTerms from './utils/useHighlightedSearchTerms';
const md5 = require('md5');
import { clipboard } from 'electron';
const supportedLocales = require('./supportedLocales');
import { hasProtocol } from '@joplin/utils/url';
import useTabIndenter from './utils/useTabIndenter';
import useKeyboardRefocusHandler from './utils/useKeyboardRefocusHandler';
import useDocument from '@joplin/lib/hooks/dom/useDocument';
import useEditDialog from './utils/useEditDialog';
import useEditDialogEventListeners from './utils/useEditDialogEventListeners';
import Setting from '@joplin/lib/models/Setting';
import useTextPatternsLookup, { TextPatternContext } from './utils/useTextPatternsLookup';
import { toFileProtocolPath } from '@joplin/utils/path';
import { RenderResultPluginAsset } from '@joplin/renderer/types';
import useCursorPositioning from './utils/useCursorPositioning';
import createVideoPoster from './utils/createVideoPoster';
import { createNoteInActiveFolder } from '../../../WindowCommandsAndDialogs/commands/newNote';
import searchNotesForMention from '../../utils/searchNotesForMention';
const logger = Logger.create('TinyMCE');
// 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, adds an empty paragraph. The exact reason for this is unclear
// so as a workaround we manually add this
for empty documents,
// which fixes the issue.
//
// However,
//
// breaks newline behaviour in new notes (see https://github.com/laurent22/joplin/issues/9786).
// Thus, we instead use
//
// which also seems to work around the list issue.
//
// Perhaps upgrading the list plugin (which is a fork of TinyMCE own list plugin)
// would help?
function preprocessHtml(html: string): string {
return html === '' ? '' : html;
}
let markupToHtml_ = new MarkupToHtml();
function stripMarkup(markupLanguage: number, markup: string, options: { collapseWhiteSpaces?: boolean } = null) {
if (!markupToHtml_) markupToHtml_ = new MarkupToHtml();
return markupToHtml_.stripMarkup(markupLanguage, markup, options);
}
interface LastOnChangeEventInfo {
content: string;
resourceInfos: ResourceInfos;
contentKey: string;
}
let dispatchDidUpdateIID_: ReturnType | null = null;
let changeId_ = 1;
const TinyMCE = (props: NoteBodyEditorProps, ref: Ref) => {
const [editorContainer, setEditorContainer] = useState(null);
const editorContainerDom = useDocument(editorContainer);
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 editorRef = useRef(null);
editorRef.current = editor;
const styles = styles_(props);
// const theme = themeStyle(props.themeId);
const { scrollToPercent } = useScroll({ editor, onScroll: props.onScroll });
const dispatchDidUpdate = useCallback((editor: Editor) => {
if (dispatchDidUpdateIID_) shim.clearTimeout(dispatchDidUpdateIID_);
dispatchDidUpdateIID_ = shim.setTimeout(() => {
dispatchDidUpdateIID_ = null;
if (editor && editor.getDoc()) editor.getDoc().dispatchEvent(new Event('joplin-noteDidUpdate'));
}, 10);
}, []);
const editDialog = useEditDialog({ editor, markupToHtml, dispatchDidUpdate });
const editDialogRef = useRef(editDialog);
editDialogRef.current = editDialog;
useEditDialogEventListeners(editor, editDialog);
usePluginServiceRegistration(ref);
useContextMenu(editor, props.plugins, props.dispatch, props.htmlToMarkdown, props.markupToHtml, editDialog);
useTabIndenter(editor, !props.tabMovesFocus);
useKeyboardRefocusHandler(editor);
const resourceInfosRef = useRef({});
resourceInfosRef.current = props.resourceInfos;
const setVideoPoster = useCallback(async (video: HTMLVideoElement) => {
if (video.dataset.joplinPosterLoading || video.dataset.joplinRuntimePoster) return;
const resource = resourceInfosRef.current[video.dataset.resourceId]?.item;
if (!resource?.mime.startsWith('video/')) return;
video.dataset.joplinPosterLoading = 'true';
const poster = await createVideoPoster(Resource.fullPath(resource));
delete video.dataset.joplinPosterLoading;
if (!poster || !video.isConnected) return;
video.poster = poster;
video.dataset.joplinRuntimePoster = 'true';
}, []);
useEffect(() => {
if (!editor) return () => {};
const editorDocument = editor.getDoc();
const onVideoError = (event: Event) => {
const video = event.target as HTMLVideoElement;
if (video?.tagName !== 'VIDEO') return;
void setVideoPoster(video);
};
const stripRuntimePosters = (event: { content: string }) => {
const document = editorDocument.implementation.createHTMLDocument('');
document.body.innerHTML = event.content;
for (const video of document.querySelectorAll('video[data-joplin-runtime-poster]')) {
video.removeAttribute('poster');
video.removeAttribute('data-joplin-runtime-poster');
video.removeAttribute('data-joplin-poster-loading');
}
event.content = document.body.innerHTML;
};
editorDocument.addEventListener('error', onVideoError, true);
editor.on('GetContent', stripRuntimePosters);
return () => {
editorDocument.removeEventListener('error', onVideoError, true);
editor.off('GetContent', stripRuntimePosters);
};
}, [editor, setVideoPoster]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TinyMCE editor instance / event types are looser than the published @types/tinymce (we use APIs not in the published types: getDoc, getWin, formatter, ui.registry, undoManager extensions)
const insertResourcesIntoContent = useCallback(async (filePaths: string[] = null, options: any = null) => {
const resourceMd = await commandAttachFileToBody('', filePaths, options);
if (!resourceMd) return;
const resourceInfos = await attachedResources(resourceMd);
resourceInfosRef.current = { ...resourceInfosRef.current, ...resourceInfos };
const result = await props.markupToHtml(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resourceMd, markupRenderOptions({
bodyOnly: true,
resourceInfos,
renderMedia: true,
autoplayVideo: true,
replaceResourceInternalToExternalLinks: false,
}));
editor.insertContent(result.html);
}, [props.markupToHtml, editor]);
const insertResourcesIntoContentRef = useRef(null);
insertResourcesIntoContentRef.current = insertResourcesIntoContent;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TinyMCE editor instance / event types are looser than the published @types/tinymce (we use APIs not in the published types: getDoc, getWin, formatter, ui.registry, undoManager extensions)
const onEditorContentClick = useCallback((event: any) => {
const nodeName = event.target ? event.target.nodeName : '';
if (nodeName === 'INPUT' && event.target.getAttribute('type') === 'checkbox') {
editor.fire(TinyMceEditorEvents.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 {
logger.warn('could not find anchor with ID ', anchorName);
}
} else {
props.onMessage({ channel: href });
}
}
}, [editor, props.onMessage, dispatchDidUpdate]);
useImperativeHandle(ref, () => {
return {
content: async () => {
if (!editorRef.current) return '';
return prop_htmlToMarkdownRef.current(props.contentMarkupLanguage, editorRef.current.getContent(), props.contentOriginalCss);
},
blurEditor: () => {
if (editor) blur('TinyMCE::reload', editor.getBody());
},
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;
logger.debug('execCommand', cmd);
let commandProcessed = true;
if (cmd.name === 'insertText') {
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, cmd.value, markupRenderOptions({ bodyOnly: true }));
editor.insertContent(result.html);
} else if (cmd.name === 'editor.focus') {
focus('TinyMCE::editor.focus', editor);
if (cmd.value?.moveCursorToStart) {
editor.selection.placeCaretAt(0, 0);
editor.selection.setCursorLocation(
editor.dom.root,
0,
);
}
} 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 {
logger.warn('unsupported drop item: ', cmd);
}
} else if (cmd.name === 'editor.scrollToText') {
const cmdValue = cmd.value as ScrollToTextValue;
const findElementByText = (doc: Document, text: string, element: string) => {
const headers = doc.querySelectorAll(element);
for (const header of headers) {
if (header.textContent?.trim() === text) {
return header;
}
}
return null;
};
const contentDocument = editor.getDoc();
const targetElement = findElementByText(contentDocument, cmdValue.text, cmdValue.element);
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
logger.warn('editor.scrollToText: Could not find text to scroll to :', cmdValue);
}
} else {
commandProcessed = false;
}
if (commandProcessed) return true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TinyMCE editor instance / event types are looser than the published @types/tinymce (we use APIs not in the published types: getDoc, getWin, formatter, ui.registry, undoManager extensions)
const additionalCommands: any = {
selectedText: () => {
return stripMarkup(MarkupToHtml.MARKUP_LANGUAGE_HTML, editor.selection.getContent());
},
selectedHtml: () => {
return editor.selection.getContent();
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TinyMCE editor instance / event types are looser than the published @types/tinymce (we use APIs not in the published types: getDoc, getWin, formatter, ui.registry, undoManager extensions)
replaceSelection: (value: any) => {
editor.selection.setContent(value);
editor.fire(TinyMceEditorEvents.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());
},
pasteAsText: () => editor.fire(TinyMceEditorEvents.PasteAsText),
'editor.undo': () => {
editor.undoManager.undo();
},
'editor.redo': () => {
editor.undoManager.redo();
},
};
if (additionalCommands[cmd.name]) {
return additionalCommands[cmd.name](cmd.value);
}
if (!joplinCommandToTinyMceCommands[cmd.name]) {
logger.warn('unsupported Joplin command: ', cmd);
return false;
}
if (joplinCommandToTinyMceCommands[cmd.name] === true) {
// Already handled in useWindowCommandHandlers.ts
} else if (joplinCommandToTinyMceCommands[cmd.name] === false) {
// explicitly 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;
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