Преглед на файлове

Merge pull request #10 from ckeditor/t/7

Copy/cut support
Aleksander Nowodzinski преди 9 години
родител
ревизия
f37ab99343

+ 98 - 13
packages/ckeditor5-clipboard/src/clipboard.js

@@ -16,9 +16,9 @@ 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
+ * ## Clipboard input pipeline
  *
- * The feature creates the clipboard pipeline which allows for processing clipboard content
+ * The feature creates the clipboard input pipeline which allows 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.
  *
@@ -27,13 +27,13 @@ import HtmlDataProcessor from '../engine/dataprocessor/htmldataprocessor.js';
  * 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.
+ * 2. prevent the default action of the native `paste` event,
+ * 3. fire {@link engine.view.Document#clipboardInput} with the clipboard data parsed to
+ * a {@link engine.view.DocumentFragment view document fragment}.
  *
- * 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`}).
+ * This action is performed by a low priority listener, so it can be overridden by a normal one
+ * when a deeper change in pasting behavior is 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}
@@ -60,6 +60,29 @@ import HtmlDataProcessor from '../engine/dataprocessor/htmldataprocessor.js';
  *			}
  *		} );
  *
+ * ## Clipboard output pipeline
+ *
+ * The output pipeline is the equivalent of the input pipeline but for the copy and cut operations.
+ * It allows to process the content which will be then put into the clipboard or to override the whole process.
+ *
+ * ### On {@link engine.view.Document#copy} and {@link engine.view.Document#cut}
+ *
+ * The default action is to:
+ *
+ * 1. {@link engine.controller.DataController#getSelectedContent get selected content} from the editor,
+ * 2. prevent the default action of the native `copy` or `cut` event,
+ * 3. fire {@link engine.view.Document#clipboardOutput} with a clone of the selected content
+ * converted to a {@link engine.view.DocumentFragment view document fragment}.
+ *
+ * ### On {@link engine.view.Document#clipboardOutput}
+ *
+ * The default action is to put the content (`data.content`, represented by a {@link engine.view.DocumentFragment})
+ * to the clipboard as HTML. In case of the cut operation, the selected content is also deleted from the editor.
+ *
+ * This action is performed by a low priority listener, so it can be overridden by a normal one.
+ *
+ * At this stage the copied/cut content can be processed by the features.
+ *
  * @memberOf clipboard
  * @extends core.Feature
  */
@@ -69,6 +92,7 @@ export default class Clipboard extends Feature {
 	 */
 	init() {
 		const editor = this.editor;
+		const doc = editor.document;
 		const editingView = editor.editing.view;
 
 		/**
@@ -81,7 +105,7 @@ export default class Clipboard extends Feature {
 
 		editingView.addObserver( ClipboardObserver );
 
-		// The clipboard pipeline.
+		// The clipboard paste pipeline.
 
 		this.listenTo( editingView, 'paste', ( evt, data ) => {
 			const dataTransfer = data.dataTransfer;
@@ -102,7 +126,6 @@ export default class Clipboard extends Feature {
 
 		this.listenTo( editingView, 'clipboardInput', ( evt, data ) => {
 			if ( !data.content.isEmpty ) {
-				const doc = editor.document;
 				const dataController = this.editor.data;
 
 				// Convert the pasted content to a model document fragment.
@@ -117,6 +140,32 @@ export default class Clipboard extends Feature {
 				} );
 			}
 		}, { priority: 'low' } );
+
+		// The clipboard copy/cut pipeline.
+
+		const onCopyCut = ( evt, data ) => {
+			const dataTransfer = data.dataTransfer;
+			const content = editor.data.toView( editor.data.getSelectedContent( doc.selection ) );
+
+			data.preventDefault();
+
+			editingView.fire( 'clipboardOutput', { dataTransfer, content, method: evt.name } );
+		};
+
+		this.listenTo( editingView, 'copy', onCopyCut, { priority: 'low' } );
+		this.listenTo( editingView, 'cut', onCopyCut, { priority: 'low' } );
+
+		this.listenTo( editingView, 'clipboardOutput', ( evt, data ) => {
+			if ( !data.content.isEmpty ) {
+				data.dataTransfer.setData( 'text/html', this._htmlDataProcessor.toData( data.content ) );
+			}
+
+			if ( data.method == 'cut' ) {
+				doc.enqueueChanges( () => {
+					editor.data.deleteContent( doc.selection, doc.batch(), { merge: true } );
+				} );
+			}
+		}, { priority: 'low' } );
 	}
 }
 
@@ -141,12 +190,48 @@ export default class Clipboard extends Feature {
  * Data transfer instance.
  *
  * @readonly
- * @member {clipboard.DataTransfer} engine.view.observer.ClipboardEventData#dataTransfer
+ * @member {clipboard.DataTransfer} engine.view.observer.ClipboardInputEventData#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}.
+ * Read more about the clipboard pipelines in {@link clipboard.Clipboard}.
+ *
+ * @member {engine.view.DocumentFragment} engine.view.observer.ClipboardInputEventData#content
+ */
+
+/**
+ * Fired on {@link envine.view.Document#copy} and {@link envine.view.Document#cut} with a copy of selected content.
+ * The content can be processed before it ends up in the clipboard. It's part of the {@link clipboard.Clipboard "clipboard pipeline"}.
+ *
+ * @see clipboard.ClipboardObserver
+ * @see clipboard.Clipboard
+ * @event engine.view.Document#clipboardOutput
+ * @param {engine.view.observer.ClipboardOutputEventData} data Event data.
+ */
+
+/**
+ * The value of the {@link engine.view.Document#clipboardOutput} event.
+ *
+ * @class engine.view.observer.ClipboardOutputEventData
+ */
+
+/**
+ * Data transfer instance.
+ *
+ * @readonly
+ * @member {clipboard.DataTransfer} engine.view.observer.ClipboardOutputEventData#dataTransfer
+ */
+
+/**
+ * Content to be put into the clipboard. It can be modified by the event listeners.
+ * Read more about the clipboard pipelines in {@link clipboard.Clipboard}.
+ *
+ * @member {engine.view.DocumentFragment} engine.view.observer.ClipboardOutputEventData#content
+ */
+
+/**
+ * Whether the event was triggered by copy or cut operation.
  *
- * @member {engine.view.DocumentFragment} engine.view.observer.ClipboardEventData#content
+ * @member {'copy'|'cut'} engine.view.observer.ClipboardOutputEventData#method
  */

+ 30 - 2
packages/ckeditor5-clipboard/src/clipboardobserver.js

@@ -19,7 +19,7 @@ export default class ClipboardObserver extends DomEventObserver {
 	constructor( doc ) {
 		super( doc );
 
-		this.domEventType = 'paste';
+		this.domEventType = [ 'paste', 'copy', 'cut' ];
 	}
 
 	onDomEvent( domEvent ) {
@@ -44,7 +44,35 @@ export default class ClipboardObserver extends DomEventObserver {
  */
 
 /**
- * The value of the {@link engine.view.Document#paste} event.
+ * Fired when user copied content from 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#copy
+ * @param {engine.view.observer.ClipboardEventData} data Event data.
+ */
+
+/**
+ * Fired when user cut content from 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#cut
+ * @param {engine.view.observer.ClipboardEventData} data Event data.
+ */
+
+/**
+ * The value of the {@link engine.view.Document#paste}, {@link engine.view.Document#copy} and {@link engine.view.Document#cut} events.
  *
  * In order to access clipboard data use {@link #dataTransfer}.
  *

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

@@ -30,4 +30,14 @@ export default class DataTransfer {
 	getData( type ) {
 		return this._native.getData( type );
 	}
+
+	/**
+	 * Sets data in the data transfer.
+	 *
+	 * @param {String} type The mime type. E.g. `text/html` or `text/plain`.
+	 * @param {String} data
+	 */
+	setData( type, data ) {
+		this._native.setData( type, data );
+	}
 }

+ 147 - 9
packages/ckeditor5-clipboard/tests/clipboard.js

@@ -10,9 +10,14 @@ import Paragraph from 'ckeditor5/paragraph/paragraph.js';
 import ClipboardObserver from 'ckeditor5/clipboard/clipboardobserver.js';
 
 import { stringify as stringifyView } from 'ckeditor5/engine/dev-utils/view.js';
-import { stringify as stringifyModel } from 'ckeditor5/engine/dev-utils/model.js';
+import {
+	stringify as stringifyModel,
+	setData as setModelData,
+	getData as getModelData
+} from 'ckeditor5/engine/dev-utils/model.js';
 
 import ViewDocumentFragment from 'ckeditor5/engine/view/documentfragment.js';
+import ViewText from 'ckeditor5/engine/view/text.js';
 
 describe( 'Clipboard feature', () => {
 	let editor, editingView;
@@ -33,7 +38,7 @@ describe( 'Clipboard feature', () => {
 		} );
 	} );
 
-	describe( 'clipboard pipeline', () => {
+	describe( 'clipboard paste pipeline', () => {
 		it( 'takes HTML data from the dataTransfer', ( done ) => {
 			const dataTransferMock = createDataTransfer( { 'text/html': '<p>x</p>', 'text/plain': 'y' } );
 			const preventDefaultSpy = sinon.spy();
@@ -170,13 +175,146 @@ describe( 'Clipboard feature', () => {
 
 			expect( spy.callCount ).to.equal( 0 );
 		} );
+
+		function createDataTransfer( data ) {
+			return {
+				getData( type ) {
+					return data[ type ];
+				}
+			};
+		}
 	} );
-} );
 
-function createDataTransfer( data ) {
-	return {
-		getData( type ) {
-			return data[ type ];
+	describe( 'clipboard copy/cut pipeline', () => {
+		it( 'fires clipboardOutput for copy with the selected content and correct method', ( done ) => {
+			const dataTransferMock = createDataTransfer();
+			const preventDefaultSpy = sinon.spy();
+
+			setModelData( editor.document, '<paragraph>a[bc</paragraph><paragraph>de]f</paragraph>' );
+
+			editingView.on( 'clipboardOutput', ( evt, data ) => {
+				expect( preventDefaultSpy.calledOnce ).to.be.true;
+				expect( data.method ).to.equal( 'copy' );
+
+				expect( data.dataTransfer ).to.equal( dataTransferMock );
+
+				expect( data.content ).is.instanceOf( ViewDocumentFragment );
+				expect( stringifyView( data.content ) ).to.equal( '<p>bc</p><p>de</p>' );
+
+				done();
+			} );
+
+			editingView.fire( 'copy', {
+				dataTransfer: dataTransferMock,
+				preventDefault: preventDefaultSpy
+			} );
+		} );
+
+		it( 'fires clipboardOutput for cut with the selected content and correct method', ( done ) => {
+			const dataTransferMock = createDataTransfer();
+			const preventDefaultSpy = sinon.spy();
+
+			setModelData( editor.document, '<paragraph>a[bc</paragraph><paragraph>de]f</paragraph>' );
+
+			editingView.on( 'clipboardOutput', ( evt, data ) => {
+				expect( data.method ).to.equal( 'cut' );
+
+				done();
+			} );
+
+			editingView.fire( 'cut', {
+				dataTransfer: dataTransferMock,
+				preventDefault: preventDefaultSpy
+			} );
+		} );
+
+		it( 'uses low priority observer for the copy event', () => {
+			const dataTransferMock = createDataTransfer();
+			const spy = sinon.spy();
+
+			editingView.on( 'copy', ( evt ) => {
+				evt.stop();
+			} );
+
+			editingView.on( 'clipboardOutput', spy );
+
+			editingView.fire( 'copy', {
+				dataTransfer: dataTransferMock,
+				preventDefault() {}
+			} );
+
+			expect( spy.callCount ).to.equal( 0 );
+		} );
+
+		it( 'sets clipboard HTML data', () => {
+			const dataTransferMock = createDataTransfer();
+
+			setModelData( editor.document, '<paragraph>f[o]o</paragraph>' );
+
+			editingView.fire( 'clipboardOutput', {
+				dataTransfer: dataTransferMock,
+				content: new ViewDocumentFragment( [ new ViewText( 'abc' ) ] ),
+				method: 'copy'
+			} );
+
+			expect( dataTransferMock.getData( 'text/html' ) ).to.equal( 'abc' );
+			expect( getModelData( editor.document ) ).to.equal( '<paragraph>f[o]o</paragraph>' );
+		} );
+
+		it( 'does not set clipboard HTML data if content is empty', () => {
+			const dataTransferMock = createDataTransfer();
+
+			editingView.fire( 'clipboardOutput', {
+				dataTransfer: dataTransferMock,
+				content: new ViewDocumentFragment(),
+				method: 'copy'
+			} );
+
+			expect( dataTransferMock.getData( 'text/html' ) ).to.be.undefined;
+		} );
+
+		it( 'deletes selected content in case of cut', () => {
+			const dataTransferMock = createDataTransfer();
+
+			setModelData( editor.document, '<paragraph>f[o</paragraph><paragraph>x]o</paragraph>' );
+
+			editingView.fire( 'clipboardOutput', {
+				dataTransfer: dataTransferMock,
+				content: new ViewDocumentFragment(),
+				method: 'cut'
+			} );
+
+			expect( getModelData( editor.document ) ).to.equal( '<paragraph>f[]o</paragraph>' );
+		} );
+
+		it( 'uses low priority observer for the clipboardOutput event', () => {
+			const dataTransferMock = createDataTransfer();
+
+			editingView.on( 'clipboardOutput', ( evt ) => {
+				evt.stop();
+			} );
+
+			editingView.fire( 'copy', {
+				dataTransfer: dataTransferMock,
+				content: new ViewDocumentFragment( [ new ViewText( 'abc' ) ] ),
+				preventDefault() {}
+			} );
+
+			expect( dataTransferMock.getData( 'text/html' ) ).to.be.undefined;
+		} );
+
+		function createDataTransfer() {
+			const store = new Map();
+
+			return {
+				setData( type, data ) {
+					store.set( type, data );
+				},
+
+				getData( type ) {
+					return store.get( type );
+				}
+			};
 		}
-	};
-}
+	} );
+} );

+ 3 - 1
packages/ckeditor5-clipboard/tests/clipboardobserver.js

@@ -18,7 +18,7 @@ describe( 'ClipboardObserver', () => {
 	} );
 
 	it( 'should define domEventType', () => {
-		expect( observer.domEventType ).to.equal( 'paste' );
+		expect( observer.domEventType ).to.deep.equal( [ 'paste', 'copy', 'cut' ] );
 	} );
 
 	describe( 'onDomEvent', () => {
@@ -41,5 +41,7 @@ describe( 'ClipboardObserver', () => {
 			expect( data.dataTransfer ).to.be.instanceOf( DataTransfer );
 			expect( data.dataTransfer.getData( 'x/y' ) ).to.equal( 'foo:x/y' );
 		} );
+
+		// If it fires paste it fires all the other events too.
 	} );
 } );

+ 21 - 6
packages/ckeditor5-clipboard/tests/datatransfer.js

@@ -6,13 +6,28 @@
 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;
-			}
+	describe( 'getData', () => {
+		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' );
 		} );
+	} );
+
+	describe( 'setData', () => {
+		it( 'should return set data in the native data transfer', () => {
+			const spy = sinon.spy();
+			const dt = new DataTransfer( {
+				setData: spy
+			} );
 
-		expect( dt.getData( 'x/y' ) ).to.equal( 'foo:x/y' );
+			dt.setData( 'text/html', 'bar' );
+
+			expect( spy.calledWithExactly( 'text/html', 'bar' ) ).to.be.true;
+		} );
 	} );
 } );

+ 45 - 0
packages/ckeditor5-clipboard/tests/manual/copycut.html

@@ -0,0 +1,45 @@
+<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>
+
+<h2>Native contentEditable</h2>
+
+<div contenteditable="true" id="native" style="border: solid 2px blue">
+	<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>

+ 79 - 0
packages/ckeditor5-clipboard/tests/manual/copycut.js

@@ -0,0 +1,79 @@
+/**
+ * @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();
+		onViewEvent( evt, data );
+	} );
+	editor.editing.view.on( 'paste', onViewEvent );
+	editor.editing.view.on( 'copy', onViewEvent, { priority: 'lowest' } );
+	editor.editing.view.on( 'cut', onViewEvent, { priority: 'lowest' } );
+
+	editor.editing.view.on( 'clipboardInput', onPipelineEvent );
+	editor.editing.view.on( 'clipboardOutput', ( evt, data ) => {
+		console.clear();
+		onPipelineEvent( evt, data );
+	} );
+
+	function onViewEvent( evt, data ) {
+		console.log( `----- ${ evt.name } -----` );
+		console.log( 'text/html\n', data.dataTransfer.getData( 'text/html' ) );
+	}
+
+	function onPipelineEvent( evt, data ) {
+		console.log( `----- ${ evt.name } -----` );
+		console.log( 'stringify( data.content )\n', stringifyView( data.content ) );
+	}
+} )
+.catch( err => {
+	console.error( err.stack );
+} );
+
+document.getElementById( 'native' ).addEventListener( 'paste', onNativeEvent );
+document.getElementById( 'native' ).addEventListener( 'copy', onNativeEvent );
+document.getElementById( 'native' ).addEventListener( 'cut', onNativeEvent );
+
+function onNativeEvent( evt ) {
+	console.clear();
+	console.log( `----- native ${ evt.type } -----` );
+
+	if ( evt.type == 'paste' ) {
+		console.log( 'text/html\n', evt.clipboardData.getData( 'text/html' ) );
+	}
+}

+ 7 - 0
packages/ckeditor5-clipboard/tests/manual/copycut.md

@@ -0,0 +1,7 @@
+@bender-ui: collapsed
+
+## Copy and cut
+
+Play with copy and cut. Paste copied content.
+
+Compare the results with the native editable. Don't expect that they behave identically.