Procházet zdrojové kódy

Merge pull request #4 from ckeditor/t/1

Initial implementation of the clipboard feature
Szymon Kupś před 9 roky
rodič
revize
006c40819a

+ 144 - 0
packages/ckeditor5-clipboard/src/clipboard.js

@@ -0,0 +1,144 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Feature from '../core/feature.js';
+
+import ClipboardObserver from './clipboardobserver.js';
+
+import plainTextToHtml from './utils/plaintexttohtml.js';
+import normalizeClipboardHtml from './utils/normalizeclipboarddata.js';
+
+import HtmlDataProcessor from '../engine/dataprocessor/htmldataprocessor.js';
+
+/**
+ * The clipboard feature. Currently, it's only responsible for intercepting the `paste` event and
+ * passing the pasted content through the clipboard pipeline.
+ *
+ * ## Clipboard Pipeline
+ *
+ * The feature creates the clipboard pipeline which allows for processing clipboard content
+ * before it gets inserted into the editor. The pipeline consists of two events on which
+ * the features can listen in order to modify or totally override the default behavior.
+ *
+ * ### On {@link engine.view.Document#paste}
+ *
+ * The default action is to:
+ *
+ * 1. get HTML or plain text from the clipboard,
+ * 2. fire {@link engine.view.Document#clipboardInput} with the clipboard data parsed to
+ * a {@link engine.view.DocumentFragment view document fragment},
+ * 3. prevent the default action of the native `paste` event.
+ *
+ * This action is performed by a low priority listener, so it can be overridden by a normal one.
+ * You'd only need to do this when a deeper change in pasting behavior was needed. For example,
+ * a feature which wants to differently read data from the clipboard (the {@link clipboard.DataTransfer `DataTransfer`}).
+ * should plug a listener at this stage.
+ *
+ * ### On {@link engine.view.Document#clipboardInput}
+ *
+ * The default action is to insert the content (`data.content`, represented by a {@link engine.view.DocumentFragment})
+ * to an editor if the data is not empty.
+ *
+ * This action is performed by a low priority listener, so it can be overridden by a normal one.
+ *
+ * At this stage the pasted content can be processed by the features. E.g. a feature which wants to transform
+ * a pasted text into a link can be implemented in this way:
+ *
+ *		this.listenTo( editor.editing.view, 'clipboardInput', ( evt, data ) => {
+ *			if ( data.content.childCount == 1 && isUrlText( data.content.getChild( 0 ) ) ) {
+ *				const linkUrl = data.content.getChild( 0 ).data;
+ *
+ *				data.content = new ViewDocumentFragment( [
+ *					ViewElement(
+ *						'a',
+ *						{ href: linkUrl },
+ *						[ new ViewText( linkUrl ) ]
+ *					)
+ *				] );
+ *			}
+ *		} );
+ *
+ * @memberOf clipboard
+ * @extends core.Feature
+ */
+export default class Clipboard extends Feature {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const editingView = editor.editing.view;
+
+		/**
+		 * Data processor used to convert pasted HTML to a view structure.
+		 *
+		 * @private
+		 * @member {engine.dataProcessor.HtmlDataProcessor} clipboard.Clipboard#_htmlDataProcessor
+		 */
+		this._htmlDataProcessor = new HtmlDataProcessor();
+
+		editingView.addObserver( ClipboardObserver );
+
+		// The clipboard pipeline.
+
+		this.listenTo( editingView, 'paste', ( evt, data ) => {
+			const dataTransfer = data.dataTransfer;
+			let content = '';
+
+			if ( dataTransfer.getData( 'text/html' ) ) {
+				content = normalizeClipboardHtml( dataTransfer.getData( 'text/html' ) );
+			} else if ( dataTransfer.getData( 'text/plain' ) ) {
+				content = plainTextToHtml( dataTransfer.getData( 'text/plain' ) );
+			}
+
+			content = this._htmlDataProcessor.toView( content );
+
+			data.preventDefault();
+
+			editingView.fire( 'clipboardInput', { dataTransfer, content } );
+		}, { priority: 'low' } );
+
+		this.listenTo( editingView, 'clipboardInput', ( evt, data ) => {
+			if ( !data.content.isEmpty ) {
+				const doc = editor.document;
+
+				doc.enqueueChanges( () => {
+					this.editor.data.insert( data.content, doc.selection );
+				} );
+			}
+		}, { priority: 'low' } );
+	}
+}
+
+/**
+ * Fired with a content which comes from the clipboard (was pasted or dropped) and
+ * should be processed in order to be inserted into the editor.
+ * It's part of the {@link clipboard.Clipboard "clipboard pipeline"}.
+ *
+ * @see clipboard.ClipboardObserver
+ * @see clipboard.Clipboard
+ * @event engine.view.Document#clipboardInput
+ * @param {engine.view.observer.ClipboardInputEventData} data Event data.
+ */
+
+/**
+ * The value of the {@link engine.view.Document#clipboardInput} event.
+ *
+ * @class engine.view.observer.ClipboardInputEventData
+ */
+
+/**
+ * Data transfer instance.
+ *
+ * @readonly
+ * @member {clipboard.DataTransfer} engine.view.observer.ClipboardEventData#dataTransfer
+ */
+
+/**
+ * Content to be inserted into the editor. It can be modified by the event listeners.
+ * Read more about the clipboard pipeline in {@link clipboard.Clipboard}.
+ *
+ * @member {engine.view.DocumentFragment} engine.view.observer.ClipboardEventData#content
+ */

