Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autosave plugin should ignore remote changes #11016

Merged
merged 5 commits into from
Jan 11, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 110 additions & 33 deletions packages/ckeditor5-autosave/src/autosave.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,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.
*
* @readonly
* @member {'synchronized'|'waiting'|'saving'} #state
*/
this.set( 'state', 'synchronized' );
Expand All @@ -109,6 +110,17 @@ export default class Autosave extends Plugin {
*/
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 resolved. It is set to `null` if there is no call in progress.
*
* @type {Promise|null}
* @private
*/
this._savePromise = null;

/**
* DOM emitter.
*
Expand All @@ -126,17 +138,26 @@ export default class Autosave extends Plugin {
this._config = config;

/**
* An action that will be added to pending action manager for actions happening in that plugin.
* Editor's pending actions manager.
*
* @private
* @member {Object} #_action
* @member {module:core/pendingactions~PendingActions} #_pendingActions
*/
this._pendingActions = editor.plugins.get( PendingActions );

/**
* Editor's pending actions manager.
* The document version
*
* @private
* @member {module:core/pendingactions~PendingActions} #_pendingActions
* @type {Boolean}
*/
this._manualSaveVersion = null;

/**
* An action that will be added to pending action manager for actions happening in that plugin.
*
* @private
* @member {Object} #_action
*/
}

Expand All @@ -146,25 +167,25 @@ export default class Autosave extends Plugin {
init() {
const editor = this.editor;
const doc = editor.model.document;
const t = editor.t;

this._pendingActions = editor.plugins.get( PendingActions );

// 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', () => {
this.listenTo( doc, 'change:data', ( evt, batch ) => {
if ( !this._saveCallbacks.length ) {
return;
}

if ( this.state == 'synchronized' ) {
this._action = this._pendingActions.add( t( 'Saving changes' ) );
this.state = 'waiting';
if ( !batch.isLocal ) {
return;
}

this._debouncedSave();
if ( this.state === 'synchronized' ) {
this.state = 'waiting';
// Set pending action already when we are waiting for the autosave callback.
this._setPendingAction();
}

else if ( this.state == 'waiting' ) {
if ( this.state === 'waiting' ) {
this._debouncedSave();
}

Expand Down Expand Up @@ -200,11 +221,15 @@ export default class Autosave extends Plugin {
}

/**
* Calls autosave plugin callback and cancels any delayed callbacks that may have been already triggered.
* 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();
this._save();

return this._save( true );
}

/**
Expand All @@ -222,39 +247,91 @@ export default class Autosave extends Plugin {
* It waits for the result and then removes the created pending action.
*
* @private
scofalik marked this conversation as resolved.
Show resolved Hide resolved
* @param {Boolean} [manualSave=false] Whether the save was triggered by another plugin (`true`) or by the autosave plugin, after
* the model has changed (`false`).
* @returns {Promise} A promise that will be resolved when the autosave callback is finished.
*/
_save() {
_save( manualSave = false ) {
if ( this._savePromise ) {
if ( manualSave ) {
scofalik marked this conversation as resolved.
Show resolved Hide resolved
this._manualSaveVersion = this.editor.model.document.version;
}

return this._savePromise;
scofalik marked this conversation as resolved.
Show resolved Hide resolved
}

// 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.
Promise.resolve()
// 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 ) )
) )
// In case of an error re-try the save later and throw the original error.
// Being in the `saving` state ensures that the debounced save action
// won't be delayed further by the `change:data` event listener.
// 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 (`save()`) during the callback, we need to immediately call another autosave callback.
// In this case, `this._savePromise` won't be cleared and won't 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._manualSaveVersion !== null && this._manualSaveVersion > this._lastDocumentVersion ) {
this._manualSaveVersion = null;

// Start another autosave callback. Return a promise that will be resolved after the new autosave callback.
// This way promises returned by `_save()` won't 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 has 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';
// Change immediately to the `saving` state so the `change:state` event will be fired.
// 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;
} )
.then( () => {
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;
}
} );

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

/**
Expand Down
Loading