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 @@ + + + + +
+

About CKEditor 5, v0.3.0

+ +

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.

+ +

Notes

+ +

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!

+
diff --git a/tests/manual/pasting.js b/tests/manual/pasting.js new file mode 100644 index 0000000..0b0e5cc --- /dev/null +++ b/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 ); +} ); diff --git a/tests/manual/pasting.md b/tests/manual/pasting.md new file mode 100644 index 0000000..98b9ea2 --- /dev/null +++ b/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. diff --git a/tests/utils/normalizeclipboarddata.js b/tests/utils/normalizeclipboarddata.js new file mode 100644 index 0000000..42ce53a --- /dev/null +++ b/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( ' \t\nx\u00a0\u00a0' ) + ).to.equal( ' \t\nx\u00a0\u00a0' ); + } ); + + it( 'should replace span.Apple-converted-space of length one with a normal space', () => { + expect( + normalizeClipboardData( ' x\u00a0' ) + ).to.equal( ' x ' ); + } ); +} ); diff --git a/tests/utils/plaintexttohtml.js b/tests/utils/plaintexttohtml.js new file mode 100644 index 0000000..e309f62 --- /dev/null +++ b/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 ' ) ).to.equal( 'x y <z>' ); + } ); + + it( 'turns double line breaks into paragraphs', () => { + expect( plainTextToHtml( 'x\n\ny\n\nz' ) ).to.equal( '

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' ); + } ); +} );