Browse Source

Merge pull request #2 from ckeditor/t/1

Provide the initial implementation.
Piotr Jasiun 7 years ago
parent
commit
9c3e0eb0f2

+ 5 - 0
packages/ckeditor5-autosave/package.json

@@ -9,8 +9,13 @@
     "ckeditor5-feature"
   ],
   "dependencies": {
+	"@ckeditor/ckeditor5-utils": "^10.0.0",
+	"@ckeditor/ckeditor5-core": "^10.0.0"
   },
   "devDependencies": {
+	"@ckeditor/ckeditor5-engine": "^10.0.0",
+	"@ckeditor/ckeditor5-paragraph": "^10.0.0",
+	"@ckeditor/ckeditor5-editor-classic": "^10.0.0",
     "eslint": "^4.15.0",
     "eslint-config-ckeditor5": "^1.0.7",
     "husky": "^0.14.3",

+ 256 - 0
packages/ckeditor5-autosave/src/autosave.js

@@ -0,0 +1,256 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module autosave/autosave
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import PendingActions from '@ckeditor/ckeditor5-core/src/pendingactions';
+import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin';
+import throttle from './throttle';
+
+/* globals window */
+
+/**
+ * Autosave plugin provides an easy-to-use API to save the editor's content.
+ * It watches {@link module:engine/model/document~Document#event:change:data change:data}
+ * and `window#beforeunload` events and calls the {@link module:autosave/autosave~Adapter#save} method.
+ *
+ * 		ClassicEditor
+ *			.create( document.querySelector( '#editor' ), {
+ *				plugins: [ ArticlePluginSet, Autosave ],
+ *				toolbar: [ 'heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo' ],
+ *				image: {
+ *					toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ],
+ *				},
+ *				autosave: {
+ *					save() {
+ *						// Note: saveEditorsContentToDatabase function should return a promise
+ *						// which should be resolved when the saving action is complete.
+ *						return saveEditorsContentToDatabase( data );
+ *					}
+ *				}
+ *			} );
+ */
+export default class Autosave extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'Autosave';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ PendingActions ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * The adapter is an object with the `save()` method. That method will be called whenever
+		 * the model's data changes. It might be called some time after the change,
+		 * since the event is throttled for performance reasons.
+		 *
+		 * @type {module:autosave/autosave~Adapter}
+		 */
+		this.adapter = undefined;
+
+		/**
+		 * Throttled save method.
+		 *
+		 * @protected
+		 * @type {Function}
+		 */
+		this._throttledSave = throttle( this._save.bind( this ), 500 );
+
+		/**
+		 * Last document version.
+		 *
+		 * @protected
+		 * @type {Number}
+		 */
+		this._lastDocumentVersion = editor.model.document.version;
+
+		/**
+		 * DOM emitter.
+		 *
+		 * @private
+		 * @type {DomEmitterMixin}
+		 */
+		this._domEmitter = Object.create( DomEmitterMixin );
+
+		/**
+		 * Save action counter monitors number of actions.
+		 *
+		 * @private
+		 * @type {Number}
+		 */
+		this._saveActionCounter = 0;
+
+		/**
+		 * An action that will be added to pending action manager for actions happening in that plugin.
+		 *
+		 * @private
+		 * @type {Object|null}
+		 */
+		this._action = null;
+
+		/**
+		 * Plugins' config.
+		 *
+		 * @private
+		 * @type {Object}
+		 */
+		this._config = editor.config.get( 'autosave' ) || {};
+
+		/**
+		 * Editor's pending actions manager.
+		 *
+		 * @private
+		 * @member {@module:core/pendingactions~PendingActions} #_pendingActions
+		 */
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const doc = editor.model.document;
+
+		this._pendingActions = editor.plugins.get( PendingActions );
+
+		this.listenTo( doc, 'change:data', () => {
+			this._incrementCounter();
+
+			const willOriginalFunctionBeCalled = this._throttledSave();
+
+			if ( !willOriginalFunctionBeCalled ) {
+				this._decrementCounter();
+			}
+		} );
+
+		// Flush on the editor's destroy listener with the highest priority to ensure that
+		// `editor.getData()` will be called before plugins are destroyed.
+		this.listenTo( editor, 'destroy', () => this._flush(), { priority: 'highest' } );
+
+		// It's not possible to easy test it because karma uses `beforeunload` event
+		// to warn before full page reload and this event cannot be dispatched manually.
+		/* istanbul ignore next */
+		this._domEmitter.listenTo( window, 'beforeunload', ( evtInfo, domEvt ) => {
+			if ( this._pendingActions.isPending ) {
+				domEvt.returnValue = this._pendingActions.first.message;
+			}
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		// There's no need for canceling or flushing the throttled save, as
+		// it's done on the editor's destroy event with the highest priority.
+
+		this._domEmitter.stopListening();
+		super.destroy();
+	}
+
+	/**
+	 * Invokes remaining `_save` method call.
+	 *
+	 * @protected
+	 */
+	_flush() {
+		this._throttledSave.flush();
+	}
+
+	/**
+	 * If the adapter is set and new document version exists,
+	 * `_save()` method creates a pending action and calls `adapter.save()` method.
+	 * It waits for the result and then removes the created pending action.
+	 *
+	 * @private
+	 */
+	_save() {
+		const version = this.editor.model.document.version;
+
+		const saveCallbacks = [];
+
+		if ( this.adapter && this.adapter.save ) {
+			saveCallbacks.push( this.adapter.save );
+		}
+
+		if ( this._config.save ) {
+			saveCallbacks.push( this._config.save );
+		}
+
+		// Change may not produce an operation, so the document's version
+		// can be the same after that change.
+		if ( version < this._lastDocumentVersion || !saveCallbacks.length ) {
+			this._decrementCounter();
+
+			return;
+		}
+
+		this._lastDocumentVersion = version;
+
+		Promise.all( saveCallbacks.map( cb => cb() ) )
+			.then( () => {
+				this._decrementCounter();
+			} );
+	}
+
+	/**
+	 * Increments counter and adds pending action if it not exists.
+	 *
+	 * @private
+	 */
+	_incrementCounter() {
+		this._saveActionCounter++;
+
+		if ( !this._action ) {
+			this._action = this._pendingActions.add( 'Saving in progress.' );
+		}
+	}
+
+	/**
+	 * Decrements counter and removes pending action if counter is empty,
+	 * which means, that no new save action occurred.
+	 *
+	 * @private
+	 */
+	_decrementCounter() {
+		this._saveActionCounter--;
+
+		if ( this._saveActionCounter === 0 ) {
+			this._pendingActions.remove( this._action );
+			this._action = null;
+		}
+	}
+}
+
+/**
+ * An interface that requires the `save()` method.
+ *
+ * Used by {module:autosave/autosave~Autosave#adapter}
+ *
+ * @interface module:autosave/autosave~Adapter
+ */
+
+/**
+ * Method that will be called when the data model changes. It should return a promise (e.g. in case of saving content to the database),
+ * so the `Autosave` plugin will wait for that action before removing it from pending actions.
+ *
+ * @method #save
+ * @returns {Promise.<*>}
+ */

+ 68 - 0
packages/ckeditor5-autosave/src/throttle.js

@@ -0,0 +1,68 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module autosave/throttle
+ */
+
+/* globals window */
+
+/**
+ * Throttle function - a helper that provides ability to specify minimum time gap between calling the original function.
+ * Comparing to the lodash implementation, this provides an information if calling the throttled function will result in
+ * calling the original function.
+ *
+ * @param {Function} fn Original function that will be called.
+ * @param {Number} wait Minimum amount of time between original function calls.
+ */
+export default function throttle( fn, wait ) {
+	// Time in ms of the last call.
+	let lastCallTime = 0;
+
+	// Timeout id that enables stopping scheduled call.
+	let timeoutId = null;
+
+	// @returns {Boolean} `true` if the original function was or will be called.
+	function throttledFn() {
+		const now = Date.now();
+
+		// Cancel call, as the next call is scheduled.
+		if ( timeoutId ) {
+			return false;
+		}
+
+		// Call instantly, as the fn wasn't called within the `time` period.
+		if ( now > lastCallTime + wait ) {
+			call();
+			return true;
+		}
+
+		// Set timeout, so the fn will be called `time` ms after the last call.
+		timeoutId = window.setTimeout( call, lastCallTime + wait - now );
+
+		return true;
+	}
+
+	throttledFn.flush = flush;
+
+	function flush() {
+		if ( timeoutId ) {
+			window.clearTimeout( timeoutId );
+			call();
+		}
+
+		lastCallTime = 0;
+	}
+
+	// Calls the original function and updates internals.
+	function call() {
+		lastCallTime = Date.now();
+		timeoutId = null;
+
+		fn();
+	}
+
+	return throttledFn;
+}

+ 527 - 0
packages/ckeditor5-autosave/tests/autosave.js

@@ -0,0 +1,527 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document, window */
+
+import ModelText from '@ckeditor/ckeditor5-engine/src/model/text';
+import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Autosave from '../src/autosave';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import PendingActions from '@ckeditor/ckeditor5-core/src/pendingactions';
+
+describe( 'Autosave', () => {
+	const sandbox = sinon.sandbox.create( { useFakeTimers: true } );
+	let editor, element, autosave;
+
+	afterEach( () => {
+		sandbox.restore();
+	} );
+
+	it( 'should have static pluginName property', () => {
+		expect( Autosave.pluginName ).to.equal( 'Autosave' );
+	} );
+
+	describe( 'initialization', () => {
+		beforeEach( () => {
+			element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			return ClassicTestEditor
+				.create( element, {
+					plugins: [ Autosave, Paragraph ]
+				} )
+				.then( _editor => {
+					const data = '<p>paragraph1</p><p>paragraph2</p>';
+
+					editor = _editor;
+					editor.setData( data );
+					autosave = editor.plugins.get( Autosave );
+
+					// Clean autosave's state after setting data.
+					autosave._flush();
+				} );
+		} );
+
+		afterEach( () => {
+			document.body.removeChild( element );
+
+			return editor.destroy();
+		} );
+
+		it( 'should initialize adapter with an undefined value', () => {
+			expect( autosave.adapter ).to.be.undefined;
+		} );
+
+		it( 'should allow plugin to work without defined adapter and without its config', () => {
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+			} );
+
+			autosave._flush();
+		} );
+	} );
+
+	describe( 'config', () => {
+		beforeEach( () => {
+			element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			return ClassicTestEditor
+				.create( element, {
+					plugins: [ Autosave, Paragraph ],
+					autosave: {
+						save: sinon.spy()
+					}
+				} )
+				.then( _editor => {
+					const data = '<p>paragraph1</p><p>paragraph2</p>';
+
+					editor = _editor;
+					editor.setData( data );
+					autosave = editor.plugins.get( Autosave );
+
+					// Clean autosave's state after setting data.
+					autosave._flush();
+					editor.config.get( 'autosave' ).save.resetHistory();
+				} );
+		} );
+
+		afterEach( () => {
+			document.body.removeChild( element );
+
+			return editor.destroy();
+		} );
+
+		it( 'should enable providing callback via the config', () => {
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+			} );
+
+			sinon.assert.calledOnce( editor.config.get( 'autosave' ).save );
+		} );
+
+		it( 'its callback and adapter callback should be called if both are provided', () => {
+			autosave.adapter = {
+				save: sinon.spy()
+			};
+
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+			} );
+
+			sinon.assert.calledOnce( editor.config.get( 'autosave' ).save );
+			sinon.assert.calledOnce( autosave.adapter.save );
+		} );
+	} );
+
+	describe( 'autosaving', () => {
+		beforeEach( () => {
+			element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			return ClassicTestEditor
+				.create( element, {
+					plugins: [ Autosave, Paragraph ]
+				} )
+				.then( _editor => {
+					const data = '<p>paragraph1</p><p>paragraph2</p>';
+
+					editor = _editor;
+					editor.setData( data );
+					autosave = editor.plugins.get( Autosave );
+
+					// Clean autosave's state after setting data.
+					autosave._flush();
+				} );
+		} );
+
+		afterEach( () => {
+			document.body.removeChild( element );
+
+			return editor.destroy();
+		} );
+
+		it( 'should run adapter\'s save method when the editor\'s change event is fired', () => {
+			autosave.adapter = {
+				save: sinon.spy()
+			};
+
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+			} );
+
+			sinon.assert.calledOnce( autosave.adapter.save );
+		} );
+
+		it( 'should throttle editor\'s change event', () => {
+			const spy = sinon.spy();
+			const savedStates = [];
+
+			autosave.adapter = {
+				save() {
+					spy();
+
+					savedStates.push( editor.getData() );
+				}
+			};
+
+			// Leading (will fire change).
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) );
+				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+			} );
+
+			// Throttled (won't fire change).
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) );
+				editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection );
+			} );
+
+			// Flushed (will fire change).
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) );
+				editor.model.insertContent( new ModelText( 'biz' ), editor.model.document.selection );
+			} );
+
+			autosave._flush();
+
+			expect( spy.callCount ).to.equal( 2 );
+			expect( savedStates ).to.deep.equal( [
+				'<p>paragraph1</p><p>foo</p>',
+				'<p>paragraph1</p><p>biz</p>'
+			] );
+		} );
+
+		it( 'should add a pending action during the saving.', () => {
+			sandbox.useFakeTimers();
+			const pendingActions = editor.plugins.get( PendingActions );
+			const serverActionSpy = sinon.spy();
+			const serverActionStub = sinon.stub();
+			serverActionStub.onCall( 0 ).resolves( wait( 500 ).then( serverActionSpy ) );
+
+			autosave.adapter = {
+				save: serverActionStub
+			};
+
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+			} );
+
+			sinon.assert.notCalled( serverActionSpy );
+			expect( pendingActions.isPending ).to.be.true;
+			expect( pendingActions.first.message ).to.equal( 'Saving in progress.' );
+
+			sandbox.clock.tick( 500 );
+			return Promise.resolve().then( () => Promise.resolve() ).then( () => {
+				sinon.assert.calledOnce( serverActionSpy );
+				expect( pendingActions.isPending ).to.be.false;
+			} );
+		} );
+
+		it( 'should add a pending action during the saving #2.', () => {
+			const serverActionSpy = sinon.spy();
+			const pendingActions = editor.plugins.get( PendingActions );
+
+			autosave.adapter = {
+				save: serverActionSpy
+			};
+
+			expect( pendingActions.isPending ).to.be.false;
+
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+			} );
+
+			expect( pendingActions.isPending ).to.be.true;
+
+			// Server action needs to wait at least a cycle.
+			return wait().then( () => {
+				sinon.assert.calledOnce( serverActionSpy );
+				expect( pendingActions.isPending ).to.be.false;
+			} );
+		} );
+
+		it( 'should handle correctly throttled save action and preserve pending action until both save actions finish', () => {
+			sandbox.useFakeTimers();
+			const serverActionSpy = sinon.spy();
+			const pendingActions = editor.plugins.get( PendingActions );
+
+			// Create a fake server that responses after 500ms for the first call and after 1000ms for the second call.
+			const serverActionStub = sinon.stub();
+			serverActionStub.onCall( 0 ).resolves( wait( 500 ).then( serverActionSpy ) );
+			serverActionStub.onCall( 1 ).resolves( wait( 1000 ).then( serverActionSpy ) );
+
+			autosave.adapter = {
+				save: serverActionStub
+			};
+
+			expect( pendingActions.isPending ).to.be.false;
+
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+			} );
+
+			expect( pendingActions.isPending ).to.be.true;
+
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+				editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection );
+			} );
+
+			autosave._flush();
+
+			expect( pendingActions.isPending ).to.be.true;
+
+			sandbox.clock.tick( 500 );
+
+			return Promise.resolve().then( () => {
+				expect( pendingActions.isPending ).to.be.true;
+				sinon.assert.calledOnce( serverActionSpy );
+
+				// Wait another 500ms and a promise cycle for the second server action.
+				sandbox.clock.tick( 500 );
+			} )
+				.then( () => Promise.resolve() )
+				.then( () => {
+					expect( pendingActions.isPending ).to.be.false;
+					sinon.assert.calledTwice( serverActionSpy );
+				} );
+		} );
+
+		it( 'should handle correctly throttled save action and preserve pending action until both save actions finish #2', () => {
+			const serverActionSpy = sinon.spy();
+			const pendingActions = editor.plugins.get( PendingActions );
+
+			autosave.adapter = {
+				save: serverActionSpy
+			};
+
+			expect( pendingActions.isPending ).to.be.false;
+
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+			} );
+
+			expect( pendingActions.isPending ).to.be.true;
+
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+				editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection );
+			} );
+
+			expect( pendingActions.isPending ).to.be.true;
+
+			// Server action needs to wait at least a cycle.
+			return wait().then( () => {
+				sinon.assert.calledOnce( serverActionSpy );
+				expect( pendingActions.isPending ).to.be.true;
+
+				autosave._flush();
+
+				// Wait another promise cycle.
+				return wait().then( () => {
+					sinon.assert.calledTwice( serverActionSpy );
+					expect( pendingActions.isPending ).to.be.false;
+				} );
+			} );
+		} );
+
+		it( 'should filter out changes in the selection', () => {
+			autosave.adapter = {
+				save: sandbox.spy()
+			};
+
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+			} );
+
+			autosave._flush();
+			sinon.assert.notCalled( autosave.adapter.save );
+		} );
+
+		it( 'should filter out markers that does not affect the data model', () => {
+			autosave.adapter = {
+				save: sandbox.spy()
+			};
+
+			const range = ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) );
+			const range2 = ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) );
+
+			editor.model.change( writer => {
+				writer.addMarker( 'name', { usingOperation: true, range } );
+			} );
+
+			autosave._flush();
+
+			editor.model.change( writer => {
+				writer.updateMarker( 'name', { range: range2 } );
+			} );
+
+			autosave._flush();
+
+			sinon.assert.notCalled( autosave.adapter.save );
+		} );
+
+		it( 'should filter out markers that does not affect the data model #2', () => {
+			autosave.adapter = {
+				save: sandbox.spy()
+			};
+
+			const range = ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) );
+			const range2 = ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) );
+
+			editor.model.change( writer => {
+				writer.addMarker( 'name', { usingOperation: false, range } );
+			} );
+
+			autosave._flush();
+
+			editor.model.change( writer => {
+				writer.updateMarker( 'name', { range: range2 } );
+			} );
+
+			autosave._flush();
+
+			sinon.assert.notCalled( autosave.adapter.save );
+		} );
+
+		it( 'should call the save method when some marker affects the data model', () => {
+			autosave.adapter = {
+				save: sandbox.spy()
+			};
+
+			const range = ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) );
+			const range2 = ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) );
+
+			editor.model.change( writer => {
+				writer.addMarker( 'name', { usingOperation: true, affectsData: true, range } );
+			} );
+
+			autosave._flush();
+
+			editor.model.change( writer => {
+				writer.updateMarker( 'name', { range: range2 } );
+			} );
+
+			autosave._flush();
+
+			sinon.assert.calledTwice( autosave.adapter.save );
+		} );
+
+		it( 'should call the save method when some marker affects the data model #2', () => {
+			autosave.adapter = {
+				save: sandbox.spy()
+			};
+
+			const range = ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) );
+			const range2 = ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) );
+
+			editor.model.change( writer => {
+				writer.addMarker( 'name', { usingOperation: false, affectsData: true, range } );
+			} );
+
+			autosave._flush();
+			sinon.assert.calledOnce( autosave.adapter.save );
+
+			editor.model.change( writer => {
+				writer.updateMarker( 'name', { range: range2 } );
+			} );
+
+			autosave._flush();
+
+			sinon.assert.calledTwice( autosave.adapter.save );
+		} );
+
+		it( 'should call the save method when some marker affects the data model #3', () => {
+			autosave.adapter = {
+				save: sandbox.spy()
+			};
+
+			const range = ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) );
+
+			editor.model.change( writer => {
+				writer.addMarker( 'marker-not-affecting-data', { usingOperation: false, affectsData: true, range } );
+				writer.addMarker( 'marker-affecting-data', { usingOperation: false, affectsData: false, range } );
+			} );
+
+			sinon.assert.calledOnce( autosave.adapter.save );
+		} );
+
+		it( 'should flush remaining calls after editor\'s destroy', () => {
+			const spy = sandbox.spy();
+			const savedStates = [];
+
+			autosave.adapter = {
+				save() {
+					spy();
+
+					savedStates.push( editor.getData() );
+				}
+			};
+
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+			} );
+
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) );
+				editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection );
+			} );
+
+			return editor.destroy().then( () => {
+				expect( spy.callCount ).to.equal( 2 );
+				expect( savedStates ).to.deep.equal( [
+					'<p>foo</p><p>paragraph2</p>',
+					'<p>foo</p><p>bar</p>',
+				] );
+			} );
+		} );
+
+		it( 'should work after editor\'s destroy with long server\'s response time', () => {
+			sandbox.useFakeTimers();
+			const pendingActions = editor.plugins.get( PendingActions );
+			const serverActionSpy = sinon.spy();
+			const serverActionStub = sinon.stub();
+			serverActionStub.onCall( 0 ).resolves( wait( 500 ).then( serverActionSpy ) );
+
+			autosave.adapter = {
+				save: serverActionStub
+			};
+
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+			} );
+
+			return editor.destroy()
+				.then( () => {
+					expect( pendingActions.isPending ).to.be.true;
+					sandbox.clock.tick( 500 );
+				} )
+				.then( () => Promise.resolve() )
+				.then( () => {
+					expect( pendingActions.isPending ).to.be.false;
+					sinon.assert.calledOnce( serverActionSpy );
+					sinon.assert.calledOnce( serverActionStub );
+				} );
+		} );
+	} );
+} );
+
+function wait( time ) {
+	return new Promise( res => {
+		window.setTimeout( res, time );
+	} );
+}