+ 60 - 0
packages/ckeditor5-clipboard/src/clipboardobserver.js

@@ -0,0 +1,60 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import DomEventObserver from '../engine/view/observer/domeventobserver.js';
+import DataTransfer from './datatransfer.js';
+
+/**
+ * {@link engine.view.Document#paste Paste} event observer.
+ *
+ * Note that this observer is not available by default. To make it available it needs to be added to {@link engine.view.Document}
+ * by the {@link engine.view.Document#addObserver} method.
+ *
+ * @memberOf clipboard
+ * @extends engine.view.observer.DomEventObserver
+ */
+export default class ClipboardObserver extends DomEventObserver {
+	constructor( doc ) {
+		super( doc );
+
+		this.domEventType = 'paste';
+	}
+
+	onDomEvent( domEvent ) {
+		this.fire( domEvent.type, domEvent, {
+			dataTransfer: new DataTransfer( domEvent.clipboardData )
+		} );
+	}
+}
+
+/**
+ * Fired when user pasted content into one of the editables.
+ *
+ * Introduced by {@link clipboard.ClipboardObserver}.
+ *
+ * Note that this event is not available by default. To make it available {@link clipboard.ClipboardObserver} needs to be added
+ * to {@link engine.view.Document} by the {@link engine.view.Document#addObserver} method.
+ * It's done by the {@link clipboard.Clipboard} feature. If it's not loaded, it must be done manually.
+ *
+ * @see clipboard.ClipboardObserver
+ * @event engine.view.Document#paste
+ * @param {engine.view.observer.ClipboardEventData} data Event data.
+ */
+
+/**
+ * The value of the {@link engine.view.Document#paste} event.
+ *
+ * In order to access clipboard data use {@link #dataTransfer}.
+ *
+ * @class engine.view.observer.ClipboardEventData
+ * @extends engine.view.observer.DomEventData
+ */
+
+/**
+ * Data transfer instance.
+ *
+ * @readonly
+ * @member {clipboard.DataTransfer} engine.view.observer.ClipboardEventData#dataTransfer
+ */

+ 33 - 0
packages/ckeditor5-clipboard/src/datatransfer.js

@@ -0,0 +1,33 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * Facade over the native [`DataTransfer`](https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer) object.
+ *
+ * @memberOf clipboard
+ */
+export default class DataTransfer {
+	constructor( nativeDataTransfer ) {
+		/**
+		 * The native DataTransfer object.
+		 *
+		 * @private
+		 * @member {DataTransfer} clipboard.DataTransfer#_native
+		 */
+		this._native = nativeDataTransfer;
+	}
+
+	/**
+	 * Gets data from the data transfer by its mime type.
+	 *
+	 *		dataTransfer.getData( 'text/plain' );
+	 *
+	 * @param {String} type The mime type. E.g. `text/html` or `text/plain`.
+	 * @returns {String}
+	 */
+	getData( type ) {
+		return this._native.getData( type );
+	}
+}

