1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-08 23:07:32 +02:00

Compare commits

...

1 Commits

Author SHA1 Message Date
Laurent Cozic
377d02e19d paste from office 2023-08-21 17:02:44 +01:00
8 changed files with 135 additions and 59 deletions

View File

@@ -3,7 +3,7 @@ import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHand
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
import { EditorCommand, NoteBodyEditorProps } from '../../utils/types'; import { EditorCommand, NoteBodyEditorProps } from '../../utils/types';
import { commandAttachFileToBody, handlePasteEvent } from '../../utils/resourceHandling'; import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../utils/resourceHandling';
import { ScrollOptions, ScrollOptionTypes } from '../../utils/types'; import { ScrollOptions, ScrollOptionTypes } from '../../utils/types';
import { CommandValue } from '../../utils/types'; import { CommandValue } from '../../utils/types';
import { usePrevious, cursorPositionToTextOffset } from './utils'; import { usePrevious, cursorPositionToTextOffset } from './utils';
@@ -268,7 +268,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
}, [props.content, props.visiblePanes, addListItem, wrapSelectionWithStrings, setEditorPercentScroll, setViewerPercentScroll, resetScroll]); }, [props.content, props.visiblePanes, addListItem, wrapSelectionWithStrings, setEditorPercentScroll, setViewerPercentScroll, resetScroll]);
const onEditorPaste = useCallback(async (event: any = null) => { const onEditorPaste = useCallback(async (event: any = null) => {
const resourceMds = await handlePasteEvent(event); const resourceMds = await getResourcesFromPasteEvent(event);
if (!resourceMds.length) return; if (!resourceMds.length) return;
if (editorRef.current) { if (editorRef.current) {
editorRef.current.replaceSelection(resourceMds.join('\n')); editorRef.current.replaceSelection(resourceMds.join('\n'));

View File

@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react'; import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
import { ScrollOptions, ScrollOptionTypes, EditorCommand, NoteBodyEditorProps, ResourceInfos } from '../../utils/types'; import { ScrollOptions, ScrollOptionTypes, EditorCommand, NoteBodyEditorProps, ResourceInfos } from '../../utils/types';
import { resourcesStatus, commandAttachFileToBody, handlePasteEvent, processPastedHtml, attachedResources } from '../../utils/resourceHandling'; import { resourcesStatus, commandAttachFileToBody, getResourcesFromPasteEvent, processPastedHtml, attachedResources } from '../../utils/resourceHandling';
import useScroll from './utils/useScroll'; import useScroll from './utils/useScroll';
import styles_ from './styles'; import styles_ from './styles';
import CommandService from '@joplin/lib/services/CommandService'; import CommandService from '@joplin/lib/services/CommandService';
@@ -1064,38 +1064,43 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
// to be processed in various ways. // to be processed in various ways.
event.preventDefault(); event.preventDefault();
const resourceMds = await handlePasteEvent(event); const pastedText = event.clipboardData.getData('text/plain');
// 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() : '';
// We should only process the images if there is no plain text or
// HTML text in the clipboard. This is because certain applications,
// such as Word, are going to add multiple versions of the copied
// data to the clipboard - one with the text formatted as HTML, and
// one with the text as an image. In that case, we need to ignore
// the image and only process the HTML.
if (!pastedText && !pastedHtml) {
const resourceMds = await getResourcesFromPasteEvent(event);
if (resourceMds.length) { if (resourceMds.length) {
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resourceMds.join('\n'), markupRenderOptions({ bodyOnly: true })); const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resourceMds.join('\n'), markupRenderOptions({ bodyOnly: true }));
editor.insertContent(result.html); editor.insertContent(result.html);
}
} else { } else {
const pastedText = event.clipboardData.getData('text/plain');
if (BaseItem.isMarkdownTag(pastedText)) { // Paste a link to a note if (BaseItem.isMarkdownTag(pastedText)) { // Paste a link to a note
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, pastedText, markupRenderOptions({ bodyOnly: true })); const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, pastedText, markupRenderOptions({ bodyOnly: true }));
editor.insertContent(result.html); editor.insertContent(result.html);
} else { // Paste regular text } else { // Paste regular text
// 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() : '';
if (pastedHtml) { // Handles HTML if (pastedHtml) { // Handles HTML
const modifiedHtml = await processPastedHtml(pastedHtml); const modifiedHtml = await processPastedHtml(pastedHtml);
editor.insertContent(modifiedHtml); editor.insertContent(modifiedHtml);
} else { // Handles plain text } else { // Handles plain text
pasteAsPlainText(pastedText); pasteAsPlainText(pastedText);
} }
// 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();
} }
} }
} }

View File

