Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { contentModelToDom, createModelToDomContext } from 'roosterjs-content-model-dom';
import { convertMarkdownToContentModel } from 'roosterjs-content-model-markdown';
import type { ClipboardData, IEditor } from 'roosterjs-content-model-types';

// Tags that are considered "thin wrappers", which only add structure (such as line breaks)
// around the plain text without applying any real formatting to its content.
const ThinWrapperTags = new Set<string>(['DIV', 'P', 'BR', 'SPAN']);

/**
* @internal
* Detect whether the pasted content is plain text, or HTML that is only a thin wrapper of
* plain text (for example, each line wrapped in a DIV or P with no other formatting). When
* this is the case we can safely interpret the plain text as markdown.
* @param clipboardData The clipboard data of the paste
* @param fragment The parsed HTML fragment to be pasted
*/
export function shouldConvertPastedTextToMarkdown(
clipboardData: ClipboardData,
fragment: DocumentFragment
): boolean {
const { text, rawHtml } = clipboardData;

// There must be some plain text to interpret as markdown
if (!text || !text.trim()) {
return false;
}

// No HTML content at all (text/plain only), so the plain text is all we have
if (!rawHtml) {
return true;
}

// There is HTML content, only continue when it is a thin wrapper of the plain text
return isThinWrapperOfPlainText(fragment, text);
}

/**
* @internal
* Interpret the given plain text as markdown, convert it to a DOM tree and replace the
* content of the given fragment with the result, so that we paste formatted HTML instead
* of the original markdown text.
* @param editor The editor instance
* @param fragment The fragment whose content will be replaced
* @param text The plain text (markdown) to convert
*/
export function convertPastedTextToMarkdown(
editor: IEditor,
fragment: DocumentFragment,
text: string
) {
const model = convertMarkdownToContentModel(text);

while (fragment.firstChild) {
fragment.removeChild(fragment.firstChild);
}

contentModelToDom(editor.getDocument(), fragment, model, createModelToDomContext());
}

function isThinWrapperOfPlainText(fragment: DocumentFragment, text: string): boolean {
const elements = fragment.querySelectorAll('*');

for (let i = 0; i < elements.length; i++) {
const element = elements[i];

// Any element that is not a structural wrapper, or that carries its own attributes
// (style, class, etc.), means the HTML adds real formatting on top of the text.
if (!ThinWrapperTags.has(element.tagName) || element.attributes.length > 0) {
return false;
}
}

// Make sure the HTML and the plain text actually represent the same content
return removeWhitespace(fragment.textContent || '') === removeWhitespace(text);
}

function removeWhitespace(text: string): string {
return text.replace(/\s/g, '');
}
17 changes: 17 additions & 0 deletions packages/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { addParser } from './utils/addParser';
import { blockElementParser } from './parsers/blockElementParser';
import { chainSanitizerCallback } from './utils/chainSanitizerCallback';
import {
convertPastedTextToMarkdown,
shouldConvertPastedTextToMarkdown,
} from './Markdown/convertPastedTextToMarkdown';
import { DefaultSanitizers } from './DefaultSanitizers';
import { deprecatedBorderColorParser } from './parsers/deprecatedColorParser';
import { getDocumentSource } from './pasteSourceValidations/getDocumentSource';
Expand Down Expand Up @@ -144,6 +148,19 @@ export class PastePlugin implements EditorPlugin {
case 'oneNoteDesktop':
processPastedContentFromOneNote(event);
break;

case 'default':
// When the pasted content is plain text (or HTML that is just a thin
// wrapper of plain text), interpret it as markdown and replace the paste
// fragment with the converted formatted HTML, so we paste formatted content
// by default instead of the raw markdown text.
if (
pasteType === 'normal' &&
shouldConvertPastedTextToMarkdown(clipboardData, fragment)
) {
convertPastedTextToMarkdown(this.editor, fragment, clipboardData.text);
}
break;
}

