1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-24 20:19:10 +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
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 { CommandValue } from '../../utils/types';
import { usePrevious, cursorPositionToTextOffset } from './utils';
@@ -268,7 +268,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
}, [props.content, props.visiblePanes, addListItem, wrapSelectionWithStrings, setEditorPercentScroll, setViewerPercentScroll, resetScroll]);
const onEditorPaste = useCallback(async (event: any = null) => {
const resourceMds = await handlePasteEvent(event);
const resourceMds = await getResourcesFromPasteEvent(event);
if (!resourceMds.length) return;
if (editorRef.current) {
editorRef.current.replaceSelection(resourceMds.join('\n'));

View File

@@ -1,7 +1,7 @@
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 { resourcesStatus, commandAttachFileToBody, getResourcesFromPasteEvent, processPastedHtml, attachedResources } from '../../utils/resourceHandling';
import useScroll from './utils/useScroll';
import styles_ from './styles';
import CommandService from '@joplin/lib/services/CommandService';
@@ -1064,38 +1064,43 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
// 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 }));
editor.insertContent(result.html);
} else {
const pastedText = event.clipboardData.getData('text/plain');
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) {
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resourceMds.join('\n'), markupRenderOptions({ bodyOnly: true }));
editor.insertContent(result.html);
}
} else {
if (BaseItem.isMarkdownTag(pastedText)) { // Paste a link to a note
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, pastedText, markupRenderOptions({ bodyOnly: true }));
editor.insertContent(result.html);
} 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
const modifiedHtml = await processPastedHtml(pastedHtml);
editor.insertContent(modifiedHtml);
} else { // Handles plain text
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;
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
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';
const { fileUriToPath } = require('@joplin/lib/urlUtils');
const joplinRendererUtils = require('@joplin/renderer').utils;
@@ -107,7 +107,7 @@ export function resourcesStatus(resourceInfos: any) {
return joplinRendererUtils.resourceStatusName(lowestIndex);
}
export async function handlePasteEvent(event: any) {
export async function getResourcesFromPasteEvent(event: any) {
const output = [];
const formats = clipboard.availableFormats();
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) => {
return mappedResources[src];
})
);
));
}

View File

@@ -1,11 +1,11 @@
import { RuleOptions } from '../../MdToHtml';
import htmlUtils from '../../htmlUtils';
import { attributesHtml } from '../../htmlUtils';
import utils from '../../utils';
function renderImageHtml(before: string, src: string, after: string, ruleOptions: RuleOptions) {
const r = utils.imageReplacement(ruleOptions.ResourceModel, src, ruleOptions.resources, ruleOptions.resourceBaseUrl, ruleOptions.itemIdToUrl);
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}]`;
}

View File

@@ -1,5 +1,5 @@
import { RuleOptions } from '../../MdToHtml';
import htmlUtils from '../../htmlUtils';
import { attributesHtml } from '../../htmlUtils';
import utils from '../../utils';
import createEventHandlingAttrs from '../createEventHandlingAttrs';
@@ -25,7 +25,7 @@ function plugin(markdownIt: any, ruleOptions: RuleOptions) {
postMessageSyntax: ruleOptions.postMessageSyntax ?? 'void',
}, 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);
};

View File

@@ -1,4 +1,4 @@
import htmlUtils from './htmlUtils';
import htmlUtils, { extractHtmlBody } from './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,24 +34,28 @@ interface SanitizeHtmlOptions {
addNoMdConvClass: boolean;
}
class HtmlUtils {
export const attributesHtml = (attr: Record<string, string>) => {
const output = [];
public attributesHtml(attr: Record<string, string>) {
const output = [];
for (const n in attr) {
if (!attr.hasOwnProperty(n)) continue;
for (const n in attr) {
if (!attr.hasOwnProperty(n)) continue;
if (!attr[n]) {
output.push(n);
} else {
output.push(`${n}="${htmlentities(attr[n])}"`);
}
if (!attr[n]) {
output.push(n);
} else {
output.push(`${n}="${htmlentities(attr[n])}"`);
}
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
public processImageTags(html: string, callback: Function) {
if (!html) return '';
@@ -70,7 +74,7 @@ class HtmlUtils {
}
if (action.type === 'setAttributes') {
const attrHtml = this.attributesHtml(action.attrs);
const attrHtml = attributesHtml(action.attrs);
return `<img${before}${attrHtml}${after}>`;
}
@@ -103,7 +107,7 @@ class HtmlUtils {
}
if (action.type === 'setAttributes') {
const attrHtml = this.attributesHtml(action.attrs);
const attrHtml = attributesHtml(action.attrs);
return `<img${before}${attrHtml}${after}>`;
}
@@ -111,10 +115,6 @@ class HtmlUtils {
});
}
public isSelfClosingTag(tagName: string) {
return selfClosingElements.includes(tagName.toLowerCase());
}
public stripHtml(html: string) {
const output: string[] = [];
@@ -274,9 +274,9 @@ class HtmlUtils {
attrs['href'] = '#';
}
let attrHtml = this.attributesHtml(attrs);
let attrHtml = attributesHtml(attrs);
if (attrHtml) attrHtml = ` ${attrHtml}`;
const closingSign = this.isSelfClosingTag(name) ? '/>' : '>';
const closingSign = isSelfClosingTag(name) ? '/>' : '>';
output.push(`<${name}${attrHtml}${closingSign}`);
},
@@ -319,7 +319,7 @@ class HtmlUtils {
if (disallowedTagDepth) return;
if (this.isSelfClosingTag(name)) return;
if (isSelfClosingTag(name)) return;
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();

View File

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