@@ -6,7 +6,7 @@ import Resource from '@joplin/lib/models/Resource';
const bridge = require('@electron/remote').require('./bridge').default; const bridge = require('@electron/remote').require('./bridge').default;
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher'; import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
import htmlUtils from '@joplin/lib/htmlUtils'; import htmlUtils from '@joplin/lib/htmlUtils';
import rendererHtmlUtils from '@joplin/renderer/htmlUtils'; import rendererHtmlUtils, { extractHtmlBody } from '@joplin/renderer/htmlUtils';
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
const { fileUriToPath } = require('@joplin/lib/urlUtils'); const { fileUriToPath } = require('@joplin/lib/urlUtils');
const joplinRendererUtils = require('@joplin/renderer').utils; const joplinRendererUtils = require('@joplin/renderer').utils;
@@ -107,7 +107,7 @@ export function resourcesStatus(resourceInfos: any) {
return joplinRendererUtils.resourceStatusName(lowestIndex); return joplinRendererUtils.resourceStatusName(lowestIndex);
} }
export async function handlePasteEvent(event: any) { export async function getResourcesFromPasteEvent(event: any) {
const output = []; const output = [];
const formats = clipboard.availableFormats(); const formats = clipboard.availableFormats();
for (let i = 0; i < formats.length; i++) { for (let i = 0; i < formats.length; i++) {
@@ -176,9 +176,9 @@ export async function processPastedHtml(html: string) {
} }
} }
return rendererHtmlUtils.sanitizeHtml( return extractHtmlBody(rendererHtmlUtils.sanitizeHtml(
htmlUtils.replaceImageUrls(html, (src: string) => { htmlUtils.replaceImageUrls(html, (src: string) => {
return mappedResources[src]; return mappedResources[src];
}) })
); ));
} }

View File

@@ -1,11 +1,11 @@
import { RuleOptions } from '../../MdToHtml'; import { RuleOptions } from '../../MdToHtml';
import htmlUtils from '../../htmlUtils'; import { attributesHtml } from '../../htmlUtils';
import utils from '../../utils'; import utils from '../../utils';
function renderImageHtml(before: string, src: string, after: string, ruleOptions: RuleOptions) { function renderImageHtml(before: string, src: string, after: string, ruleOptions: RuleOptions) {
const r = utils.imageReplacement(ruleOptions.ResourceModel, src, ruleOptions.resources, ruleOptions.resourceBaseUrl, ruleOptions.itemIdToUrl); const r = utils.imageReplacement(ruleOptions.ResourceModel, src, ruleOptions.resources, ruleOptions.resourceBaseUrl, ruleOptions.itemIdToUrl);
if (typeof r === 'string') return r; if (typeof r === 'string') return r;
if (r) return `<img ${before} ${htmlUtils.attributesHtml(r)} ${after}/>`; if (r) return `<img ${before} ${attributesHtml(r)} ${after}/>`;
return `[Image: ${src}]`; return `[Image: ${src}]`;
} }

View File

@@ -1,5 +1,5 @@
import { RuleOptions } from '../../MdToHtml'; import { RuleOptions } from '../../MdToHtml';
import htmlUtils from '../../htmlUtils'; import { attributesHtml } from '../../htmlUtils';
import utils from '../../utils'; import utils from '../../utils';
import createEventHandlingAttrs from '../createEventHandlingAttrs'; import createEventHandlingAttrs from '../createEventHandlingAttrs';
@@ -25,7 +25,7 @@ function plugin(markdownIt: any, ruleOptions: RuleOptions) {
postMessageSyntax: ruleOptions.postMessageSyntax ?? 'void', postMessageSyntax: ruleOptions.postMessageSyntax ?? 'void',
}, null); }, null);
return `<img data-from-md ${htmlUtils.attributesHtml({ ...r, title: title, alt: token.content })} ${js}/>`; return `<img data-from-md ${attributesHtml({ ...r, title: title, alt: token.content })} ${js}/>`;
} }
return defaultRender(tokens, idx, options, env, self); return defaultRender(tokens, idx, options, env, self);
}; };

View File

@@ -1,4 +1,4 @@
import htmlUtils from './htmlUtils'; import htmlUtils, { extractHtmlBody } from './htmlUtils';
describe('htmlUtils', () => { describe('htmlUtils', () => {
@@ -29,4 +29,26 @@ describe('htmlUtils', () => {
} }
}); });
test('should extract the HTML body', () => {
const testCases: [string, string][] = [
[
'Just <b>testing</b>',
'Just <b>testing</b>',
],
[
'',
'',
],
[
'<html><head></head><meta bla><body>Here is the body<img src="test.png"/></body></html>',
'Here is the body<img src="test.png"/>',
],
];
for (const [input, expected] of testCases) {
const actual = extractHtmlBody(input);
expect(actual).toBe(expected);
}
});
}); });

View File

