fix(undo): Move undo/redo to session, properly undo all changes

Summary:
- Simplify undoManager to just maintain the undo/redo history items
- DraftEditingSession manages snapshotting state of draft, hack allows it to also save selection (still hoping to eventually put selection in body HTML as markers)
- Switch from `debounce` to `throttle` style behavior so typing for along time followed by undo doesn't undo away your entire block.

This resolves two issues:
+ Changes to participant fields are no longer undoable because they go straight to the session.
+ Changes to metadata weren't undoable.

Test Plan: Tests WIP

Reviewers: evan, juan

Reviewed By: juan

Differential Revision: https://phab.nylas.com/D2956
This commit is contained in:
Ben Gotow 2016-05-24 11:48:33 -07:00
parent eabdd78935
commit bf955891d9
8 changed files with 251 additions and 339 deletions

View file

@ -15,8 +15,6 @@ import {DropZone, ScrollRegion, Contenteditable} from 'nylas-component-kit';
* @param {string} props.body - Html string with the draft content to be
* rendered by the editor
* @param {string} props.draftClientId - Id of the draft being currently edited
* @param {object} props.initialSelectionSnapshot - Initial content selection
* that was previously saved
* @param {object} props.parentActions - Object containg helper actions
* associated with the parent container
* @param {props.parentActions.getComposerBoundingRect} props.parentActions.getComposerBoundingRect
@ -67,7 +65,6 @@ class ComposerEditor extends Component {
static propTypes = {
body: PropTypes.string.isRequired,
draftClientId: PropTypes.string,
initialSelectionSnapshot: PropTypes.object,
onFilePaste: PropTypes.func,
onBodyChanged: PropTypes.func,
parentActions: PropTypes.shape({
@ -94,18 +91,6 @@ class ComposerEditor extends Component {
// Public methods
/**
* @private
* Methods in ES6 classes should be defined using object method shorthand
* syntax (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Method_definitions),
* as opposed to arrow function syntax, if we want them to be enumerated
* on the prototype. Arrow function syntax is used to lexically bind the `this`
* value, and are specialized for non-method callbacks, where them picking up
* the this of their surrounding method or constructor is an advantage.
* See https://goo.gl/9ZMOGl for an example
* and http://www.2ality.com/2015/02/es6-classes-final.html for more info.
*/
// TODO Get rid of these selection methods
getCurrentSelection() {
return this.refs.contenteditable.getCurrentSelection();
@ -115,6 +100,10 @@ class ComposerEditor extends Component {
return this.refs.contenteditable.getPreviousSelection();
}
setSelection(selection) {
this.refs.contenteditable.setSelection(selection);
}
focus() {
// focus the composer and place the insertion point at the last text node of
// the body. Be sure to choose the last node /above/ the signature and any
@ -285,7 +274,6 @@ class ComposerEditor extends Component {
onChange={this.props.onBodyChanged}
onFilePaste={this.props.onFilePaste}
onSelectionRestored={this._ensureSelectionVisible}
initialSelectionSnapshot={this.props.initialSelectionSnapshot}
extensions={this.state.extensions}
/>
</DropZone>

View file

@ -7,7 +7,6 @@ import {
Utils,
Actions,
DraftStore,
UndoManager,
ContactStore,
QuotedHTMLTransformer,
FileDownloadStore,
@ -61,13 +60,14 @@ export default class ComposerView extends React.Component {
componentDidMount() {
if (this.props.session) {
this._receivedNewSession();
this._setupForProps(this.props);
}
}
componentWillReceiveProps(newProps) {
if (newProps.session !== this.props.session) {
this._receivedNewSession();
this._teardownForProps();
this._setupForProps(newProps);
}
if (Utils.isForwardedMessage(this.props.draft) !== Utils.isForwardedMessage(newProps.draft)) {
this.setState({
@ -76,23 +76,11 @@ export default class ComposerView extends React.Component {
}
}
componentDidUpdate() {
// We want to use a temporary variable instead of putting this into the
// state. This is because the selection is a transient property that
// only needs to be applied once. It's not a long-living property of
// the state. We could call `setState` here, but this saves us from a
// re-rendering.
if (this._recoveredSelection) {
this._recoveredSelection = null;
}
componentWillUnmount() {
this._teardownForProps();
}
focus() {
// TODO is it safe to remove this?
// if (ReactDOM.findDOMNode(this).contains(document.activeElement)) {
// return;
// }
if (this.props.draft.to.length === 0) {
this.refs.header.showAndFocusField(Fields.To);
} else if ((this.props.draft.subject || "").trim().length === 0) {
@ -113,23 +101,57 @@ export default class ComposerView extends React.Component {
'composer:show-and-focus-bcc': () => this.refs.header.showAndFocusField(Fields.Bcc),
'composer:show-and-focus-cc': () => this.refs.header.showAndFocusField(Fields.Cc),
'composer:focus-to': () => this.refs.header.showAndFocusField(Fields.To),
"composer:show-and-focus-from": () => {}, // todo
"core:undo": this.undo,
"core:redo": this.redo,
"composer:show-and-focus-from": () => {},
"core:undo": (event) => {
event.preventDefault();
event.stopPropagation();
this.props.session.undo();
},
"core:redo": (event) => {
event.preventDefault();
event.stopPropagation();
this.props.session.redo();
},
};
}
_receivedNewSession() {
this.undoManager = new UndoManager();
this._saveToHistory();
_setupForProps({draft, session}) {
this.setState({
showQuotedText: Utils.isForwardedMessage(draft),
});
this.props.draft.files.forEach((file) => {
// TODO: This is a dirty hack to save selection state into the undo/redo
// history. Remove it if / when selection is written into the body with
// marker tags, or when selection is moved from `contenteditable.innerState`
// into a first-order part of the session state.
session._composerViewSelectionRetrieve = () => {
// Selection updates /before/ the contenteditable emits it's change event,
// so the selection that goes with the snapshot state is the previous one.
if (this.refs[Fields.Body].getPreviousSelection) {
return this.refs[Fields.Body].getPreviousSelection();
}
return null;
}
session._composerViewSelectionRestore = (selection) => {
this.refs[Fields.Body].setSelection(selection);
}
draft.files.forEach((file) => {
if (Utils.shouldDisplayAsImage(file)) {
Actions.fetchFile(file);
}
});
}
_teardownForProps() {
if (this.props.session) {
this.props.session._composerViewSelectionRestore = null;
this.props.session._composerViewSelectionRetrieve = null;
}
}
_renderContentScrollRegion() {
if (NylasEnv.isComposerWindow()) {
return (
@ -181,15 +203,10 @@ export default class ComposerView extends React.Component {
getComposerBoundingRect: this._getComposerBoundingRect,
scrollTo: this.props.scrollTo,
},
initialSelectionSnapshot: this._recoveredSelection,
onFilePaste: this._onFilePaste,
onBodyChanged: this._onBodyChanged,
};
// TODO Get rid of the unecessary required methods:
// getCurrentSelection and getPreviousSelection shouldn't be needed and
// undo/redo functionality should be refactored into ComposerEditor
// _onDOMMutated === just for testing purposes, refactor the tests
return (
<InjectedComponent
ref={Fields.Body}
@ -199,8 +216,8 @@ export default class ComposerView extends React.Component {
requiredMethods={[
'focus',
'focusAbsoluteEnd',
'getCurrentSelection',
'getPreviousSelection',
'setSelection',
'_onDOMMutated',
]}
exposedProps={exposedProps}
@ -469,19 +486,10 @@ export default class ComposerView extends React.Component {
}
_onBodyChanged = (event) => {
this._applyChanges({body: this._showQuotedText(event.target.value)});
this.props.session.changes.add({body: this._showQuotedText(event.target.value)});
return;
}
_applyChanges = (changes = {}, source = {}) => {
const selections = this._getSelections();
this.props.session.changes.add(changes);
if (!source.fromUndoManager) {
this._saveToHistory(selections);
}
}
_isValidDraft = (options = {}) => {
// We need to check the `DraftStore` because the `DraftStore` is
// immediately and synchronously updated as soon as this function
@ -579,62 +587,6 @@ export default class ComposerView extends React.Component {
return (cleaned.indexOf("attach") >= 0);
}
undo = (event) => {
event.preventDefault();
event.stopPropagation();
const historyItem = this.undoManager.undo() || {};
if (!historyItem.state) {
return;
}
this._recoveredSelection = historyItem.currentSelection;
this._applyChanges(historyItem.state, {fromUndoManager: true});
this._recoveredSelection = null;
}
redo = (event) => {
event.preventDefault();
event.stopPropagation();
const historyItem = this.undoManager.redo() || {}
if (!historyItem.state) {
return;
}
this._recoveredSelection = historyItem.currentSelection;
this._applyChanges(historyItem.state, {fromUndoManager: true});
this._recoveredSelection = null;
}
_getSelections = () => {
const bodyComponent = this.refs[Fields.Body];
return {
currentSelection: bodyComponent.getCurrentSelection ? bodyComponent.getCurrentSelection() : null,
previousSelection: bodyComponent.getPreviousSelection ? bodyComponent.getPreviousSelection() : null,
}
}
_saveToHistory = (selections) => {
const {previousSelection, currentSelection} = selections || this._getSelections();
const historyItem = {
previousSelection,
currentSelection,
state: {
body: _.clone(this.props.draft.body),
subject: _.clone(this.props.draft.subject),
to: _.clone(this.props.draft.to),
cc: _.clone(this.props.draft.cc),
bcc: _.clone(this.props.draft.bcc),
},
}
const lastState = this.undoManager.current()
if (lastState) {
lastState.currentSelection = historyItem.previousSelection;
}
this.undoManager.saveToHistory(historyItem);
}
render() {
const dropCoverDisplay = this.state.isDropping ? 'block' : 'none';

View file

@ -1,148 +0,0 @@
UndoManager = require "../src/undo-manager"
describe "UndoManager", ->
beforeEach ->
@undoManager = new UndoManager
afterEach ->
advanceClock(500)
it "Initializes empty", ->
expect(@undoManager._history.length).toBe 0
expect(@undoManager._markers.length).toBe 0
expect(@undoManager._historyIndex).toBe -1
expect(@undoManager._markerIndex).toBe -1
it "can push a history item onto the stack", ->
@undoManager.saveToHistory "A"
advanceClock(500)
expect(@undoManager._history[0]).toBe "A"
expect(@undoManager._history.length).toBe 1
expect(@undoManager.current()).toBe "A"
it "updates the historyIndex", ->
@undoManager.saveToHistory "A"
expect(@undoManager._historyIndex).toBe 0
it "updates the markerIndex", ->
@undoManager.saveToHistory "A"
advanceClock(500)
@undoManager.saveToHistory "AB"
@undoManager.saveToHistory "ABC"
advanceClock(500)
expect(@undoManager._markerIndex).toBe 1
expect(@undoManager._historyIndex).toBe 2
describe "when undoing", ->
beforeEach ->
@undoManager.saveToHistory "A"
advanceClock(500)
@undoManager.saveToHistory "AB"
@undoManager.saveToHistory "ABC"
it "returns the last item on the stack at the most recent marker", ->
expect(@undoManager.undo()).toBe "A"
it "doesn't change the size of the stack", ->
@undoManager.undo()
expect(@undoManager._history.length).toBe 3
it "set the historyIndex properly", ->
@undoManager.undo()
expect(@undoManager._historyIndex).toBe 0
it "set the markerIndex properly after a wait", ->
advanceClock(500)
@undoManager.undo()
expect(@undoManager._markerIndex).toBe 0
it "set the markerIndex properly when undo fires immediately", ->
@undoManager.undo()
expect(@undoManager._markerIndex).toBe 0
it "returns null when there's nothing to undo", ->
@undoManager.undo()
@undoManager.undo()
expect(@undoManager.undo()).toBe null
expect(@undoManager._markerIndex).toBe 0
describe "when redoing", ->
beforeEach ->
@undoManager.saveToHistory "X"
advanceClock(500)
@undoManager.saveToHistory "XY"
@undoManager.saveToHistory "XYZ"
it "returns the last item on the stack after a wait", ->
advanceClock(500)
@undoManager.undo()
advanceClock(500)
expect(@undoManager.redo()).toBe "XYZ"
it "returns the last item on the stack when fired immediately", ->
@undoManager.undo()
expect(@undoManager.redo()).toBe "XYZ"
it "doesn't change the size of the stack", ->
@undoManager.undo()
@undoManager.redo()
expect(@undoManager._history.length).toBe 3
it "set the historyIndex properly", ->
@undoManager.undo()
@undoManager.redo()
expect(@undoManager._historyIndex).toBe 2
it "set the markerIndex properly", ->
@undoManager.undo()
@undoManager.redo()
expect(@undoManager._markerIndex).toBe 1
it "returns null when there's nothing to redo", ->
expect(@undoManager.redo()).toBe null
expect(@undoManager.redo()).toBe null
expect(@undoManager._markerIndex).toBe 1
describe "when undoing and adding items", ->
beforeEach ->
@undoManager.saveToHistory "1"
advanceClock(500)
@undoManager.saveToHistory "12"
@undoManager.saveToHistory "123"
advanceClock(500)
@undoManager.saveToHistory "1234"
@undoManager.undo()
@undoManager.undo()
advanceClock(500)
@undoManager.saveToHistory "A"
advanceClock(500)
it "correctly sets the history", ->
expect(@undoManager._history).toEqual ["1", "A"]
it "correctly sets the length", ->
expect(@undoManager._history.length).toBe 2
it "puts the correct items on the stack", ->
@undoManager.undo()
expect(@undoManager.redo()).toBe "A"
it "sets the historyIndex correctly", ->
expect(@undoManager._historyIndex).toBe 1
describe "when the stack is full", ->
beforeEach ->
@undoManager._MAX_HISTORY_SIZE = 2
@undoManager.saveToHistory "A"
@undoManager.saveToHistory "AB"
@undoManager.saveToHistory "ABC"
@undoManager.saveToHistory "ABCD"
it "correctly sets the length", ->
expect(@undoManager._history.length).toBe 2
it "keeps the latest histories", ->
expect(@undoManager._history).toEqual ["ABC", "ABCD"]
it "updates the historyIndex", ->
expect(@undoManager._historyIndex).toBe 1

63
spec/undo-stack-spec.es6 Normal file
View file

@ -0,0 +1,63 @@
import UndoStack from "../src/undo-stack";
describe("UndoStack", function UndoStackSpecs() {
beforeEach(() => {
this.undoManager = new UndoStack;
});
afterEach(() => {
advanceClock(500);
})
describe("undo", () => {
it("can restore history items, and returns null when none are available", () => {
this.undoManager.saveToHistory("A")
this.undoManager.saveToHistory("B")
this.undoManager.saveToHistory("C")
expect(this.undoManager.current()).toBe("C")
expect(this.undoManager.undo()).toBe("B")
expect(this.undoManager.current()).toBe("B")
expect(this.undoManager.undo()).toBe("A")
expect(this.undoManager.current()).toBe("A")
expect(this.undoManager.undo()).toBe(null)
expect(this.undoManager.current()).toBe("A")
});
it("limits the undo stack to the MAX_HISTORY_SIZE", () => {
this.undoManager._MAX_STACK_SIZE = 3
this.undoManager.saveToHistory("A")
this.undoManager.saveToHistory("B")
this.undoManager.saveToHistory("C")
this.undoManager.saveToHistory("D")
expect(this.undoManager.current()).toBe("D")
expect(this.undoManager.undo()).toBe("C")
expect(this.undoManager.undo()).toBe("B")
expect(this.undoManager.undo()).toBe(null)
expect(this.undoManager.current()).toBe("B")
});
});
describe("undo followed by redo", () => {
it("can restore previously undone history items", () => {
this.undoManager.saveToHistory("A")
this.undoManager.saveToHistory("B")
this.undoManager.saveToHistory("C")
expect(this.undoManager.current()).toBe("C")
expect(this.undoManager.undo()).toBe("B")
expect(this.undoManager.current()).toBe("B")
expect(this.undoManager.redo()).toBe("C")
expect(this.undoManager.current()).toBe("C")
});
it("cannot be used after pushing additional items", () => {
this.undoManager.saveToHistory("A")
this.undoManager.saveToHistory("B")
this.undoManager.saveToHistory("C")
expect(this.undoManager.current()).toBe("C")
expect(this.undoManager.undo()).toBe("B")
this.undoManager.saveToHistory("D")
expect(this.undoManager.redo()).toBe(null)
expect(this.undoManager.current()).toBe("D")
});
});
});

View file

@ -46,9 +46,6 @@ class Contenteditable extends React.Component
# The current html state, as a string, of the contenteditable.
value: React.PropTypes.string
# Initial content selection that was previously saved
initialSelectionSnapshot: React.PropTypes.object,
# Handlers
onChange: React.PropTypes.func.isRequired
onFilePaste: React.PropTypes.func
@ -114,6 +111,11 @@ class Contenteditable extends React.Component
focus: => @_editableNode().focus()
setSelection: (selection) =>
@setInnerState
exportedSelection: selection
previousExportedSelection: @innerState.exportedSelection
@_restoreSelection()
######################################################################
########################## React Lifecycle ###########################
@ -147,12 +149,6 @@ class Contenteditable extends React.Component
(not Utils.isEqualReact(nextProps, @props) or
not Utils.isEqualReact(nextState, @state))
componentWillReceiveProps: (nextProps) =>
if nextProps.initialSelectionSnapshot?
@setInnerState
exportedSelection: nextProps.initialSelectionSnapshot
previousExportedSelection: @innerState.exportedSelection
componentDidUpdate: =>
if @_shouldRestoreSelectionOnUpdate()
@_restoreSelection()

View file

@ -1,6 +1,7 @@
Message = require('../models/message').default
Actions = require '../actions'
DatabaseStore = require './database-store'
UndoStack = require '../../undo-stack'
ExtensionRegistry = require('../../extension-registry')
{Listener, Publisher} = require '../modules/reflux-coffee'
SyncbackDraftTask = require('../tasks/syncback-draft-task').default
@ -26,7 +27,10 @@ DraftChangeSet associated with the store session. The DraftChangeSet does two th
Section: Drafts
###
class DraftChangeSet
constructor: (@_onAltered, @_onCommit) ->
@include: CoffeeHelpers.includeModule
@include Publisher
constructor: (@callbacks) ->
@_commitChain = Promise.resolve()
@_pending = {}
@_saving = {}
@ -40,9 +44,10 @@ class DraftChangeSet
@_timer = null
add: (changes, {doesNotAffectPristine}={}) =>
@callbacks.onWillAddChanges(changes)
@_pending = _.extend(@_pending, changes)
@_pending['pristine'] = false unless doesNotAffectPristine
@_onAltered()
@_pending.pristine = false unless doesNotAffectPristine
@callbacks.onDidAddChanges(changes)
clearTimeout(@_timer) if @_timer
@_timer = setTimeout(@commit, 10000)
@ -59,7 +64,7 @@ class DraftChangeSet
@_saving = @_pending
@_pending = {}
return @_onCommit({noSyncback}).then =>
return @callbacks.onCommit({noSyncback}).then =>
@_saving = {}
return @_commitChain
@ -100,8 +105,13 @@ class DraftEditingSession
@_draft = false
@_draftPristineBody = null
@_destroyed = false
@_undoStack = new UndoStack()
@changes = new DraftChangeSet(@_changeSetAltered, @_changeSetCommit)
@changes = new DraftChangeSet({
onWillAddChanges: @changeSetWillAddChanges
onDidAddChanges: @changeSetDidAddChanges
onCommit: @changeSetCommit
})
if draft
@_draftPromise = @_setDraft(draft)
@ -137,12 +147,6 @@ class DraftEditingSession
if !draft.body?
throw new Error("DraftEditingSession._setDraft - new draft has no body!")
# We keep track of the draft's initial body if it's pristine when the editing
# session begins. This initial value powers things like "are you sure you want
# to send with an empty body?"
if draft.pristine
@_draftPristineBody = draft.body
# Reverse draft transformations performed by third-party plugins when the draft
# was last saved to disk
return Promise.each ExtensionRegistry.Composer.extensions(), (ext) ->
@ -152,6 +156,14 @@ class DraftEditingSession
draft = untransformed
.then =>
@_draft = draft
# We keep track of the draft's initial body if it's pristine when the editing
# session begins. This initial value powers things like "are you sure you want
# to send with an empty body?"
if draft.pristine
@_draftPristineBody = draft.body
@_undoStack.save(@_snapshot())
@trigger()
Promise.resolve(@)
@ -173,15 +185,7 @@ class DraftEditingSession
@_setDraft(Object.assign(new Message(), @_draft, nextValues))
@trigger()
_changeSetAltered: =>
return if @_destroyed
if !@_draft
throw new Error("DraftChangeSet was modified before the draft was prepared.")
@changes.applyToModel(@_draft)
@trigger()
_changeSetCommit: ({noSyncback}={}) =>
changeSetCommit: ({noSyncback}={}) =>
if @_destroyed or not @_draft
return Promise.resolve(true)
@ -215,6 +219,51 @@ class DraftEditingSession
Actions.ensureDraftSynced(@draftClientId)
# Undo / Redo
changeSetWillAddChanges: (changes) =>
return if @_restoring
hasBeen300ms = Date.now() - @_lastAddTimestamp > 300
hasChangedFields = !_.isEqual(Object.keys(changes), @_lastChangedFields)
@_lastChangedFields = Object.keys(changes)
@_lastAddTimestamp = Date.now()
if hasBeen300ms || hasChangedFields
@_undoStack.save(@_snapshot())
changeSetDidAddChanges: =>
return if @_destroyed
if !@_draft
throw new Error("DraftChangeSet was modified before the draft was prepared.")
@changes.applyToModel(@_draft)
@trigger()
restoreSnapshot: (snapshot) =>
return unless snapshot
@_restoring = true
@changes.add(snapshot.draft)
if @_composerViewSelectionRestore
@_composerViewSelectionRestore(snapshot.selection)
@_restoring = false
undo: =>
@restoreSnapshot(@_undoStack.saveAndUndo(@_snapshot()))
redo: =>
@restoreSnapshot(@_undoStack.redo())
_snapshot: =>
snapshot = {
selection: @_composerViewSelectionRetrieve?()
draft: Object.assign({}, @draft())
}
for {pluginId, value} in snapshot.draft.pluginMetadata
snapshot.draft["#{MetadataChangePrefix}#{pluginId}"] = value
delete snapshot.draft.pluginMetadata
return snapshot
DraftEditingSession.DraftChangeSet = DraftChangeSet
module.exports = DraftEditingSession

View file

@ -1,50 +0,0 @@
_ = require 'underscore'
module.exports =
class UndoManager
constructor: ->
@_historyIndex = -1
@_markerIndex = -1
@_history = []
@_markers = []
@_MAX_HISTORY_SIZE = 1000
current: ->
return @_history[@_historyIndex]
undo: ->
@__saveHistoryMarker()
if @_historyIndex > 0
@_markerIndex -= 1
@_historyIndex = @_markers[@_markerIndex]
return @_history[@_historyIndex]
else return null
redo: ->
@__saveHistoryMarker()
if @_historyIndex < (@_history.length - 1)
@_markerIndex += 1
@_historyIndex = @_markers[@_markerIndex]
return @_history[@_historyIndex]
else return null
saveToHistory: (historyItem) =>
if not _.isEqual((_.last(@_history) ? {}), historyItem)
@_historyIndex += 1
@_history.length = @_historyIndex
@_history.push(historyItem)
@_saveHistoryMarker()
while @_history.length > @_MAX_HISTORY_SIZE
@_history.shift()
@_historyIndex -= 1
__saveHistoryMarker: =>
if @_markers[@_markerIndex] isnt @_historyIndex
@_markerIndex += 1
@_markers.length = @_markerIndex
@_markers.push(@_historyIndex)
while @_markers.length > @_MAX_HISTORY_SIZE
@_markers.shift()
@_markerIndex -= 1
_saveHistoryMarker: _.debounce(UndoManager::__saveHistoryMarker, 300)

62
src/undo-stack.es6 Normal file
View file

@ -0,0 +1,62 @@
import _ from 'underscore';
export default class UndoStack {
constructor(options) {
this._options = options;
this._stack = []
this._redoStack = []
this._MAX_STACK_SIZE = 1000
this._accumulated = {};
}
current() {
return _.last(this._stack) || null;
}
undo() {
if (this._stack.length <= 1) { return null; }
const item = this._stack.pop();
this._redoStack.push(item);
return this.current();
}
redo() {
const item = this._redoStack.pop();
if (!item) { return null; }
this._stack.push(item);
return this.current();
}
accumulate = (state) => {
Object.assign(this._accumulated, state);
const shouldSnapshot = this._options.shouldSnapshot && this._options.shouldSnapshot(this.current(), this._accumulated);
if (!this.current() || shouldSnapshot) {
this.saveToHistory(this._accumulated);
this._accumulated = {};
}
}
save = (historyItem) => {
if (_.isEqual(this.current(), historyItem)) {
return;
}
this._redoStack = [];
this._stack.push(historyItem);
while (this._stack.length > this._MAX_STACK_SIZE) {
this._stack.shift();
}
}
saveAndUndo = (currentItem) => {
const top = this._stack.length - 1;
const snapshot = this._stack[top];
if (!snapshot) {
return null;
}
this._stack[top] = currentItem;
this.undo();
return snapshot;
}
}