addParser(event.domToModelOption, 'link', parseLink);
Expand Down
1 change: 1 addition & 0 deletions packages/roosterjs-content-model-plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"tslib": "^2.3.1",
"roosterjs-content-model-core": "",
"roosterjs-content-model-dom": "",
"roosterjs-content-model-markdown": "",
"roosterjs-content-model-types": "",
"roosterjs-content-model-api": ""
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { IEditor } from 'roosterjs-content-model-types';
import {
convertPastedTextToMarkdown,
shouldConvertPastedTextToMarkdown,
} from '../../../lib/paste/Markdown/convertPastedTextToMarkdown';

function createFragment(html: string): DocumentFragment {
const template = document.createElement('template');
template.innerHTML = html;
return template.content;
}

describe('shouldConvertPastedTextToMarkdown', () => {
it('returns false when there is no plain text', () => {
const result = shouldConvertPastedTextToMarkdown(
<any>{ text: '', rawHtml: null },
createFragment('')
);

expect(result).toBeFalse();
});

it('returns false when the plain text is only whitespace', () => {
const result = shouldConvertPastedTextToMarkdown(
<any>{ text: ' \n ', rawHtml: null },
createFragment('')
);

expect(result).toBeFalse();
});

it('returns true when there is plain text and no HTML (rawHtml is null)', () => {
const result = shouldConvertPastedTextToMarkdown(
<any>{ text: '# Heading', rawHtml: null },
createFragment('')
);

expect(result).toBeTrue();
});

it('returns true when there is plain text and rawHtml is undefined', () => {
const result = shouldConvertPastedTextToMarkdown(
<any>{ text: '# Heading', rawHtml: undefined },
createFragment('')
);

expect(result).toBeTrue();
});

it('returns true when the HTML is a thin wrapper of the plain text', () => {
const text = '# Heading\n- item 1\n- item 2';
const result = shouldConvertPastedTextToMarkdown(
<any>{ text, rawHtml: '<div># Heading</div><div>- item 1</div><div>- item 2</div>' },
createFragment('<div># Heading</div><div>- item 1</div><div>- item 2</div>')
);

expect(result).toBeTrue();
});

it('returns true when the HTML uses P and BR as thin wrappers', () => {
const text = 'line 1\nline 2';
const result = shouldConvertPastedTextToMarkdown(
<any>{ text, rawHtml: '<p>line 1<br>line 2</p>' },
createFragment('<p>line 1<br>line 2</p>')
);

expect(result).toBeTrue();
});

it('returns false when the HTML contains a formatting element', () => {
const text = 'hello world';
const result = shouldConvertPastedTextToMarkdown(
<any>{ text, rawHtml: '<div>hello <b>world</b></div>' },
createFragment('<div>hello <b>world</b></div>')
);

expect(result).toBeFalse();
});

it('returns false when a thin wrapper element carries attributes', () => {
const text = 'hello world';
const result = shouldConvertPastedTextToMarkdown(
<any>{ text, rawHtml: '<div style="font-weight:bold">hello world</div>' },
createFragment('<div style="font-weight:bold">hello world</div>')
);

expect(result).toBeFalse();
});

it('returns false when the HTML text does not match the plain text', () => {
const result = shouldConvertPastedTextToMarkdown(
<any>{ text: 'hello world', rawHtml: '<div>different content</div>' },
createFragment('<div>different content</div>')
);

expect(result).toBeFalse();
});

it('returns false when the HTML contains a link', () => {
const text = 'see roosterjs';
const result = shouldConvertPastedTextToMarkdown(
<any>{ text, rawHtml: '<div>see <a href="https://x">roosterjs</a></div>' },
createFragment('<div>see <a href="https://x">roosterjs</a></div>')
);

expect(result).toBeFalse();
});
});