+ 24 - 0
packages/ckeditor5-clipboard/src/utils/normalizeclipboarddata.js

@@ -0,0 +1,24 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * Removes some popular browser quirks out of the clipboard data (HTML).
+ *
+ * @function clipboard.utils.normalizeClipboardData
+ * @param {String} data The HTML data to normalize.
+ * @returns {String} Normalized HTML.
+ */
+export default function normalizeClipboardData( data ) {
+	return data
+		.replace( /<span class="Apple-converted-space">(\s+)<\/span>/g, ( fullMatch, spaces ) => {
+			// Handle the most popular and problematic case when even a single space becomes an nbsp;.
+			// Decode those to normal spaces. Read more in https://github.com/ckeditor/ckeditor5-clipboard/issues/2.
+			if ( spaces.length == 1 ) {
+				return ' ';
+			}
+
+			return spaces;
+		} );
+}

+ 37 - 0
packages/ckeditor5-clipboard/src/utils/plaintexttohtml.js

@@ -0,0 +1,37 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * Converts plain text to its HTML-ized version.
+ *
+ * @function clipboard.utils.plainTextToHtml
+ * @param {String} text The plain text to convert.
+ * @returns {String} HTML generated from the plain text.
+ */
+export default function plainTextToHtml( text ) {
+	text = text
+		// Encode <>.
+		.replace( /</g, '&lt;' )
+		.replace( />/g, '&gt;' )
+		// Creates paragraphs for double line breaks and change single line breaks to spaces.
+		// In the future single line breaks may be converted into <br>s.
+		.replace( /\n\n/g, '</p><p>' )
+		.replace( /\n/g, ' ' )
+		// Preserve trailing spaces (only the first and last one – the rest is handled below).
+		.replace( /^\s/, '&nbsp;' )
+		.replace( /\s$/, '&nbsp;' )
+		// Preserve other subsequent spaces now.
+		.replace( /\s\s/g, ' &nbsp;' );
+
+	if ( text.indexOf( '</p><p>' ) > -1 ) {
+		// If we created paragraphs above, add the trailing ones.
+		text = `<p>${ text }</p>`;
+	}
+
+	// TODO:
+	// * What about '\nfoo' vs ' foo'?
+
+	return text;
+}

+ 162 - 0
packages/ckeditor5-clipboard/tests/clipboard.js