+ 6 - 0
packages/ckeditor5-autosave/tests/manual/autosave.html

@@ -0,0 +1,6 @@
+<button id="destroy-editor-button">Destroy editor</button>
+
+<div id="editor">
+	<h2>Heading 1</h2>
+	<p>Paragraph</p>
+</div>

+ 45 - 0
packages/ckeditor5-autosave/tests/manual/autosave.js

@@ -0,0 +1,45 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document, window, console */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Autosave from '../../src/autosave';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, Autosave ],
+		toolbar: [ 'heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo' ],
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ],
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		const destroyButton = document.getElementById( 'destroy-editor-button' );
+		destroyButton.addEventListener( 'click', () => editor.destroy() );
+
+		const autosave = editor.plugins.get( Autosave );
+		autosave.adapter = {
+			save() {
+				const data = editor.getData();
+
+				return saveEditorContentToDatabase( data );
+			}
+		};
+	} );
+
+function saveEditorContentToDatabase( data ) {
+	return new Promise( res => {
+		window.setTimeout( () => {
+			console.log( data );
+
+			res();
+		}, 1000 );
+	} );
+}

+ 5 - 0
packages/ckeditor5-autosave/tests/manual/autosave.md

@@ -0,0 +1,5 @@
+1. Play with the editor. You should logs of the editor's data in console with 1s timeout (it simulates the back-end). You should not see logs when you changes the selection.
+
+1. Type something and quickly try to reload the page. You should see something like this: `Reload site? Changes that you made may not be saved.`.
+
+1. Type something and quickly and click the `destroy editor` button. Most recent changes should be logged to the console.

