From be5fd3a00471c2d708992d4481465ff750994848 Mon Sep 17 00:00:00 2001 From: Marcin Panek Date: Thu, 12 Jan 2023 11:31:27 +0100 Subject: [PATCH 1/3] Rewrite ckeditor5-autosave to TypeScript. --- .gitignore | 1 + packages/ckeditor5-autosave/package.json | 10 +- .../src/{autosave.js => autosave.ts} | 407 +++++++++--------- .../src/{index.js => index.ts} | 0 packages/ckeditor5-autosave/tsconfig.json | 7 + .../ckeditor5-autosave/tsconfig.release.json | 10 + packages/ckeditor5-core/src/index.ts | 2 +- 7 files changed, 231 insertions(+), 206 deletions(-) rename packages/ckeditor5-autosave/src/{autosave.js => autosave.ts} (53%) rename packages/ckeditor5-autosave/src/{index.js => index.ts} (100%) create mode 100644 packages/ckeditor5-autosave/tsconfig.json create mode 100644 packages/ckeditor5-autosave/tsconfig.release.json diff --git a/.gitignore b/.gitignore index 2be16cab519..66522005c72 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ yalc.lock # Ignore compiled TypeScript files. packages/ckeditor5-adapter-ckfinder/src/**/*.js packages/ckeditor5-alignment/src/**/*.js +packages/ckeditor5-autosave/src/**/*.js packages/ckeditor5-basic-styles/src/**/*.js packages/ckeditor5-block-quote/src/**/*.js packages/ckeditor5-clipboard/src/**/*.js diff --git a/packages/ckeditor5-autosave/package.json b/packages/ckeditor5-autosave/package.json index 76d662d5d0c..2cc7c3d73d3 100644 --- a/packages/ckeditor5-autosave/package.json +++ b/packages/ckeditor5-autosave/package.json @@ -10,7 +10,7 @@ "ckeditor5-plugin", "ckeditor5-dll" ], - "main": "src/index.js", + "main": "src/index.ts", "dependencies": { "ckeditor5": "^35.4.0", "lodash-es": "^4.17.15" @@ -22,6 +22,7 @@ "@ckeditor/ckeditor5-paragraph": "^35.4.0", "@ckeditor/ckeditor5-source-editing": "^35.4.0", "@ckeditor/ckeditor5-theme-lark": "^35.4.0", + "typescript": "^4.8.4", "webpack": "^5.58.1", "webpack-cli": "^4.9.0" }, @@ -40,13 +41,16 @@ }, "files": [ "lang", - "src", + "src/**/*.js", + "src/**/*.d.ts", "theme", "build", "ckeditor5-metadata.json", "CHANGELOG.md" ], "scripts": { - "dll:build": "webpack" + "dll:build": "webpack", + "build": "tsc -p ./tsconfig.release.json", + "postversion": "npm run build" } } diff --git a/packages/ckeditor5-autosave/src/autosave.js b/packages/ckeditor5-autosave/src/autosave.ts similarity index 53% rename from packages/ckeditor5-autosave/src/autosave.js rename to packages/ckeditor5-autosave/src/autosave.ts index 57ba8ad3e2b..3de313f07e5 100644 --- a/packages/ckeditor5-autosave/src/autosave.js +++ b/packages/ckeditor5-autosave/src/autosave.ts @@ -7,9 +7,18 @@ * @module autosave/autosave */ -import { Plugin, PendingActions } from 'ckeditor5/src/core'; -import { DomEmitterMixin, ObservableMixin, mix } from 'ckeditor5/src/utils'; -import { debounce } from 'lodash-es'; +import { + Plugin, + PendingActions, + type PluginDependencies, + type Editor, + type PendingAction, + type EditorDestroyEvent, + type EditorReadyEvent +} from 'ckeditor5/src/core'; +import { DomEmitterMixin, ObservableMixin, mix, type DomEmitter, type Emitter } from 'ckeditor5/src/utils'; +import type { Batch } from 'ckeditor5/src/engine'; +import { debounce, type DebouncedFunc } from 'lodash-es'; /* globals window */ @@ -21,159 +30,144 @@ import { debounce } from 'lodash-es'; * and `window#beforeunload` events and calls the * {@link module:autosave/autosave~AutosaveAdapter#save `config.autosave.save()`} function. * - * ClassicEditor - * .create( document.querySelector( '#editor' ), { - * plugins: [ ArticlePluginSet, Autosave ], - * toolbar: [ 'heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo' ], - * image: { - * toolbar: [ 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative' ], - * }, - * autosave: { - * save( editor ) { - * // The saveData() function must return a promise - * // which should be resolved when the data is successfully saved. - * return saveData( editor.getData() ); - * } - * } - * } ); + * ```ts + * ClassicEditor + * .create( document.querySelector( '#editor' ), { + * plugins: [ ArticlePluginSet, Autosave ], + * toolbar: [ 'heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo' ], + * image: { + * toolbar: [ 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative' ], + * }, + * autosave: { + * save( editor: Editor ) { + * // The saveData() function must return a promise + * // which should be resolved when the data is successfully saved. + * return saveData( editor.getData() ); + * } + * } + * } ); + * ``` * * Read more about this feature in the {@glink installation/advanced/saving-data#autosave-feature Autosave feature} * section of the {@glink installation/advanced/saving-data Saving and getting data}. - * - * @extends module:core/plugin~Plugin */ export default class Autosave extends Plugin { + /** + * The adapter is an object with a `save()` method. That method will be called whenever + * the data changes. It might be called some time after the change, + * since the event is throttled for performance reasons. + */ + + declare public adapter: AutosaveAdapter; + + /** + * The state of this plugin. + * + * The plugin can be in the following states: + * + * * synchronized – When all changes are saved. + * * waiting – When the plugin is waiting for other changes before calling `adapter#save()` and `config.autosave.save()`. + * * saving – When the provided save method is called and the plugin waits for the response. + * * error &ndash When the provided save method will throw an error. This state immediately changes to the `saving` state and + * the save method will be called again in the short period of time. + * + * @readonly + */ + declare public state: 'synchronized' | 'waiting' | 'saving' | 'error'; + + /** + * Debounced save method. The `save()` method is called the specified `waitingTime` after `debouncedSave()` is called, + * unless a new action happens in the meantime. + */ + private _debouncedSave: DebouncedFunc<( () => void )>; + + /** + * The last saved document version. + */ + private _lastDocumentVersion: number; + + /** + * Promise used for asynchronous save calls. + * + * Created to handle the autosave call to an external data source. It resolves when that call is finished. It is re-used if + * save is called before the promise has been resolved. It is set to `null` if there is no call in progress. + */ + private _savePromise: Promise | null; + + /** + * DOM emitter. + */ + private _domEmitter: DomEmitter; + + /** + * The configuration of this plugins. + */ + private _config: AutosaveConfig; + + /** + * Editor's pending actions manager. + */ + private _pendingActions: PendingActions; + + /** + * Informs whether there should be another autosave callback performed, immediately after current autosave callback finishes. + * + * This is set to `true` when there is a save request while autosave callback is already being processed + * and the model has changed since the last save. + */ + private _makeImmediateSave: boolean; + + /** + * An action that will be added to the pending action manager for actions happening in that plugin. + */ + declare private _action: PendingAction | null; + /** * @inheritDoc */ - static get pluginName() { + public static get pluginName(): 'Autosave' { return 'Autosave'; } /** * @inheritDoc */ - static get requires() { + public static get requires(): PluginDependencies { return [ PendingActions ]; } /** * @inheritDoc */ - constructor( editor ) { + constructor( editor: Editor ) { super( editor ); - const config = editor.config.get( 'autosave' ) || {}; + const config: AutosaveConfig = editor.config.get( 'autosave' )! || {}; // A minimum amount of time that needs to pass after the last action. // After that time the provided save callbacks are being called. const waitingTime = config.waitingTime || 1000; - /** - * The adapter is an object with a `save()` method. That method will be called whenever - * the data changes. It might be called some time after the change, - * since the event is throttled for performance reasons. - * - * @member {module:autosave/autosave~AutosaveAdapter} #adapter - */ - - /** - * The state of this plugin. - * - * The plugin can be in the following states: - * - * * synchronized – When all changes are saved. - * * waiting – When the plugin is waiting for other changes before calling `adapter#save()` and `config.autosave.save()`. - * * saving – When the provided save method is called and the plugin waits for the response. - * * error &ndash When the provided save method will throw an error. This state immediately changes to the `saving` state and - * the save method will be called again in the short period of time. - * - * @readonly - * @member {'synchronized'|'waiting'|'saving'} #state - */ this.set( 'state', 'synchronized' ); - - /** - * Debounced save method. The `save()` method is called the specified `waitingTime` after `debouncedSave()` is called, - * unless a new action happens in the meantime. - * - * @private - * @type {Function} - */ this._debouncedSave = debounce( this._save.bind( this ), waitingTime ); - - /** - * The last saved document version. - * - * @private - * @type {Number} - */ this._lastDocumentVersion = editor.model.document.version; - - /** - * Promise used for asynchronous save calls. - * - * Created to handle the autosave call to an external data source. It resolves when that call is finished. It is re-used if - * save is called before the promise has been resolved. It is set to `null` if there is no call in progress. - * - * @type {Promise|null} - * @private - */ this._savePromise = null; - - /** - * DOM emitter. - * - * @private - * @type {DomEmitterMixin} - */ this._domEmitter = Object.create( DomEmitterMixin ); - - /** - * The configuration of this plugins. - * - * @private - * @type {Object} - */ this._config = config; - - /** - * Editor's pending actions manager. - * - * @private - * @member {module:core/pendingactions~PendingActions} #_pendingActions - */ this._pendingActions = editor.plugins.get( PendingActions ); - - /** - * Informs whether there should be another autosave callback performed, immediately after current autosave callback finishes. - * - * This is set to `true` when there is a save request while autosave callback is already being processed - * and the model has changed since the last save. - * - * @private - * @type {Boolean} - */ this._makeImmediateSave = false; - - /** - * An action that will be added to the pending action manager for actions happening in that plugin. - * - * @private - * @member {Object} #_action - */ } /** * @inheritDoc */ - init() { + public init(): void { const editor = this.editor; const doc = editor.model.document; // Add the listener only after the editor is initialized to prevent firing save callback on data init. - this.listenTo( editor, 'ready', () => { - this.listenTo( doc, 'change:data', ( evt, batch ) => { + this.listenTo( editor, 'ready', () => { + this.listenTo( doc, 'change:data', ( evt, batch ) => { if ( !this._saveCallbacks.length ) { return; } @@ -200,14 +194,14 @@ export default class Autosave extends Plugin { // 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' } ); + 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 ) => { + this._domEmitter.listenTo( window as unknown as Emitter, 'beforeunload', ( evtInfo, domEvt ) => { if ( this._pendingActions.hasAny ) { - domEvt.returnValue = this._pendingActions.first.message; + domEvt.returnValue = this._pendingActions.first!.message; } } ); } @@ -215,7 +209,7 @@ export default class Autosave extends Plugin { /** * @inheritDoc */ - destroy() { + public override destroy(): void { // 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. @@ -227,9 +221,9 @@ export default class Autosave extends Plugin { * Immediately calls autosave callback. All previously queued (debounced) callbacks are cleared. If there is already an autosave * callback in progress, then the requested save will be performed immediately after the current callback finishes. * - * @returns {Promise} A promise that will be resolved when the autosave callback is finished. + * @returns A promise that will be resolved when the autosave callback is finished. */ - save() { + public save(): Promise { this._debouncedSave.cancel(); return this._save(); @@ -237,10 +231,8 @@ export default class Autosave extends Plugin { /** * Invokes the remaining `_save()` method call. - * - * @protected */ - _flush() { + protected _flush(): void { this._debouncedSave.flush(); } @@ -249,10 +241,9 @@ export default class Autosave extends Plugin { * the `_save()` method creates a pending action and calls the `adapter.save()` method. * It waits for the result and then removes the created pending action. * - * @private - * @returns {Promise} A promise that will be resolved when the autosave callback is finished. + * @returns A promise that will be resolved when the autosave callback is finished. */ - _save() { + private _save(): Promise { if ( this._savePromise ) { this._makeImmediateSave = this.editor.model.document.version > this._lastDocumentVersion; @@ -299,7 +290,7 @@ export default class Autosave extends Plugin { this._debouncedSave(); } else { this.state = 'synchronized'; - this._pendingActions.remove( this._action ); + this._pendingActions.remove( this._action! ); this._action = null; } } @@ -322,10 +313,8 @@ export default class Autosave extends Plugin { /** * Creates a pending action if it is not set already. - * - * @private */ - _setPendingAction() { + private _setPendingAction(): void { const t = this.editor.t; if ( !this._action ) { @@ -335,11 +324,8 @@ export default class Autosave extends Plugin { /** * Saves callbacks. - * - * @private - * @type {Array.} */ - get _saveCallbacks() { + private get _saveCallbacks(): Array<( editor: Editor ) => Promise> { const saveCallbacks = []; if ( this.adapter && this.adapter.save ) { @@ -360,86 +346,103 @@ mix( Autosave, ObservableMixin ); * An interface that requires the `save()` method. * * Used by {@link module:autosave/autosave~Autosave#adapter}. - * - * @interface module:autosave/autosave~AutosaveAdapter */ +type AutosaveAdapter = { -/** - * 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), - * so the autosave plugin will wait for that action before removing it from pending actions. - * - * @method #save - * @param {module:core/editor/editor~Editor} editor The editor instance. - * @returns {Promise.<*>} - */ - -/** - * The configuration of the {@link module:autosave/autosave~Autosave autosave feature}. - * - * Read more in {@link module:autosave/autosave~AutosaveConfig}. - * - * @member {module:autosave/autosave~AutosaveConfig} module:core/editor/editorconfig~EditorConfig#autosave - */ + /** + * 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), + * so the autosave plugin will wait for that action before removing it from pending actions. + */ + save: AutosaveConfig['save']; +}; /** * The configuration of the {@link module:autosave/autosave~Autosave autosave feature}. * - * ClassicEditor - * .create( editorElement, { - * autosave: { - * save( editor ) { - * // The saveData() function must return a promise - * // which should be resolved when the data is successfully saved. - * return saveData( editor.getData() ); - * } - * } - * } ); - * .then( ... ) - * .catch( ... ); + * ```ts + * ClassicEditor + * .create( editorElement, { + * autosave: { + * save( editor: Editor ) { + * // The saveData() function must return a promise + * // which should be resolved when the data is successfully saved. + * return saveData( editor.getData() ); + * } + * } + * } ); + * .then( ... ) + * .catch( ... ); + * ``` * * See {@link module:core/editor/editorconfig~EditorConfig all editor configuration options}. * * See also the demo of the {@glink installation/advanced/saving-data#autosave-feature autosave feature}. - * - * @interface AutosaveConfig */ +type AutosaveConfig = { -/** - * The callback to be executed when the data needs to be saved. - * - * This function must return a promise which should be resolved when the data is successfully saved. - * - * ClassicEditor - * .create( editorElement, { - * autosave: { - * save( editor ) { - * return saveData( editor.getData() ); - * } - * } - * } ); - * .then( ... ) - * .catch( ... ); - * - * @method module:autosave/autosave~AutosaveConfig#save - * @param {module:core/editor/editor~Editor} editor The editor instance. - * @returns {Promise.<*>} - */ + /** + * The callback to be executed when the data needs to be saved. + * + * This function must return a promise which should be resolved when the data is successfully saved. + * + * ```ts + * ClassicEditor + * .create( editorElement, { + * autosave: { + * save( editor: Editor ) { + * return saveData( editor.getData() ); + * } + * } + * } ); + * .then( ... ) + * .catch( ... ); + * ``` + */ + save: ( editor: Editor ) => Promise; -/** - * The minimum amount of time that needs to pass after the last action to call the provided callback. - * By default it is 1000 ms. - * - * ClassicEditor - * .create( editorElement, { - * autosave: { - * save( editor ) { - * return saveData( editor.getData() ); - * }, - * waitingTime: 2000 - * } - * } ); - * .then( ... ) - * .catch( ... ); - * - * @member {Number} module:autosave/autosave~AutosaveConfig#waitingTime - */ + /** + * The minimum amount of time that needs to pass after the last action to call the provided callback. + * By default it is 1000 ms. + * + * ```ts + * ClassicEditor + * .create( editorElement, { + * autosave: { + * save( editor: Editor ) { + * return saveData( editor.getData() ); + * }, + * waitingTime: 2000 + * } + * } ); + * .then( ... ) + * .catch( ... ); + * ``` + */ + waitingTime: number; +}; + +type DocumentChangeDataEvent = { + name: 'change:data'; + args: [ Batch ]; +}; + +type WindowBeforeUnloadEvent = { + name: 'beforeunload'; + args: [ BeforeUnloadEvent ]; +}; + +declare module '@ckeditor/ckeditor5-core' { + interface PluginsMap { + [ Autosave.pluginName ]: Autosave; + } + + interface EditorConfig { + + /** + * The configuration of the {@link module:autosave/autosave~Autosave autosave feature}. + * + * Read more in {@link module:autosave/autosave~AutosaveConfig}. + */ + autosave?: AutosaveConfig; + } +} diff --git a/packages/ckeditor5-autosave/src/index.js b/packages/ckeditor5-autosave/src/index.ts similarity index 100% rename from packages/ckeditor5-autosave/src/index.js rename to packages/ckeditor5-autosave/src/index.ts diff --git a/packages/ckeditor5-autosave/tsconfig.json b/packages/ckeditor5-autosave/tsconfig.json new file mode 100644 index 00000000000..9d4c891939c --- /dev/null +++ b/packages/ckeditor5-autosave/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "ckeditor5/tsconfig.json", + "include": [ + "src", + "../../typings" + ] +} diff --git a/packages/ckeditor5-autosave/tsconfig.release.json b/packages/ckeditor5-autosave/tsconfig.release.json new file mode 100644 index 00000000000..6d2d43909f9 --- /dev/null +++ b/packages/ckeditor5-autosave/tsconfig.release.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.release.json", + "include": [ + "./src/", + "../../typings/" + ], + "exclude": [ + "./tests/" + ] +} diff --git a/packages/ckeditor5-core/src/index.ts b/packages/ckeditor5-core/src/index.ts index e73afc5e248..7ce5538c928 100644 --- a/packages/ckeditor5-core/src/index.ts +++ b/packages/ckeditor5-core/src/index.ts @@ -16,7 +16,7 @@ export { PluginsMap } from './plugincollection'; export { default as Context } from './context'; export { default as ContextPlugin } from './contextplugin'; -export { default as Editor, type EditorReadyEvent } from './editor/editor'; +export { default as Editor, type EditorReadyEvent, type EditorDestroyEvent } from './editor/editor'; export type { EditorConfig, LanguageConfig, From 7de1bba07189a9518b9070e119d32963bf3f64d0 Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Wed, 18 Jan 2023 16:15:53 +0100 Subject: [PATCH 2/3] Misc changes in ckeditor5-autosave. --- packages/ckeditor5-autosave/src/autosave.ts | 53 ++++++++----------- .../ckeditor5-utils/src/dom/emittermixin.ts | 7 ++- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/packages/ckeditor5-autosave/src/autosave.ts b/packages/ckeditor5-autosave/src/autosave.ts index dc601089f4a..099726c8d9a 100644 --- a/packages/ckeditor5-autosave/src/autosave.ts +++ b/packages/ckeditor5-autosave/src/autosave.ts @@ -16,8 +16,11 @@ import { type EditorDestroyEvent, type EditorReadyEvent } from 'ckeditor5/src/core'; -import { DomEmitterMixin, ObservableMixin, mix, type DomEmitter, type Emitter } from 'ckeditor5/src/utils'; -import type { Batch } from 'ckeditor5/src/engine'; + +import { DomEmitterMixin, type DomEmitter } from 'ckeditor5/src/utils'; + +import type { DocumentChangeEvent } from 'ckeditor5/src/engine'; + import { debounce, type DebouncedFunc } from 'lodash-es'; /* globals window */ @@ -58,7 +61,7 @@ export default class Autosave extends Plugin { * since the event is throttled for performance reasons. */ - declare public adapter: AutosaveAdapter; + public adapter?: AutosaveAdapter; /** * The state of this plugin. @@ -71,6 +74,7 @@ export default class Autosave extends Plugin { * * error &ndash When the provided save method will throw an error. This state immediately changes to the `saving` state and * the save method will be called again in the short period of time. * + * @observable * @readonly */ declare public state: 'synchronized' | 'waiting' | 'saving' | 'error'; @@ -120,7 +124,7 @@ export default class Autosave extends Plugin { /** * An action that will be added to the pending action manager for actions happening in that plugin. */ - declare private _action: PendingAction | null; + private _action: PendingAction | null = null; /** * @inheritDoc @@ -142,17 +146,18 @@ export default class Autosave extends Plugin { constructor( editor: Editor ) { super( editor ); - const config: AutosaveConfig = editor.config.get( 'autosave' )! || {}; + const config: AutosaveConfig = editor.config.get( 'autosave' ) || {}; // A minimum amount of time that needs to pass after the last action. // After that time the provided save callbacks are being called. const waitingTime = config.waitingTime || 1000; this.set( 'state', 'synchronized' ); + this._debouncedSave = debounce( this._save.bind( this ), waitingTime ); this._lastDocumentVersion = editor.model.document.version; this._savePromise = null; - this._domEmitter = Object.create( DomEmitterMixin ); + this._domEmitter = new ( DomEmitterMixin() )(); this._config = config; this._pendingActions = editor.plugins.get( PendingActions ); this._makeImmediateSave = false; @@ -167,7 +172,7 @@ export default class Autosave extends Plugin { // Add the listener only after the editor is initialized to prevent firing save callback on data init. this.listenTo( editor, 'ready', () => { - this.listenTo( doc, 'change:data', ( evt, batch ) => { + this.listenTo( doc, 'change:data', ( evt, batch ) => { if ( !this._saveCallbacks.length ) { return; } @@ -199,7 +204,7 @@ export default class Autosave extends Plugin { // 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 as unknown as Emitter, 'beforeunload', ( evtInfo, domEvt ) => { + this._domEmitter.listenTo( window, 'beforeunload', ( evtInfo, domEvt ) => { if ( this._pendingActions.hasAny ) { domEvt.returnValue = this._pendingActions.first!.message; } @@ -232,7 +237,7 @@ export default class Autosave extends Plugin { /** * Invokes the remaining `_save()` method call. */ - protected _flush(): void { + private _flush(): void { this._debouncedSave.flush(); } @@ -325,8 +330,8 @@ export default class Autosave extends Plugin { /** * Saves callbacks. */ - private get _saveCallbacks(): Array<( editor: Editor ) => Promise> { - const saveCallbacks = []; + private get _saveCallbacks(): Array<( editor: Editor ) => Promise> { + const saveCallbacks: Array<( editor: Editor ) => Promise> = []; if ( this.adapter && this.adapter.save ) { saveCallbacks.push( this.adapter.save ); @@ -340,21 +345,19 @@ export default class Autosave extends Plugin { } } -mix( Autosave, ObservableMixin ); - /** * An interface that requires the `save()` method. * * Used by {@link module:autosave/autosave~Autosave#adapter}. */ -type AutosaveAdapter = { +export interface AutosaveAdapter { /** * 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), * so the autosave plugin will wait for that action before removing it from pending actions. */ - save: AutosaveConfig['save']; -}; + save( editor: Editor ): Promise; +} /** * The configuration of the {@link module:autosave/autosave~Autosave autosave feature}. @@ -378,7 +381,7 @@ type AutosaveAdapter = { * * See also the demo of the {@glink installation/getting-started/getting-and-setting-data#autosave-feature autosave feature}. */ -type AutosaveConfig = { +export interface AutosaveConfig { /** * The callback to be executed when the data needs to be saved. @@ -398,7 +401,7 @@ type AutosaveConfig = { * .catch( ... ); * ``` */ - save: ( editor: Editor ) => Promise; + save?: ( editor: Editor ) => Promise; /** * The minimum amount of time that needs to pass after the last action to call the provided callback. @@ -418,18 +421,8 @@ type AutosaveConfig = { * .catch( ... ); * ``` */ - waitingTime: number; -}; - -type DocumentChangeDataEvent = { - name: 'change:data'; - args: [ Batch ]; -}; - -type WindowBeforeUnloadEvent = { - name: 'beforeunload'; - args: [ BeforeUnloadEvent ]; -}; + waitingTime?: number; +} declare module '@ckeditor/ckeditor5-core' { interface PluginsMap { diff --git a/packages/ckeditor5-utils/src/dom/emittermixin.ts b/packages/ckeditor5-utils/src/dom/emittermixin.ts index dcb1b4b4a08..ad33841eb74 100644 --- a/packages/ckeditor5-utils/src/dom/emittermixin.ts +++ b/packages/ckeditor5-utils/src/dom/emittermixin.ts @@ -380,6 +380,9 @@ function getProxyEmitterId( node: Node | Window, options: { [ option: string ]: return id; } +export interface DomEventMap extends HTMLElementEventMap, WindowEventMap { +} + /** * Interface representing classes which mix in {@link module:utils/dom/emittermixin~DomEmitterMixin}. * @@ -407,10 +410,10 @@ export interface DomEmitter extends Emitter { * @param options.usePassive Indicates that the function specified by listener will never call preventDefault() * and prevents blocking browser's main thread by this event handler. */ - listenTo( + listenTo( emitter: Node | Window, event: K, - callback: ( this: this, ev: EventInfo, event: HTMLElementEventMap[ K ] ) => void, + callback: ( this: this, ev: EventInfo, event: DomEventMap[ K ] ) => void, options?: CallbackOptions & { readonly useCapture?: boolean; readonly usePassive?: boolean } ): void; From f00b8e36984fed9b4ac9c0380e4ab7efe18a301a Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Wed, 18 Jan 2023 16:16:35 +0100 Subject: [PATCH 3/3] Added ckeditor5-autosave/_src. --- packages/ckeditor5-autosave/_src/autosave.js | 445 +++++++++++++++++++ packages/ckeditor5-autosave/_src/index.js | 10 + 2 files changed, 455 insertions(+) create mode 100644 packages/ckeditor5-autosave/_src/autosave.js create mode 100644 packages/ckeditor5-autosave/_src/index.js diff --git a/packages/ckeditor5-autosave/_src/autosave.js b/packages/ckeditor5-autosave/_src/autosave.js new file mode 100644 index 00000000000..c39d1a1e106 --- /dev/null +++ b/packages/ckeditor5-autosave/_src/autosave.js @@ -0,0 +1,445 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module autosave/autosave + */ + +import { Plugin, PendingActions } from 'ckeditor5/src/core'; +import { DomEmitterMixin, ObservableMixin, mix } from 'ckeditor5/src/utils'; +import { debounce } from 'lodash-es'; + +/* globals window */ + +/** + * The {@link module:autosave/autosave~Autosave} plugin allows you to automatically save the data (e.g. send it to the server) + * when needed (when the user changed the content). + * + * It listens to the {@link module:engine/model/document~Document#event:change:data `editor.model.document#change:data`} + * and `window#beforeunload` events and calls the + * {@link module:autosave/autosave~AutosaveAdapter#save `config.autosave.save()`} function. + * + * ClassicEditor + * .create( document.querySelector( '#editor' ), { + * plugins: [ ArticlePluginSet, Autosave ], + * toolbar: [ 'heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo' ], + * image: { + * toolbar: [ 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative' ], + * }, + * autosave: { + * save( editor ) { + * // The saveData() function must return a promise + * // which should be resolved when the data is successfully saved. + * return saveData( editor.getData() ); + * } + * } + * } ); + * + * Read more about this feature in the {@glink installation/getting-started/getting-and-setting-data#autosave-feature Autosave feature} + * section of the {@glink installation/getting-started/getting-and-setting-data Saving and getting data}. + * + * @extends module:core/plugin~Plugin + */ +export default class Autosave extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'Autosave'; + } + + /** + * @inheritDoc + */ + static get requires() { + return [ PendingActions ]; + } + + /** + * @inheritDoc + */ + constructor( editor ) { + super( editor ); + + const config = editor.config.get( 'autosave' ) || {}; + + // A minimum amount of time that needs to pass after the last action. + // After that time the provided save callbacks are being called. + const waitingTime = config.waitingTime || 1000; + + /** + * The adapter is an object with a `save()` method. That method will be called whenever + * the data changes. It might be called some time after the change, + * since the event is throttled for performance reasons. + * + * @member {module:autosave/autosave~AutosaveAdapter} #adapter + */ + + /** + * The state of this plugin. + * + * The plugin can be in the following states: + * + * * synchronized – When all changes are saved. + * * waiting – When the plugin is waiting for other changes before calling `adapter#save()` and `config.autosave.save()`. + * * saving – When the provided save method is called and the plugin waits for the response. + * * error &ndash When the provided save method will throw an error. This state immediately changes to the `saving` state and + * the save method will be called again in the short period of time. + * + * @readonly + * @member {'synchronized'|'waiting'|'saving'} #state + */ + this.set( 'state', 'synchronized' ); + + /** + * Debounced save method. The `save()` method is called the specified `waitingTime` after `debouncedSave()` is called, + * unless a new action happens in the meantime. + * + * @private + * @type {Function} + */ + this._debouncedSave = debounce( this._save.bind( this ), waitingTime ); + + /** + * The last saved document version. + * + * @private + * @type {Number} + */ + this._lastDocumentVersion = editor.model.document.version; + + /** + * Promise used for asynchronous save calls. + * + * Created to handle the autosave call to an external data source. It resolves when that call is finished. It is re-used if + * save is called before the promise has been resolved. It is set to `null` if there is no call in progress. + * + * @type {Promise|null} + * @private + */ + this._savePromise = null; + + /** + * DOM emitter. + * + * @private + * @type {DomEmitterMixin} + */ + this._domEmitter = Object.create( DomEmitterMixin ); + + /** + * The configuration of this plugins. + * + * @private + * @type {Object} + */ + this._config = config; + + /** + * Editor's pending actions manager. + * + * @private + * @member {module:core/pendingactions~PendingActions} #_pendingActions + */ + this._pendingActions = editor.plugins.get( PendingActions ); + + /** + * Informs whether there should be another autosave callback performed, immediately after current autosave callback finishes. + * + * This is set to `true` when there is a save request while autosave callback is already being processed + * and the model has changed since the last save. + * + * @private + * @type {Boolean} + */ + this._makeImmediateSave = false; + + /** + * An action that will be added to the pending action manager for actions happening in that plugin. + * + * @private + * @member {Object} #_action + */ + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const doc = editor.model.document; + + // Add the listener only after the editor is initialized to prevent firing save callback on data init. + this.listenTo( editor, 'ready', () => { + this.listenTo( doc, 'change:data', ( evt, batch ) => { + if ( !this._saveCallbacks.length ) { + return; + } + + if ( !batch.isLocal ) { + return; + } + + if ( this.state === 'synchronized' ) { + this.state = 'waiting'; + // Set pending action already when we are waiting for the autosave callback. + this._setPendingAction(); + } + + if ( this.state === 'waiting' ) { + this._debouncedSave(); + } + + // If the plugin is in `saving` state, it will change its state later basing on the `document.version`. + // If the `document.version` will be higher than stored `#_lastDocumentVersion`, then it means, that some `change:data` + // event has fired in the meantime. + } ); + } ); + + // 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.hasAny ) { + 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(); + } + + /** + * Immediately calls autosave callback. All previously queued (debounced) callbacks are cleared. If there is already an autosave + * callback in progress, then the requested save will be performed immediately after the current callback finishes. + * + * @returns {Promise} A promise that will be resolved when the autosave callback is finished. + */ + save() { + this._debouncedSave.cancel(); + + return this._save(); + } + + /** + * Invokes the remaining `_save()` method call. + * + * @protected + */ + _flush() { + this._debouncedSave.flush(); + } + + /** + * If the adapter is set and a new document version exists, + * the `_save()` method creates a pending action and calls the `adapter.save()` method. + * It waits for the result and then removes the created pending action. + * + * @private + * @returns {Promise} A promise that will be resolved when the autosave callback is finished. + */ + _save() { + if ( this._savePromise ) { + this._makeImmediateSave = this.editor.model.document.version > this._lastDocumentVersion; + + return this._savePromise; + } + + // Make sure there is a pending action (in case if `_save()` was called through manual `save()` call). + this._setPendingAction(); + + this.state = 'saving'; + this._lastDocumentVersion = this.editor.model.document.version; + + // Wait one promise cycle to be sure that save callbacks are not called inside a conversion or when the editor's state changes. + this._savePromise = Promise.resolve() + // Make autosave callback. + .then( () => Promise.all( + this._saveCallbacks.map( cb => cb( this.editor ) ) + ) ) + // When the autosave callback is finished, always clear `this._savePromise`, no matter if it was successful or not. + .finally( () => { + this._savePromise = null; + } ) + // If the save was successful, we have three scenarios: + // + // 1. If a save was requested when an autosave callback was already processed, we need to immediately call + // another autosave callback. In this case, `this._savePromise` will not be resolved until the next callback is done. + // 2. Otherwise, if changes happened to the model, make a delayed autosave callback (like the change just happened). + // 3. If no changes happened to the model, return to the `synchronized` state. + .then( () => { + if ( this._makeImmediateSave ) { + this._makeImmediateSave = false; + + // Start another autosave callback. Return a promise that will be resolved after the new autosave callback. + // This way promises returned by `_save()` will not be resolved until all changes are saved. + // + // If `save()` was called when another (most often automatic) autosave callback was already processed, + // the promise returned by `save()` call will be resolved only after new changes have been saved. + // + // Note that it would not work correctly if `this._savePromise` is not cleared. + return this._save(); + } else { + if ( this.editor.model.document.version > this._lastDocumentVersion ) { + this.state = 'waiting'; + this._debouncedSave(); + } else { + this.state = 'synchronized'; + this._pendingActions.remove( this._action ); + this._action = null; + } + } + } ) + // In case of an error, retry the autosave callback after a delay (and also throw the original error). + .catch( err => { + // Change state to `error` so that listeners handling autosave error can be called. + this.state = 'error'; + // Then, immediately change to the `saving` state as described above. + // Being in the `saving` state ensures that the autosave callback won't be delayed further by the `change:data` listener. + this.state = 'saving'; + + this._debouncedSave(); + + throw err; + } ); + + return this._savePromise; + } + + /** + * Creates a pending action if it is not set already. + * + * @private + */ + _setPendingAction() { + const t = this.editor.t; + + if ( !this._action ) { + this._action = this._pendingActions.add( t( 'Saving changes' ) ); + } + } + + /** + * Saves callbacks. + * + * @private + * @type {Array.} + */ + get _saveCallbacks() { + const saveCallbacks = []; + + if ( this.adapter && this.adapter.save ) { + saveCallbacks.push( this.adapter.save ); + } + + if ( this._config.save ) { + saveCallbacks.push( this._config.save ); + } + + return saveCallbacks; + } +} + +mix( Autosave, ObservableMixin ); + +/** + * An interface that requires the `save()` method. + * + * Used by {@link module:autosave/autosave~Autosave#adapter}. + * + * @interface module:autosave/autosave~AutosaveAdapter + */ + +/** + * 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), + * so the autosave plugin will wait for that action before removing it from pending actions. + * + * @method #save + * @param {module:core/editor/editor~Editor} editor The editor instance. + * @returns {Promise.<*>} + */ + +/** + * The configuration of the {@link module:autosave/autosave~Autosave autosave feature}. + * + * Read more in {@link module:autosave/autosave~AutosaveConfig}. + * + * @member {module:autosave/autosave~AutosaveConfig} module:core/editor/editorconfig~EditorConfig#autosave + */ + +/** + * The configuration of the {@link module:autosave/autosave~Autosave autosave feature}. + * + * ClassicEditor + * .create( editorElement, { + * autosave: { + * save( editor ) { + * // The saveData() function must return a promise + * // which should be resolved when the data is successfully saved. + * return saveData( editor.getData() ); + * } + * } + * } ); + * .then( ... ) + * .catch( ... ); + * + * See {@link module:core/editor/editorconfig~EditorConfig all editor configuration options}. + * + * See also the demo of the {@glink installation/getting-started/getting-and-setting-data#autosave-feature autosave feature}. + * + * @interface AutosaveConfig + */ + +/** + * The callback to be executed when the data needs to be saved. + * + * This function must return a promise which should be resolved when the data is successfully saved. + * + * ClassicEditor + * .create( editorElement, { + * autosave: { + * save( editor ) { + * return saveData( editor.getData() ); + * } + * } + * } ); + * .then( ... ) + * .catch( ... ); + * + * @method module:autosave/autosave~AutosaveConfig#save + * @param {module:core/editor/editor~Editor} editor The editor instance. + * @returns {Promise.<*>} + */ + +/** + * The minimum amount of time that needs to pass after the last action to call the provided callback. + * By default it is 1000 ms. + * + * ClassicEditor + * .create( editorElement, { + * autosave: { + * save( editor ) { + * return saveData( editor.getData() ); + * }, + * waitingTime: 2000 + * } + * } ); + * .then( ... ) + * .catch( ... ); + * + * @member {Number} module:autosave/autosave~AutosaveConfig#waitingTime + */ diff --git a/packages/ckeditor5-autosave/_src/index.js b/packages/ckeditor5-autosave/_src/index.js new file mode 100644 index 00000000000..dc9e9104bc8 --- /dev/null +++ b/packages/ckeditor5-autosave/_src/index.js @@ -0,0 +1,10 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module autosave + */ + +export { default as Autosave } from './autosave';