@@ -0,0 +1,162 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '/tests/core/_utils/virtualtesteditor.js';
+import Clipboard from '/ckeditor5/clipboard/clipboard.js';
+import ClipboardObserver from '/ckeditor5/clipboard/clipboardobserver.js';
+import { stringify as stringifyView } from '/ckeditor5/engine/dev-utils/view.js';
+import ViewDocumentFragment from '/ckeditor5/engine/view/documentfragment.js';
+
+describe( 'Clipboard feature', () => {
+	let editor, editingView;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+				features: [ Clipboard ]
+			} )
+			.then( ( newEditor ) => {
+				editor = newEditor;
+				editingView = editor.editing.view;
+			} );
+	} );
+
+	describe( 'constructor', () => {
+		it( 'registers ClipboardObserver', () => {
+			expect( editingView.getObserver( ClipboardObserver ) ).to.be.instanceOf( ClipboardObserver );
+		} );
+	} );
+
+	describe( 'clipboard pipeline', () => {
+		it( 'takes HTML data from the dataTransfer', ( done ) => {
+			const dataTransferMock = createDataTransfer( { 'text/html': '<p>x</p>', 'text/plain': 'y' } );
+			const preventDefaultSpy = sinon.spy();
+
+			editingView.on( 'clipboardInput', ( evt, data ) => {
+				expect( preventDefaultSpy.calledOnce ).to.be.true;
+
+				expect( data.dataTransfer ).to.equal( dataTransferMock );
+
+				expect( data.content ).is.instanceOf( ViewDocumentFragment );
+				expect( stringifyView( data.content ) ).to.equal( '<p>x</p>' );
+
+				done();
+			} );
+
+			editingView.fire( 'paste', {
+				dataTransfer: dataTransferMock,
+				preventDefault: preventDefaultSpy
+			} );
+		} );
+
+		it( 'takes plain text data from the dataTransfer if there is no HTML', ( done ) => {
+			const dataTransferMock = createDataTransfer( { 'text/plain': 'x\n\ny  z' } );
+			const preventDefaultSpy = sinon.spy();
+
+			editingView.on( 'clipboardInput', ( evt, data ) => {
+				expect( preventDefaultSpy.calledOnce ).to.be.true;
+
+				expect( data.dataTransfer ).to.equal( dataTransferMock );
+
+				expect( data.content ).is.instanceOf( ViewDocumentFragment );
+				expect( stringifyView( data.content ) ).to.equal( '<p>x</p><p>y  z</p>' );
+
+				done();
+			} );
+
+			editingView.fire( 'paste', {
+				dataTransfer: dataTransferMock,
+				preventDefault: preventDefaultSpy
+			} );
+		} );
+
+		it( 'fires clipboardInput event with empty data if there is no HTML nor plain text', ( done ) => {
+			const dataTransferMock = createDataTransfer( {} );
+			const preventDefaultSpy = sinon.spy();
+
+			editingView.on( 'clipboardInput', ( evt, data ) => {
+				expect( preventDefaultSpy.calledOnce ).to.be.true;
+
+				expect( data.dataTransfer ).to.equal( dataTransferMock );
+
+				expect( data.content ).is.instanceOf( ViewDocumentFragment );
+				expect( stringifyView( data.content ) ).to.equal( '' );
+
+				done();
+			} );
+
+			editingView.fire( 'paste', {
+				dataTransfer: dataTransferMock,
+				preventDefault: preventDefaultSpy
+			} );
+		} );
+
+		it( 'uses low priority observer for the paste event', () => {
+			const dataTransferMock = createDataTransfer( { 'text/html': 'x' } );
+			const spy = sinon.spy();
+
+			editingView.on( 'paste', ( evt ) => {
+				evt.stop();
+			} );
+
+			editingView.on( 'clipboardInput', spy );
+
+			editingView.fire( 'paste', {
+				dataTransfer: dataTransferMock,
+				preventDefault() {}
+			} );
+
+			expect( spy.callCount ).to.equal( 0 );
+		} );
+
+		it( 'inserts content to the editor', () => {
+			const dataTransferMock = createDataTransfer( { 'text/html': '<p>x</p>', 'text/plain': 'y' } );
+			const spy = sinon.stub( editor.data, 'insert' );
+
+			editingView.fire( 'paste', {
+				dataTransfer: dataTransferMock,
+				preventDefault() {}
+			} );
+
+			expect( spy.calledOnce ).to.be.true;
+			expect( stringifyView( spy.args[ 0 ][ 0 ] ) ).to.equal( '<p>x</p>' );
+		} );
+
+		it( 'does nothing when pasted content is empty', () => {
+			const dataTransferMock = createDataTransfer( { 'text/plain': '' } );
+			const spy = sinon.stub( editor.data, 'insert' );
+
+			editingView.fire( 'clipboardInput', {
+				dataTransfer: dataTransferMock,
+				content: new ViewDocumentFragment()
+			} );
+
+			expect( spy.callCount ).to.equal( 0 );
+		} );
+
+		it( 'uses low priority observer for the clipboardInput event', () => {
+			const dataTransferMock = createDataTransfer( { 'text/html': 'x' } );
+			const spy = sinon.stub( editor.data, 'insert' );
+
+			editingView.on( 'clipboardInput', ( evt ) => {
+				evt.stop();
+			} );
+
+			editingView.fire( 'paste', {
+				dataTransfer: dataTransferMock,
+				preventDefault() {}
+			} );
+
+			expect( spy.callCount ).to.equal( 0 );
+		} );
+	} );
+} );
+
+function createDataTransfer( data ) {
+	return {
+		getData( type ) {
+			return data[ type ];
+		}
+	};
+}