+ 143 - 0
packages/ckeditor5-autosave/tests/throttle.js

@@ -0,0 +1,143 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import throttle from '../src/throttle';
+
+describe( 'throttle', () => {
+	const sandbox = sinon.sandbox.create( {
+		useFakeTimers: true
+	} );
+
+	beforeEach( () => {
+		sandbox.useFakeTimers( { now: 1000 } );
+	} );
+
+	afterEach( () => {
+		sandbox.restore();
+	} );
+
+	it( 'should run first call synchronously', () => {
+		const spy = sinon.spy();
+		const throttledFn = throttle( spy, 100 );
+
+		throttledFn();
+
+		sinon.assert.calledOnce( spy );
+
+		sandbox.clock.runAll();
+		sinon.assert.calledOnce( spy );
+	} );
+
+	it( 'should run next calls after specified amount of time', () => {
+		const spy = sinon.spy();
+		const throttledFn = throttle( spy, 100 );
+
+		throttledFn();
+		throttledFn();
+
+		sinon.assert.calledOnce( spy );
+
+		sandbox.clock.tick( 99 );
+
+		sinon.assert.calledOnce( spy );
+
+		sandbox.clock.tick( 1 );
+
+		sinon.assert.calledTwice( spy );
+	} );
+
+	it( 'should skip the call if another call is scheduled', () => {
+		const spy = sinon.spy();
+		const throttledFn = throttle( spy, 100 );
+
+		const isFirstInvoked = throttledFn();
+		const willSecondInvoke = throttledFn();
+		const willThirdInvoke = throttledFn();
+
+		expect( isFirstInvoked ).to.be.true;
+		expect( willSecondInvoke ).to.be.true;
+		expect( willThirdInvoke ).to.be.false;
+
+		sandbox.clock.runAll();
+		sinon.assert.calledTwice( spy );
+	} );
+
+	it( 'should call the next call after the specified amount of time', () => {
+		const spy = sinon.spy();
+		const throttledFn = throttle( spy, 100 );
+
+		throttledFn();
+		throttledFn();
+
+		sandbox.clock.tick( 50 );
+
+		sinon.assert.calledOnce( spy );
+
+		sandbox.clock.tick( 50 );
+
+		sinon.assert.calledTwice( spy );
+
+		throttledFn();
+
+		sandbox.clock.tick( 100 );
+
+		sinon.assert.calledThrice( spy );
+	} );
+
+	describe( 'flush', () => {
+		it( 'should be provide as a method on the throttled function', () => {
+			const spy = sinon.spy();
+			const throttledFn = throttle( spy, 100 );
+
+			expect( throttledFn.flush ).to.be.a( 'function' );
+		} );
+
+		it( 'should enable calling the throttled call immediately', () => {
+			const spy = sinon.spy();
+			const throttledFn = throttle( spy, 100 );
+
+			throttledFn();
+			throttledFn();
+
+			sinon.assert.calledOnce( spy );
+
+			throttledFn.flush();
+			sinon.assert.calledTwice( spy );
+
+			sandbox.clock.runAll();
+			sinon.assert.calledTwice( spy );
+		} );
+
+		it( 'should do nothing if there is no scheduled call', () => {
+			const spy = sinon.spy();
+			const throttledFn = throttle( spy, 100 );
+
+			throttledFn();
+
+			sinon.assert.calledOnce( spy );
+
+			throttledFn.flush();
+			sinon.assert.calledOnce( spy );
+
+			sandbox.clock.runAll();
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should enable calling after the flushed call', () => {
+			const spy = sinon.spy();
+			const throttledFn = throttle( spy, 100 );
+
+			throttledFn();
+			throttledFn();
+			throttledFn.flush();
+			throttledFn();
+
+			sinon.assert.calledThrice( spy );
+
+			sandbox.clock.runAll();
+			sinon.assert.calledThrice( spy );
+		} );
+	} );
+} );