diff --git a/.eslintignore b/.eslintignore index 26a61a9d118..9d250f13db8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -953,6 +953,9 @@ packages/lib/fs-driver-node.js.map packages/lib/htmlUtils.d.ts packages/lib/htmlUtils.js packages/lib/htmlUtils.js.map +packages/lib/htmlUtils.test.d.ts +packages/lib/htmlUtils.test.js +packages/lib/htmlUtils.test.js.map packages/lib/import-enex-md-gen.d.ts packages/lib/import-enex-md-gen.js packages/lib/import-enex-md-gen.js.map diff --git a/.gitignore b/.gitignore index a76bc33294d..2fa4489542a 100644 --- a/.gitignore +++ b/.gitignore @@ -939,6 +939,9 @@ packages/lib/fs-driver-node.js.map packages/lib/htmlUtils.d.ts packages/lib/htmlUtils.js packages/lib/htmlUtils.js.map +packages/lib/htmlUtils.test.d.ts +packages/lib/htmlUtils.test.js +packages/lib/htmlUtils.test.js.map packages/lib/import-enex-md-gen.d.ts packages/lib/import-enex-md-gen.js packages/lib/import-enex-md-gen.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 3294cf70c32..03793976245 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx @@ -20,6 +20,7 @@ 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'; const { themeStyle } = require('@joplin/lib/theme'); const { clipboard } = require('electron'); const supportedLocales = require('./supportedLocales'); @@ -1037,6 +1038,10 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { } async function onPaste(event: any) { + // We do not use the default pasting behaviour because the input has + // to be processed in various ways. + event.preventDefault(); + const resourceMds = await handlePasteEvent(event); if (resourceMds.length) { const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resourceMds.join('\n'), markupRenderOptions({ bodyOnly: true })); @@ -1045,23 +1050,25 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { const pastedText = event.clipboardData.getData('text/plain'); if (BaseItem.isMarkdownTag(pastedText)) { // Paste a link to a note - event.preventDefault(); const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, pastedText, markupRenderOptions({ bodyOnly: true })); editor.insertContent(result.html); } else { // Paste regular text - // HACK: TinyMCE doesn't add an undo step when pasting, for unclear reasons - // so we manually add it here. We also can't do it immediately it seems, or - // else nothing is added to the stack, so do it on the next frame. - const pastedHtml = event.clipboardData.getData('text/html'); - if (pastedHtml) { - event.preventDefault(); + if (pastedHtml) { // Handles HTML const modifiedHtml = await processPastedHtml(pastedHtml); editor.insertContent(modifiedHtml); + } else { // Handles plain text + pasteAsPlainText(pastedText); } - window.requestAnimationFrame(() => editor.undoManager.add()); - onChangeHandler(); + // This code before was necessary to get undo working after + // pasting but it seems it's no longer necessary, so + // removing it for now. We also couldn't do it immediately + // it seems, or else nothing is added to the stack, so do it + // on the next frame. + // + // window.requestAnimationFrame(() => + // editor.undoManager.add()); onChangeHandler(); } } } @@ -1080,6 +1087,13 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { onChangeHandler(); } + function pasteAsPlainText(text: string = null) { + const pastedText = text === null ? clipboard.readText() : text; + if (pastedText) { + editor.insertContent(plainTextToHtml(pastedText)); + } + } + function onKeyDown(event: any) { // It seems "paste as text" is handled automatically by // on Windows so the code below so we need to run the below @@ -1092,8 +1106,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { // it here and we don't need to do anything special in onPaste if (!shim.isWindows()) { if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.code === 'KeyV') { - const pastedText = clipboard.readText(); - if (pastedText) editor.insertContent(pastedText); + pasteAsPlainText(); } } } diff --git a/packages/app-desktop/gui/NoteEditor/utils/resourceHandling.ts b/packages/app-desktop/gui/NoteEditor/utils/resourceHandling.ts index a0ab30c9e2f..55ce27fd0ed 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/resourceHandling.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/resourceHandling.ts @@ -135,6 +135,13 @@ export async function processPastedHtml(html: string) { const allImageUrls: string[] = []; const mappedResources: Record = {}; + // When copying text from eg. GitHub, the HTML might contain non-breaking + // spaces instead of regular spaces. If these non-breaking spaces are + // inserted into the TinyMCE editor (using insertContent), they will be + // dropped. So here we convert them to regular spaces. + // https://stackoverflow.com/a/31790544/561309 + html = html.replace(/[\u202F\u00A0]/g, ' '); + htmlUtils.replaceImageUrls(html, (src: string) => { allImageUrls.push(src); }); diff --git a/packages/lib/htmlUtils.test.ts b/packages/lib/htmlUtils.test.ts new file mode 100644 index 00000000000..5002bc9d0a2 --- /dev/null +++ b/packages/lib/htmlUtils.test.ts @@ -0,0 +1,28 @@ +import { plainTextToHtml } from './htmlUtils'; + +describe('htmlUtils', () => { + + test('should convert a plain text string to its HTML equivalent', () => { + const testCases = [ + [ + '', + '', + ], + [ + 'line 1\nline 2', + '

line 1

line 2

', + ], + [ + '', + '<img onerror="http://downloadmalware.com"/>', + ], + ]; + + for (const t of testCases) { + const [input, expected] = t; + const actual = plainTextToHtml(input); + expect(actual).toBe(expected); + } + }); + +}); diff --git a/packages/lib/htmlUtils.ts b/packages/lib/htmlUtils.ts index 3489001e3f7..4aa71fc3a64 100644 --- a/packages/lib/htmlUtils.ts +++ b/packages/lib/htmlUtils.ts @@ -2,6 +2,7 @@ const urlUtils = require('./urlUtils.js'); const Entities = require('html-entities').AllHtmlEntities; const htmlentities = new Entities().encode; const htmlparser2 = require('@joplin/fork-htmlparser2'); +const { escapeHtml } = require('./string-utils.js'); // [\s\S] instead of . for multiline matching // https://stackoverflow.com/a/16119722/561309 @@ -153,3 +154,16 @@ class HtmlUtils { } export default new HtmlUtils(); + +export function plainTextToHtml(plainText: string): string { + const lines = plainText + .replace(/[\n\r]/g, '\n') + .split('\n'); + + const lineOpenTag = lines.length > 1 ? '

' : ''; + const lineCloseTag = lines.length > 1 ? '

' : ''; + + return lines + .map(line => lineOpenTag + escapeHtml(line) + lineCloseTag) + .join(''); +}