describe('convertPastedTextToMarkdown', () => {
let editor: IEditor;

beforeEach(() => {
editor = (<any>{
getDocument: () => document,
}) as IEditor;
});

it('converts a markdown heading into an HTML heading', () => {
const fragment = createFragment('<div># Heading</div>');

convertPastedTextToMarkdown(editor, fragment, '# Heading');

const div = document.createElement('div');
div.appendChild(fragment.cloneNode(true));

expect(div.querySelector('h1')).not.toBeNull();
expect(div.textContent).toBe('Heading');
});

it('converts a markdown unordered list into list items', () => {
const fragment = createFragment('');

convertPastedTextToMarkdown(editor, fragment, '- item 1\n- item 2');

const div = document.createElement('div');
div.appendChild(fragment.cloneNode(true));

const listItems = div.querySelectorAll('li');
expect(listItems.length).toBe(2);
expect(listItems[0].textContent).toBe('item 1');
expect(listItems[1].textContent).toBe('item 2');
});

it('clears the existing content of the fragment before conversion', () => {
const fragment = createFragment('<div>old content</div>');

convertPastedTextToMarkdown(editor, fragment, 'new content');

const div = document.createElement('div');
div.appendChild(fragment.cloneNode(true));

expect(div.textContent).toBe('new content');
expect(div.textContent).not.toContain('old');
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as addParser from '../../../lib/paste/utils/addParser';
import * as ExcelFile from '../../../lib/paste/Excel/processPastedContentFromExcel';
import * as getDocumentSource from '../../../lib/paste/pasteSourceValidations/getDocumentSource';
import * as MarkdownFile from '../../../lib/paste/Markdown/convertPastedTextToMarkdown';
import * as oneNoteFile from '../../../lib/paste/oneNote/processPastedContentFromOneNote';
import * as PowerPointFile from '../../../lib/paste/PowerPoint/processPastedContentFromPowerPoint';
import * as setProcessor from '../../../lib/paste/utils/setProcessor';
Expand All @@ -24,6 +25,7 @@ describe('Content Model Paste Plugin Test', () => {
getTrustedHTMLHandler: () => trustedHTMLHandler,
getDOMCreator: () => domCreator,
getEnvironment: () => ({}),
getDocument: () => document,
} as any) as IEditor;
spyOn(addParser, 'addParser').and.callThrough();
spyOn(setProcessor, 'setProcessor').and.callThrough();
Expand Down Expand Up @@ -174,6 +176,60 @@ describe('Content Model Paste Plugin Test', () => {
expect(Object.keys(event.domToModelOption.styleSanitizers).length).toEqual(4);
});

it('Default | plain text is converted to markdown HTML', () => {
spyOn(getDocumentSource, 'getDocumentSource').and.returnValue('default');
spyOn(MarkdownFile, 'convertPastedTextToMarkdown').and.callThrough();

(<any>event).clipboardData = {
text: '# Heading',
rawHtml: null,
types: [],
};

plugin.initialize(editor);
plugin.onPluginEvent(event);

expect(MarkdownFile.convertPastedTextToMarkdown).toHaveBeenCalledWith(
editor,
event.fragment,
'# Heading'
);
});

it('Default | formatted HTML is not converted to markdown HTML', () => {
spyOn(getDocumentSource, 'getDocumentSource').and.returnValue('default');
spyOn(MarkdownFile, 'convertPastedTextToMarkdown').and.callThrough();

(<any>event).clipboardData = {
text: 'hello world',
rawHtml: '<div>hello <b>world</b></div>',
types: [],
};
event.fragment.appendChild(domCreator.htmlToDOM('<div>hello <b>world</b></div>').body);

plugin.initialize(editor);
plugin.onPluginEvent(event);

expect(MarkdownFile.convertPastedTextToMarkdown).not.toHaveBeenCalled();
});

it('Default | plain text is not converted when pasting as plain text', () => {
spyOn(getDocumentSource, 'getDocumentSource').and.returnValue('default');
spyOn(MarkdownFile, 'convertPastedTextToMarkdown').and.callThrough();

(<any>event).pasteType = 'asPlainText';
(<any>event).clipboardData = {
text: '# Heading',
rawHtml: null,
types: [],
};

plugin.initialize(editor);
plugin.onPluginEvent(event);

expect(MarkdownFile.convertPastedTextToMarkdown).not.toHaveBeenCalled();
});

it('excelNonNativeEvent', () => {
spyOn(getDocumentSource, 'getDocumentSource').and.returnValue('excelNonNativeEvent');
spyOn(ExcelFile, 'processPastedContentFromExcel').and.callThrough();
Expand Down
Loading