autosave.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module autosave/autosave
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import PendingActions from '@ckeditor/ckeditor5-core/src/pendingactions';
  10. import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin';
  11. import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
  12. import mix from '@ckeditor/ckeditor5-utils/src/mix';
  13. import { debounce } from 'lodash-es';
  14. /* globals window */
  15. /**
  16. * The {@link module:autosave/autosave~Autosave} plugin allows you to automatically save the data (e.g. send it to the server)
  17. * when needed (when the user changed the content).
  18. *
  19. * It listens to the {@link module:engine/model/document~Document#event:change:data `editor.model.document#change:data`}
  20. * and `window#beforeunload` events and calls the
  21. * {@link module:autosave/autosave~AutosaveAdapter#save `config.autosave.save()`} function.
  22. *
  23. * ClassicEditor
  24. * .create( document.querySelector( '#editor' ), {
  25. * plugins: [ ArticlePluginSet, Autosave ],
  26. * toolbar: [ 'heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo' ],
  27. * image: {
  28. * toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ],
  29. * },
  30. * autosave: {
  31. * save( editor ) {
  32. * // The saveData() function must return a promise
  33. * // which should be resolved when the data is successfully saved.
  34. * return saveData( editor.getData() );
  35. * }
  36. * }
  37. * } );
  38. *
  39. * Read more about this feature in the {@glink builds/guides/integration/saving-data#autosave-feature Autosave feature}
  40. * section of the {@glink builds/guides/integration/saving-data Saving and getting data}.
  41. *
  42. * @extends module:core/plugin~Plugin
  43. */
  44. export default class Autosave extends Plugin {
  45. /**
  46. * @inheritDoc
  47. */
  48. static get pluginName() {
  49. return 'Autosave';
  50. }
  51. /**
  52. * @inheritDoc
  53. */
  54. static get requires() {
  55. return [ PendingActions ];
  56. }
  57. /**
  58. * @inheritDoc
  59. */
  60. constructor( editor ) {
  61. super( editor );
  62. const config = editor.config.get( 'autosave' ) || {};
  63. // A minimum amount of time that needs to pass after the last action.
  64. // After that time the provided save callbacks are being called.
  65. const waitingTime = config.waitingTime || 1000;
  66. /**
  67. * The adapter is an object with a `save()` method. That method will be called whenever
  68. * the data changes. It might be called some time after the change,
  69. * since the event is throttled for performance reasons.
  70. *
  71. * @member {module:autosave/autosave~AutosaveAdapter} #adapter
  72. */
  73. /**
  74. * The state of this plugin.
  75. *
  76. * The plugin can be in the following states:
  77. *
  78. * * synchronized – When all changes are saved.
  79. * * waiting – When the plugin is waiting for other changes before calling `adapter#save()` and `config.autosave.save()`.
  80. * * saving – When the provided save method is called and the plugin waits for the response.
  81. * * error &ndash When the provided save method will throw an error. This state immediately changes to the `saving` state and
  82. * the save method will be called again in the short period of time.
  83. *
  84. * @member {'synchronized'|'waiting'|'saving'} #state
  85. */
  86. this.set( 'state', 'synchronized' );
  87. /**
  88. * Debounced save method. The `save()` method is called the specified `waitingTime` after `debouncedSave()` is called,
  89. * unless a new action happens in the meantime.
  90. *
  91. * @private
  92. * @type {Function}
  93. */
  94. this._debouncedSave = debounce( this._save.bind( this ), waitingTime );
  95. /**
  96. * The last document version.
  97. *
  98. * @private
  99. * @type {Number}
  100. */
  101. this._lastDocumentVersion = editor.model.document.version;
  102. /**
  103. * DOM emitter.
  104. *
  105. * @private
  106. * @type {DomEmitterMixin}
  107. */
  108. this._domEmitter = Object.create( DomEmitterMixin );
  109. /**
  110. * The configuration of this plugins.
  111. *
  112. * @private
  113. * @type {Object}
  114. */
  115. this._config = config;
  116. /**
  117. * An action that will be added to pending action manager for actions happening in that plugin.
  118. *
  119. * @private
  120. * @member {Object} #_action
  121. */
  122. /**
  123. * Editor's pending actions manager.
  124. *
  125. * @private
  126. * @member {module:core/pendingactions~PendingActions} #_pendingActions
  127. */
  128. }
  129. /**
  130. * @inheritDoc
  131. */
  132. init() {
  133. const editor = this.editor;
  134. const doc = editor.model.document;
  135. const t = editor.t;
  136. this._pendingActions = editor.plugins.get( PendingActions );
  137. this.listenTo( doc, 'change:data', () => {
  138. if ( !this._saveCallbacks.length ) {
  139. return;
  140. }
  141. if ( this.state == 'synchronized' ) {
  142. this._action = this._pendingActions.add( t( 'Saving changes' ) );
  143. this.state = 'waiting';
  144. this._debouncedSave();
  145. }
  146. else if ( this.state == 'waiting' ) {
  147. this._debouncedSave();
  148. }
  149. // If the plugin is in `saving` state, it will change its state later basing on the `document.version`.
  150. // If the `document.version` will be higher than stored `#_lastDocumentVersion`, then it means, that some `change:data`
  151. // event has fired in the meantime.
  152. } );
  153. // Flush on the editor's destroy listener with the highest priority to ensure that
  154. // `editor.getData()` will be called before plugins are destroyed.
  155. this.listenTo( editor, 'destroy', () => this._flush(), { priority: 'highest' } );
  156. // It's not possible to easy test it because karma uses `beforeunload` event
  157. // to warn before full page reload and this event cannot be dispatched manually.
  158. /* istanbul ignore next */
  159. this._domEmitter.listenTo( window, 'beforeunload', ( evtInfo, domEvt ) => {
  160. if ( this._pendingActions.hasAny ) {
  161. domEvt.returnValue = this._pendingActions.first.message;
  162. }
  163. } );
  164. }
  165. /**
  166. * @inheritDoc
  167. */
  168. destroy() {
  169. // There's no need for canceling or flushing the throttled save, as
  170. // it's done on the editor's destroy event with the highest priority.
  171. this._domEmitter.stopListening();
  172. super.destroy();
  173. }
  174. /**
  175. * Invokes the remaining `_save()` method call.
  176. *
  177. * @protected
  178. */
  179. _flush() {
  180. this._debouncedSave.flush();
  181. }
  182. /**
  183. * If the adapter is set and a new document version exists,
  184. * the `_save()` method creates a pending action and calls the `adapter.save()` method.
  185. * It waits for the result and then removes the created pending action.
  186. *
  187. * @private
  188. */
  189. _save() {
  190. const version = this.editor.model.document.version;
  191. // Change may not produce an operation, so the document's version
  192. // can be the same after that change.
  193. if (
  194. version < this._lastDocumentVersion ||
  195. this.editor.state === 'initializing'
  196. ) {
  197. this._debouncedSave.cancel();
  198. return;
  199. }
  200. this._lastDocumentVersion = version;
  201. this.state = 'saving';
  202. // Wait one promise cycle to be sure that save callbacks are not called
  203. // inside a conversion or when the editor's state changes.
  204. Promise.resolve()
  205. .then( () => Promise.all(
  206. this._saveCallbacks.map( cb => cb( this.editor ) )
  207. ) )
  208. // In case of an error re-try the save later and throw the original error.
  209. // Being in the `saving` state ensures that the debounced save action
  210. // won't be delayed further by the `change:data` event listener.
  211. .catch( err => {
  212. this.state = 'error';
  213. // Change immediately to the `saving` state so the `change:state` event will be fired.
  214. this.state = 'saving';
  215. this._debouncedSave();
  216. throw err;
  217. } )
  218. .then( () => {
  219. if ( this.editor.model.document.version > this._lastDocumentVersion ) {
  220. this.state = 'waiting';
  221. this._debouncedSave();
  222. } else {
  223. this.state = 'synchronized';
  224. this._pendingActions.remove( this._action );
  225. this._action = null;
  226. }
  227. } );
  228. }
  229. /**
  230. * Saves callbacks.
  231. *
  232. * @private
  233. * @type {Array.<Function>}
  234. */
  235. get _saveCallbacks() {
  236. const saveCallbacks = [];
  237. if ( this.adapter && this.adapter.save ) {
  238. saveCallbacks.push( this.adapter.save );
  239. }
  240. if ( this._config.save ) {
  241. saveCallbacks.push( this._config.save );
  242. }
  243. return saveCallbacks;
  244. }
  245. }
  246. mix( Autosave, ObservableMixin );
  247. /**
  248. * An interface that requires the `save()` method.
  249. *
  250. * Used by {@link module:autosave/autosave~Autosave#adapter}.
  251. *
  252. * @interface module:autosave/autosave~AutosaveAdapter
  253. */
  254. /**
  255. * The method that will be called when the data changes. It should return a promise (e.g. in case of saving content to the database),
  256. * so the autosave plugin will wait for that action before removing it from pending actions.
  257. *
  258. * @method #save
  259. * @param {module:core/editor/editor~Editor} editor The editor instance.
  260. * @returns {Promise.<*>}
  261. */
  262. /**
  263. * The configuration of the {@link module:autosave/autosave~Autosave autosave feature}.
  264. *
  265. * Read more in {@link module:autosave/autosave~AutosaveConfig}.
  266. *
  267. * @member {module:autosave/autosave~AutosaveConfig} module:core/editor/editorconfig~EditorConfig#autosave
  268. */
  269. /**
  270. * The configuration of the {@link module:autosave/autosave~Autosave autosave feature}.
  271. *
  272. * ClassicEditor
  273. * .create( editorElement, {
  274. * autosave: {
  275. * save( editor ) {
  276. * // The saveData() function must return a promise
  277. * // which should be resolved when the data is successfully saved.
  278. * return saveData( editor.getData() );
  279. * }
  280. * }
  281. * } );
  282. * .then( ... )
  283. * .catch( ... );
  284. *
  285. * See {@link module:core/editor/editorconfig~EditorConfig all editor configuration options}.
  286. *
  287. * See also the demo of the {@glink builds/guides/integration/saving-data#autosave-feature autosave feature}.
  288. *
  289. * @interface AutosaveConfig
  290. */
  291. /**
  292. * The callback to be executed when the data needs to be saved.
  293. *
  294. * This function must return a promise which should be resolved when the data is successfully saved.
  295. *
  296. * ClassicEditor
  297. * .create( editorElement, {
  298. * autosave: {
  299. * save( editor ) {
  300. * return saveData( editor.getData() );
  301. * }
  302. * }
  303. * } );
  304. * .then( ... )
  305. * .catch( ... );
  306. *
  307. * @method module:autosave/autosave~AutosaveConfig#save
  308. * @param {module:core/editor/editor~Editor} editor The editor instance.
  309. * @returns {Promise.<*>}
  310. */
  311. /**
  312. * The minimum amount of time that needs to pass after the last action to call the provided callback.
  313. * By default it is 1000 ms.
  314. *
  315. * ClassicEditor
  316. * .create( editorElement, {
  317. * autosave: {
  318. * save( editor ) {
  319. * return saveData( editor.getData() );
  320. * },
  321. * waitingTime: 2000
  322. * }
  323. * } );
  324. * .then( ... )
  325. * .catch( ... );
  326. *
  327. * @member {Number} module:autosave/autosave~AutosaveConfig#waitingTime
  328. */