@@ -34,9 +34,7 @@ interface SanitizeHtmlOptions {
addNoMdConvClass: boolean; addNoMdConvClass: boolean;
} }
class HtmlUtils { export const attributesHtml = (attr: Record<string, string>) => {
public attributesHtml(attr: Record<string, string>) {
const output = []; const output = [];
for (const n in attr) { for (const n in attr) {
@@ -50,7 +48,13 @@ class HtmlUtils {
} }
return output.join(' '); return output.join(' ');
} };
export const isSelfClosingTag = (tagName: string) => {
return selfClosingElements.includes(tagName.toLowerCase());
};
class HtmlUtils {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
public processImageTags(html: string, callback: Function) { public processImageTags(html: string, callback: Function) {
@@ -70,7 +74,7 @@ class HtmlUtils {
} }
if (action.type === 'setAttributes') { if (action.type === 'setAttributes') {
const attrHtml = this.attributesHtml(action.attrs); const attrHtml = attributesHtml(action.attrs);
return `<img${before}${attrHtml}${after}>`; return `<img${before}${attrHtml}${after}>`;
} }
@@ -103,7 +107,7 @@ class HtmlUtils {
} }
if (action.type === 'setAttributes') { if (action.type === 'setAttributes') {
const attrHtml = this.attributesHtml(action.attrs); const attrHtml = attributesHtml(action.attrs);
return `<img${before}${attrHtml}${after}>`; return `<img${before}${attrHtml}${after}>`;
} }
@@ -111,10 +115,6 @@ class HtmlUtils {
}); });
} }
public isSelfClosingTag(tagName: string) {
return selfClosingElements.includes(tagName.toLowerCase());
}
public stripHtml(html: string) { public stripHtml(html: string) {
const output: string[] = []; const output: string[] = [];
@@ -274,9 +274,9 @@ class HtmlUtils {
attrs['href'] = '#'; attrs['href'] = '#';
} }
let attrHtml = this.attributesHtml(attrs); let attrHtml = attributesHtml(attrs);
if (attrHtml) attrHtml = ` ${attrHtml}`; if (attrHtml) attrHtml = ` ${attrHtml}`;
const closingSign = this.isSelfClosingTag(name) ? '/>' : '>'; const closingSign = isSelfClosingTag(name) ? '/>' : '>';
output.push(`<${name}${attrHtml}${closingSign}`); output.push(`<${name}${attrHtml}${closingSign}`);
}, },
@@ -319,7 +319,7 @@ class HtmlUtils {
if (disallowedTagDepth) return; if (disallowedTagDepth) return;
if (this.isSelfClosingTag(name)) return; if (isSelfClosingTag(name)) return;
output.push(`</${name}>`); output.push(`</${name}>`);
}, },
@@ -334,4 +334,53 @@ class HtmlUtils {
} }
const makeHtmlTag = (name: string, attrs: Record<string, string>) => {
let attrHtml = attributesHtml(attrs);
if (attrHtml) attrHtml = ` ${attrHtml}`;
const closingSign = isSelfClosingTag(name) ? '/>' : '>';
return `<${name}${attrHtml}${closingSign}`;
};
// Will return either the content of the <BODY> tag if it exists, or the whole
// HTML (which would be a fragment of HTML)
export const extractHtmlBody = (html: string) => {
let inBody = false;
let bodyFound = false;
const output: string[] = [];
const parser = new htmlparser2.Parser({
onopentag: (name: string, attrs: Record<string, string>) => {
if (name === 'body') {
inBody = true;
bodyFound = true;
return;
}
if (inBody) {
output.push(makeHtmlTag(name, attrs));
}
},
ontext: (encodedText: string) => {
if (inBody) output.push(encodedText);
},
onclosetag: (name: string) => {
if (inBody && name === 'body') inBody = false;
if (inBody) {
if (isSelfClosingTag(name)) return;
output.push(`</${name}>`);
}
},
}, { decodeEntities: false });
parser.write(html);
parser.end();
return bodyFound ? output.join('') : html;
};
export default new HtmlUtils(); export default new HtmlUtils();

View File

@@ -1,5 +1,5 @@
import { unique } from '@joplin/lib/ArrayUtils'; import { unique } from '@joplin/lib/ArrayUtils';
import htmlUtils from '@joplin/renderer/htmlUtils'; import { attributesHtml, isSelfClosingTag } from '@joplin/renderer/htmlUtils';
const Entities = require('html-entities').AllHtmlEntities; const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = new Entities().encode; const htmlentities = new Entities().encode;
const htmlparser2 = require('@joplin/fork-htmlparser2'); const htmlparser2 = require('@joplin/fork-htmlparser2');
@@ -106,9 +106,9 @@ export default (html: string, _languageCode: string, translations: Record<string
state.translateStack.push(name); state.translateStack.push(name);
} }
let attrHtml = htmlUtils.attributesHtml(attrs); let attrHtml = attributesHtml(attrs);
if (attrHtml) attrHtml = ` ${attrHtml}`; if (attrHtml) attrHtml = ` ${attrHtml}`;
const closingSign = htmlUtils.isSelfClosingTag(name) ? '/>' : '>'; const closingSign = isSelfClosingTag(name) ? '/>' : '>';
pushContent(state, `<${name}${attrHtml}${closingSign}`); pushContent(state, `<${name}${attrHtml}${closingSign}`);
state.translateIsOpening = false; state.translateIsOpening = false;
@@ -133,7 +133,7 @@ export default (html: string, _languageCode: string, translations: Record<string
if (name === 'script') state.inScript = false; if (name === 'script') state.inScript = false;
if (htmlUtils.isSelfClosingTag(name)) return; if (isSelfClosingTag(name)) return;
pushContent(state, `</${name}>`); pushContent(state, `</${name}>`);
}, },