diff --git a/src/clipboard.js b/src/clipboard.js
new file mode 100644
index 0000000..8b2dc74
--- /dev/null
+++ b/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
+ */
diff --git a/src/clipboardobserver.js b/src/clipboardobserver.js
new file mode 100644
index 0000000..37b991c
--- /dev/null
+++ b/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
+ */
diff --git a/src/datatransfer.js b/src/datatransfer.js
new file mode 100644
index 0000000..8144c1c
--- /dev/null
+++ b/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 );
+ }
+}
diff --git a/src/utils/normalizeclipboarddata.js b/src/utils/normalizeclipboarddata.js
new file mode 100644
index 0000000..12c4cb1
--- /dev/null
+++ b/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( /(\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;
+ } );
+}
diff --git a/src/utils/plaintexttohtml.js b/src/utils/plaintexttohtml.js
new file mode 100644
index 0000000..dbf6d35
--- /dev/null
+++ b/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, '>' )
+ // Creates paragraphs for double line breaks and change single line breaks to spaces.
+ // In the future single line breaks may be converted into
s.
+ .replace( /\n\n/g, '
' ) + .replace( /\n/g, ' ' ) + // Preserve trailing spaces (only the first and last one – the rest is handled below). + .replace( /^\s/, ' ' ) + .replace( /\s$/, ' ' ) + // Preserve other subsequent spaces now. + .replace( /\s\s/g, ' ' ); + + if ( text.indexOf( '
' ) > -1 ) { + // If we created paragraphs above, add the trailing ones. + text = `
${ text }
`; + } + + // TODO: + // * What about '\nfoo' vs ' foo'? + + return text; +} diff --git a/tests/clipboard.js b/tests/clipboard.js new file mode 100644 index 0000000..27ed30d --- /dev/null +++ b/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': 'x
', '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( 'x
' ); + + 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( 'x
y z
' ); + + 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': 'x
', '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( 'x
' ); + } ); + + 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 ]; + } + }; +} diff --git a/tests/clipboardobserver.js b/tests/clipboardobserver.js new file mode 100644 index 0000000..63077a9 --- /dev/null +++ b/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' ); + } ); + } ); +} ); diff --git a/tests/datatransfer.js b/tests/datatransfer.js new file mode 100644 index 0000000..0b64ece --- /dev/null +++ b/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' ); + } ); +} ); diff --git a/tests/manual/pasting.html b/tests/manual/pasting.html new file mode 100644 index 0000000..e141e4e --- /dev/null +++ b/tests/manual/pasting.html @@ -0,0 +1,23 @@ + + + + +This is the third developer preview of CKEditor 5.
+ +After 2 years of work, building the next generation editor from scratch and closing over 670 tickets, we created a highly extensible and flexible architecture which consists of an amazing editing framework and editing solutions that will be built on top of it.
+ +CKEditor 5 is under heavy development and this demo is not production-ready software. For example:
+ +It has bugs 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!
+x
y
z
' ); + } ); + + it( 'preserves trailing spaces', () => { + expect( plainTextToHtml( ' x ' ) ).to.equal( ' x ' ); + } ); + + it( 'preserve subsequent spaces', () => { + expect( plainTextToHtml( 'x y ' ) ).to.equal( 'x y ' ); + } ); + + it( 'turns single line breaks to spaces', () => { + expect( plainTextToHtml( 'x\ny\nz' ) ).to.equal( 'x y z' ); + } ); +} );