+ 45 - 0
packages/ckeditor5-clipboard/tests/clipboardobserver.js

@@ -0,0 +1,45 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document */
+
+import ClipboardObserver from '/ckeditor5/clipboard/clipboardobserver.js';
+import ViewDocument from '/ckeditor5/engine/view/document.js';
+import DataTransfer from '/ckeditor5/clipboard/datatransfer.js';
+
+describe( 'ClipboardObserver', () => {
+	let viewDocument, observer;
+
+	beforeEach( () => {
+		viewDocument = new ViewDocument();
+		observer = viewDocument.addObserver( ClipboardObserver );
+	} );
+
+	it( 'should define domEventType', () => {
+		expect( observer.domEventType ).to.equal( 'paste' );
+	} );
+
+	describe( 'onDomEvent', () => {
+		it( 'should fire paste with the right event data', () => {
+			const spy = sinon.spy();
+			const dataTransfer = {
+				getData( type ) {
+					return 'foo:' + type;
+				}
+			};
+
+			viewDocument.on( 'paste', spy );
+
+			observer.onDomEvent( { type: 'paste', target: document.body, clipboardData: dataTransfer } );
+
+			expect( spy.calledOnce ).to.be.true;
+
+			const data = spy.args[ 0 ][ 1 ];
+			expect( data.domTarget ).to.equal( document.body );
+			expect( data.dataTransfer ).to.be.instanceOf( DataTransfer );
+			expect( data.dataTransfer.getData( 'x/y' ) ).to.equal( 'foo:x/y' );
+		} );
+	} );
+} );

+ 18 - 0
packages/ckeditor5-clipboard/tests/datatransfer.js

@@ -0,0 +1,18 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import DataTransfer from '/ckeditor5/clipboard/datatransfer.js';
+
+describe( 'DataTransfer', () => {
+	it( 'should return data from the native data transfer', () => {
+		const dt = new DataTransfer( {
+			getData( type ) {
+				return 'foo:' + type;
+			}
+		} );
+
+		expect( dt.getData( 'x/y' ) ).to.equal( 'foo:x/y' );
+	} );
+} );

+ 23 - 0
packages/ckeditor5-clipboard/tests/manual/pasting.html

@@ -0,0 +1,23 @@
+<head>
+	<link rel="stylesheet" href="%APPS_DIR%ckeditor/build/modules/amd/theme/ckeditor.css">
+</head>
+
+<div id="editor">
+	<h2>About CKEditor&nbsp;5, v0.3.0</h2>
+
+	<p>This is the <a href="http://ckeditor.com/blog/Third-Developer-Preview-of-CKEditor-5-Available">third developer preview</a> of <a href="https://ckeditor5.github.io">CKEditor&nbsp;5</a>.</p>
+
+	<p>After 2 years of work, building the next generation editor from scratch and closing over 670 tickets, we created a highly <strong>extensible and flexible architecture</strong> which consists of an <strong>amazing editing framework</strong> and <strong>editing solutions</strong> that will be built on top of it.</p>
+
+	<h3>Notes</h3>
+
+	<p><a href="https://ckeditor5.github.io">CKEditor&nbsp;5</a> is <em>under heavy development</em> and this demo is not production-ready software. For example:</p>
+
+	<ul>
+		<li><strong>only Chrome, Opera and Safari are supported</strong>,</li>
+		<li>Firefox requires enabling the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Document/onselectionchange">&ldquo;dom.select_events.enabled&rdquo;</a> option,</li>
+		<li><a href="https://github.com/ckeditor/ckeditor5/issues/342">support for pasting</a> is under development.</li>
+	</ul>
+
+	<p>It has <em>bugs</em> that we are aware of – and that we will be working on in the next few iterations of the project. Stay tuned for some updates soon!</p>
+</div>

+ 56 - 0
packages/ckeditor5-clipboard/tests/manual/pasting.js

