From 8920db5537da5d28224fe12be8e2487a3170d06b Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Mon, 26 Jul 2021 14:50:31 +0100 Subject: [PATCH] Desktop: Fixes #5241: Katex code could be broken after editing it in Rich Text editor --- .eslintignore | 3 + .gitignore | 3 + .../NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx | 129 +--------------- .../NoteBody/TinyMCE/utils/openEditDialog.ts | 140 ++++++++++++++++++ 4 files changed, 149 insertions(+), 126 deletions(-) create mode 100644 packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.ts diff --git a/.eslintignore b/.eslintignore index b7f7372f4..db0300d39 100644 --- a/.eslintignore +++ b/.eslintignore @@ -372,6 +372,9 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js.map packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.d.ts packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js.map +packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.d.ts +packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.js +packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.js.map packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.d.ts packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js.map diff --git a/.gitignore b/.gitignore index c9b9fb1d4..2aa5a343a 100644 --- a/.gitignore +++ b/.gitignore @@ -357,6 +357,9 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js.map packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.d.ts packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js.map +packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.d.ts +packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.js +packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.js.map packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.d.ts packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js.map diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx index f5d0df4bc..70c150624 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx @@ -16,11 +16,11 @@ import { copyHtmlToClipboard } from '../../utils/clipboardUtils'; import shim from '@joplin/lib/shim'; const { MarkupToHtml } = require('@joplin/renderer'); -const taboverride = require('taboverride'); 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'; const { themeStyle } = require('@joplin/lib/theme'); const { clipboard } = require('electron'); const supportedLocales = require('./supportedLocales'); @@ -40,33 +40,6 @@ function markupRenderOptions(override: any = null) { }; } -function findBlockSource(node: any) { - const sources = node.getElementsByClassName('joplin-source'); - if (!sources.length) throw new Error('No source for node'); - const source = sources[0]; - - return { - openCharacters: source.getAttribute('data-joplin-source-open'), - closeCharacters: source.getAttribute('data-joplin-source-close'), - content: source.textContent, - node: source, - language: source.getAttribute('data-joplin-language') || '', - }; -} - -function newBlockSource(language: string = '', content: string = ''): any { - const fence = language === 'katex' ? '$$' : '```'; - const fenceLanguage = language === 'katex' ? '' : language; - - return { - openCharacters: `\n${fence}${fenceLanguage}\n`, - closeCharacters: `\n${fence}\n`, - content: content, - node: null, - language: language, - }; -} - // In TinyMCE 5.2, when setting the body to '
', // it would end up as '