@@ -0,0 +1,56 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '/ckeditor5/editor-classic/classic.js';
+import Typing from '/ckeditor5/typing/typing.js';
+import Paragraph from '/ckeditor5/paragraph/paragraph.js';
+import Undo from '/ckeditor5/undo/undo.js';
+import Enter from '/ckeditor5/enter/enter.js';
+import Clipboard from '/ckeditor5/clipboard/clipboard.js';
+import Link from '/ckeditor5/link/link.js';
+import List from '/ckeditor5/list/list.js';
+import Heading from '/ckeditor5/heading/heading.js';
+import Bold from '/ckeditor5/basic-styles/bold.js';
+import Italic from '/ckeditor5/basic-styles/italic.js';
+
+import { stringify as stringifyView } from '/ckeditor5/engine/dev-utils/view.js';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	features: [
+		Typing,
+		Paragraph,
+		Undo,
+		Enter,
+		Clipboard,
+		Link,
+		List,
+		Heading,
+		Bold,
+		Italic
+	],
+	toolbar: [ 'headings', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'undo', 'redo' ]
+} )
+.then( editor => {
+	window.editor = editor;
+
+	editor.editing.view.on( 'paste', ( evt, data ) => {
+		console.clear();
+
+		console.log( '----- paste -----' );
+		console.log( data );
+		console.log( 'text/html\n', data.dataTransfer.getData( 'text/html' ) );
+		console.log( 'text/plain\n', data.dataTransfer.getData( 'text/plain' ) );
+	} );
+
+	editor.editing.view.on( 'clipboardInput', ( evt, data ) => {
+		console.log( '----- clipboardInput -----' );
+		console.log( 'stringify( data.content )\n', stringifyView( data.content ) );
+	} );
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 14 - 0
packages/ckeditor5-clipboard/tests/manual/pasting.md

@@ -0,0 +1,14 @@
+@bender-ui: collapsed
+
+## Pasting
+
+Simply test pasting various content to the editor.
+
+Check:
+
+1. Copy & paste within the editor (nothing should be filtered out, nothing should be lost).
+2. ~~Pasting from some websites (editor should not blow up).~~ (not yet stable)
+
+Note:
+
+* In Safari, only plain text is pasted.

+ 20 - 0
packages/ckeditor5-clipboard/tests/utils/normalizeclipboarddata.js

@@ -0,0 +1,20 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import normalizeClipboardData from '/ckeditor5/clipboard/utils/normalizeclipboarddata.js';
+
+describe( 'normalizeClipboardData', () => {
+	it( 'should strip all span.Apple-converted-space', () => {
+		expect(
+			normalizeClipboardData( '<span class="Apple-converted-space"> \t\n</span>x<span class="Apple-converted-space">\u00a0\u00a0</span>' )
+		).to.equal( ' \t\nx\u00a0\u00a0' );
+	} );
+
+	it( 'should replace span.Apple-converted-space of length one with a normal space', () => {
+		expect(
+			normalizeClipboardData( '<span class="Apple-converted-space"> </span>x<span class="Apple-converted-space">\u00a0</span>' )
+		).to.equal( ' x ' );
+	} );
+} );

+ 28 - 0
packages/ckeditor5-clipboard/tests/utils/plaintexttohtml.js

@@ -0,0 +1,28 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import plainTextToHtml from '/ckeditor5/clipboard/utils/plaintexttohtml.js';
+
+describe( 'plainTextToHtml', () => {
+	it( 'encodes < and >', () => {
+		expect( plainTextToHtml( 'x y <z>' ) ).to.equal( 'x y &lt;z&gt;' );
+	} );
+
+	it( 'turns double line breaks into paragraphs', () => {
+		expect( plainTextToHtml( 'x\n\ny\n\nz' ) ).to.equal( '<p>x</p><p>y</p><p>z</p>' );
+	} );
+
+	it( 'preserves trailing spaces', () => {
+		expect( plainTextToHtml( ' x ' ) ).to.equal( '&nbsp;x&nbsp;' );
+	} );
+
+	it( 'preserve subsequent spaces', () => {
+		expect( plainTextToHtml( 'x  y  ' ) ).to.equal( 'x &nbsp;y &nbsp;' );
+	} );
+
+	it( 'turns single line breaks to spaces', () => {
+		expect( plainTextToHtml( 'x\ny\nz' ) ).to.equal( 'x y z' );
+	} );
+} );