' once rendered // (an additional
was inserted). @@ -95,42 +68,12 @@ function findEditableContainer(node: any): any { return null; } -function editableInnerHtml(html: string): string { - const temp = document.createElement('div'); - temp.innerHTML = html; - const editable = temp.getElementsByClassName('joplin-editable'); - if (!editable.length) throw new Error(`Invalid joplin-editable: ${html}`); - return editable[0].innerHTML; -} - -function dialogTextArea_keyDown(event: any) { - if (event.key === 'Tab') { - window.requestAnimationFrame(() => event.target.focus()); - } -} - let markupToHtml_ = new MarkupToHtml(); function stripMarkup(markupLanguage: number, markup: string, options: any = null) { if (!markupToHtml_) markupToHtml_ = new MarkupToHtml(); return markupToHtml_.stripMarkup(markupLanguage, markup, options); } -// Allows pressing tab in a textarea to input an actual tab (instead of changing focus) -// taboverride will take care of actually inserting the tab character, while the keydown -// event listener will override the default behaviour, which is to focus the next field. -function enableTextAreaTab(enable: boolean) { - const textAreas = document.getElementsByClassName('tox-textarea'); - for (const textArea of textAreas) { - taboverride.set(textArea, enable); - - if (enable) { - textArea.addEventListener('keydown', dialogTextArea_keyDown); - } else { - textArea.removeEventListener('keydown', dialogTextArea_keyDown); - } - } -} - interface TinyMceCommand { name: string; value?: any; @@ -618,70 +561,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { joplinSup: { inline: 'sup', remove: 'all' }, }, setup: (editor: any) => { - - function openEditDialog(editable: any) { - const source = editable ? findBlockSource(editable) : newBlockSource(); - - editor.windowManager.open({ - title: _('Edit'), - size: 'large', - initialData: { - codeTextArea: source.content, - languageInput: source.language, - }, - onSubmit: async (dialogApi: any) => { - const newSource = newBlockSource(dialogApi.getData().languageInput, dialogApi.getData().codeTextArea); - const md = `${newSource.openCharacters}${newSource.content.trim()}${newSource.closeCharacters}`; - const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, md, { bodyOnly: true }); - - // markupToHtml will return the complete editable HTML, but we only - // want to update the inner HTML, so as not to break additional props that - // are added by TinyMCE on the main node. - - if (editable) { - editable.innerHTML = editableInnerHtml(result.html); - } else { - editor.insertContent(result.html); - } - - dialogApi.close(); - editor.fire('joplinChange'); - dispatchDidUpdate(editor); - }, - onClose: () => { - enableTextAreaTab(false); - }, - body: { - type: 'panel', - items: [ - { - type: 'input', - name: 'languageInput', - label: 'Language', - // Katex is a special case with special opening/closing tags - // and we don't currently handle switching the language in this case. - disabled: source.language === 'katex', - }, - { - type: 'textarea', - name: 'codeTextArea', - value: source.content, - }, - ], - }, - buttons: [ - { - type: 'submit', - text: 'OK', - }, - ], - }); - - window.requestAnimationFrame(() => { - enableTextAreaTab(true); - }); - } - editor.ui.registry.addButton('joplinAttach', { tooltip: _('Attach file'), icon: 'paperclip', @@ -696,7 +575,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { tooltip: _('Code Block'), icon: 'code-sample', onAction: async function() { - openEditDialog(null); + openEditDialog(editor, markupToHtml, dispatchDidUpdate, null); }, }); @@ -738,12 +617,10 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { editor.addShortcut('Meta+Shift+8', '', () => editor.execCommand('InsertUnorderedList')); editor.addShortcut('Meta+Shift+9', '', () => editor.execCommand('InsertJoplinChecklist')); - // setupContextMenu(editor); - // TODO: remove event on unmount? editor.on('DblClick', (event: any) => { const editable = findEditableContainer(event.target); - if (editable) openEditDialog(editable); + if (editable) openEditDialog(editor, markupToHtml, dispatchDidUpdate, editable); }); // This is triggered when an external file is dropped on the editor diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.ts new file mode 100644 index 000000000..6b1bf604b --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.ts @@ -0,0 +1,140 @@ +import { _ } from '@joplin/lib/locale'; +import { MarkupToHtml } from '@joplin/renderer'; +const taboverride = require('taboverride'); + +interface SourceInfo { + openCharacters: string; + closeCharacters: string; + content: string; + node: any; + language: string; +} + +function dialogTextArea_keyDown(event: any) { + if (event.key === 'Tab') { + window.requestAnimationFrame(() => event.target.focus()); + } +} + +// Allows pressing tab in a textarea to input an actual tab (instead of changing focus) +// taboverride will take care of actually inserting the tab character, while the keydown +// event listener will override the default behaviour, which is to focus the next field. +function enableTextAreaTab(enable: boolean) { + const textAreas = document.getElementsByClassName('tox-textarea'); + for (const textArea of textAreas) { + taboverride.set(textArea, enable); + + if (enable) { + textArea.addEventListener('keydown', dialogTextArea_keyDown); + } else { + textArea.removeEventListener('keydown', dialogTextArea_keyDown); + } + } +} + +function findBlockSource(node: any): SourceInfo { + const sources = node.getElementsByClassName('joplin-source'); + if (!sources.length) throw new Error('No source for node'); + const source = sources[0]; + + return { + openCharacters: source.getAttribute('data-joplin-source-open'), + closeCharacters: source.getAttribute('data-joplin-source-close'), + content: source.textContent, + node: source, + language: source.getAttribute('data-joplin-language') || '', + }; +} + +function newBlockSource(language: string = '', content: string = '', previousSource: SourceInfo = null): SourceInfo { + let fence = '```'; + + if (language === 'katex') { + if (previousSource && previousSource.openCharacters === '$') { + fence = '$'; + } else { + fence = '$$'; + } + } + + const fenceLanguage = language === 'katex' ? '' : language; + + return { + openCharacters: fence === '$' ? '$' : `\n${fence}${fenceLanguage}\n`, + closeCharacters: fence === '$' ? '$' : `\n${fence}\n`, + content: content, + node: null, + language: language, + }; +} + +function editableInnerHtml(html: string): string { + const temp = document.createElement('div'); + temp.innerHTML = html; + const editable = temp.getElementsByClassName('joplin-editable'); + if (!editable.length) throw new Error(`Invalid joplin-editable: ${html}`); + return editable[0].innerHTML; +} + +export default function openEditDialog(editor: any, markupToHtml: any, dispatchDidUpdate: Function, editable: any) { + const source = editable ? findBlockSource(editable) : newBlockSource(); + + editor.windowManager.open({ + title: _('Edit'), + size: 'large', + initialData: { + codeTextArea: source.content, + languageInput: source.language, + }, + onSubmit: async (dialogApi: any) => { + const newSource = newBlockSource(dialogApi.getData().languageInput, dialogApi.getData().codeTextArea, source); + const md = `${newSource.openCharacters}${newSource.content.trim()}${newSource.closeCharacters}`; + const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, md, { bodyOnly: true }); + + // markupToHtml will return the complete editable HTML, but we only + // want to update the inner HTML, so as not to break additional props that + // are added by TinyMCE on the main node. + + if (editable) { + editable.innerHTML = editableInnerHtml(result.html); + } else { + editor.insertContent(result.html); + } + + dialogApi.close(); + editor.fire('joplinChange'); + dispatchDidUpdate(editor); + }, + onClose: () => { + enableTextAreaTab(false); + }, + body: { + type: 'panel', + items: [ + { + type: 'input', + name: 'languageInput', + label: 'Language', + // Katex is a special case with special opening/closing tags + // and we don't currently handle switching the language in this case. + disabled: source.language === 'katex', + }, + { + type: 'textarea', + name: 'codeTextArea', + value: source.content, + }, + ], + }, + buttons: [ + { + type: 'submit', + text: 'OK', + }, + ], + }); + + window.requestAnimationFrame(() => { + enableTextAreaTab(true); + }); +}