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 * @param {string} props.body - Html string with the draft content to be
* rendered by the editor * rendered by the editor
* @param {string} props.draftClientId - Id of the draft being currently edited * @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 * @param {object} props.parentActions - Object containg helper actions
* associated with the parent container * associated with the parent container
* @param {props.parentActions.getComposerBoundingRect} props.parentActions.getComposerBoundingRect * @param {props.parentActions.getComposerBoundingRect} props.parentActions.getComposerBoundingRect
@ -67,7 +65,6 @@ class ComposerEditor extends Component {
static propTypes = { static propTypes = {
body: PropTypes.string.isRequired, body: PropTypes.string.isRequired,
draftClientId: PropTypes.string, draftClientId: PropTypes.string,
initialSelectionSnapshot: PropTypes.object,
onFilePaste: PropTypes.func, onFilePaste: PropTypes.func,
onBodyChanged: PropTypes.func, onBodyChanged: PropTypes.func,
parentActions: PropTypes.shape({ parentActions: PropTypes.shape({
@ -94,18 +91,6 @@ class ComposerEditor extends Component {
// Public methods // 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 // TODO Get rid of these selection methods
getCurrentSelection() { getCurrentSelection() {
return this.refs.contenteditable.getCurrentSelection(); return this.refs.contenteditable.getCurrentSelection();
@ -115,6 +100,10 @@ class ComposerEditor extends Component {
return this.refs.contenteditable.getPreviousSelection(); return this.refs.contenteditable.getPreviousSelection();
} }
setSelection(selection) {
this.refs.contenteditable.setSelection(selection);
}
focus() { focus() {
// focus the composer and place the insertion point at the last text node of // 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 // 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} onChange={this.props.onBodyChanged}
onFilePaste={this.props.onFilePaste} onFilePaste={this.props.onFilePaste}
onSelectionRestored={this._ensureSelectionVisible} onSelectionRestored={this._ensureSelectionVisible}
initialSelectionSnapshot={this.props.initialSelectionSnapshot}
extensions={this.state.extensions} extensions={this.state.extensions}
/> />
</DropZone> </DropZone>

View file

@ -7,7 +7,6 @@ import {
Utils, Utils,
Actions, Actions,
DraftStore, DraftStore,
UndoManager,
ContactStore, ContactStore,
QuotedHTMLTransformer, QuotedHTMLTransformer,
FileDownloadStore, FileDownloadStore,
@ -61,13 +60,14 @@ export default class ComposerView extends React.Component {
componentDidMount() { componentDidMount() {
if (this.props.session) { if (this.props.session) {
this._receivedNewSession(); this._setupForProps(this.props);
} }
} }
componentWillReceiveProps(newProps) { componentWillReceiveProps(newProps) {
if (newProps.session !== this.props.session) { if (newProps.session !== this.props.session) {
this._receivedNewSession(); this._teardownForProps();
this._setupForProps(newProps);
} }
if (Utils.isForwardedMessage(this.props.draft) !== Utils.isForwardedMessage(newProps.draft)) { if (Utils.isForwardedMessage(this.props.draft) !== Utils.isForwardedMessage(newProps.draft)) {
this.setState({ this.setState({
@ -76,23 +76,11 @@ export default class ComposerView extends React.Component {
} }
} }
componentDidUpdate() { componentWillUnmount() {
// We want to use a temporary variable instead of putting this into the this._teardownForProps();
// 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;
}
} }
focus() { focus() {
// TODO is it safe to remove this?
// if (ReactDOM.findDOMNode(this).contains(document.activeElement)) {
// return;
// }
if (this.props.draft.to.length === 0) { if (this.props.draft.to.length === 0) {
this.refs.header.showAndFocusField(Fields.To); this.refs.header.showAndFocusField(Fields.To);
} else if ((this.props.draft.subject || "").trim().length === 0) { } 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-bcc': () => this.refs.header.showAndFocusField(Fields.Bcc),
'composer:show-and-focus-cc': () => this.refs.header.showAndFocusField(Fields.Cc), 'composer:show-and-focus-cc': () => this.refs.header.showAndFocusField(Fields.Cc),
'composer:focus-to': () => this.refs.header.showAndFocusField(Fields.To), 'composer:focus-to': () => this.refs.header.showAndFocusField(Fields.To),
"composer:show-and-focus-from": () => {}, // todo "composer:show-and-focus-from": () => {},
"core:undo": this.undo, "core:undo": (event) => {
"core:redo": this.redo, event.preventDefault();
event.stopPropagation();
this.props.session.undo();
},
"core:redo": (event) => {
event.preventDefault();
event.stopPropagation();
this.props.session.redo();
},
}; };
} }
_receivedNewSession() { _setupForProps({draft, session}) {
this.undoManager = new UndoManager(); this.setState({
this._saveToHistory(); 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)) { if (Utils.shouldDisplayAsImage(file)) {
Actions.fetchFile(file); Actions.fetchFile(file);
} }
}); });
} }
_teardownForProps() {
if (this.props.session) {
this.props.session._composerViewSelectionRestore = null;
this.props.session._composerViewSelectionRetrieve = null;
}
}
_renderContentScrollRegion() { _renderContentScrollRegion() {
if (NylasEnv.isComposerWindow()) { if (NylasEnv.isComposerWindow()) {
return ( return (
@ -181,15 +203,10 @@ export default class ComposerView extends React.Component {
getComposerBoundingRect: this._getComposerBoundingRect, getComposerBoundingRect: this._getComposerBoundingRect,
scrollTo: this.props.scrollTo, scrollTo: this.props.scrollTo,
}, },
initialSelectionSnapshot: this._recoveredSelection,
onFilePaste: this._onFilePaste, onFilePaste: this._onFilePaste,
onBodyChanged: this._onBodyChanged, 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 ( return (
<InjectedComponent <InjectedComponent
ref={Fields.Body} ref={Fields.Body}
@ -199,8 +216,8 @@ export default class ComposerView extends React.Component {
requiredMethods={[ requiredMethods={[
'focus', 'focus',
'focusAbsoluteEnd', 'focusAbsoluteEnd',
'getCurrentSelection',
'getPreviousSelection', 'getPreviousSelection',
'setSelection',
'_onDOMMutated', '_onDOMMutated',
]} ]}
exposedProps={exposedProps} exposedProps={exposedProps}
@ -469,19 +486,10 @@ export default class ComposerView extends React.Component {
} }
_onBodyChanged = (event) => { _onBodyChanged = (event) => {
this._applyChanges({body: this._showQuotedText(event.target.value)}); this.props.session.changes.add({body: this._showQuotedText(event.target.value)});
return; return;
} }
_applyChanges = (changes = {}, source = {}) => {
const selections = this._getSelections();
this.props.session.changes.add(changes);
if (!source.fromUndoManager) {
this._saveToHistory(selections);
}
}
_isValidDraft = (options = {}) => { _isValidDraft = (options = {}) => {
// We need to check the `DraftStore` because the `DraftStore` is // We need to check the `DraftStore` because the `DraftStore` is
// immediately and synchronously updated as soon as this function // 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); 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() { render() {
const dropCoverDisplay = this.state.isDropping ? 'block' : 'none'; 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. # The current html state, as a string, of the contenteditable.
value: React.PropTypes.string value: React.PropTypes.string
# Initial content selection that was previously saved
initialSelectionSnapshot: React.PropTypes.object,
# Handlers # Handlers
onChange: React.PropTypes.func.isRequired onChange: React.PropTypes.func.isRequired
onFilePaste: React.PropTypes.func onFilePaste: React.PropTypes.func
@ -114,6 +111,11 @@ class Contenteditable extends React.Component
focus: => @_editableNode().focus() focus: => @_editableNode().focus()
setSelection: (selection) =>
@setInnerState
exportedSelection: selection
previousExportedSelection: @innerState.exportedSelection
@_restoreSelection()
###################################################################### ######################################################################
########################## React Lifecycle ########################### ########################## React Lifecycle ###########################
@ -147,12 +149,6 @@ class Contenteditable extends React.Component
(not Utils.isEqualReact(nextProps, @props) or (not Utils.isEqualReact(nextProps, @props) or
not Utils.isEqualReact(nextState, @state)) not Utils.isEqualReact(nextState, @state))
componentWillReceiveProps: (nextProps) =>
if nextProps.initialSelectionSnapshot?
@setInnerState
exportedSelection: nextProps.initialSelectionSnapshot
previousExportedSelection: @innerState.exportedSelection
componentDidUpdate: => componentDidUpdate: =>
if @_shouldRestoreSelectionOnUpdate() if @_shouldRestoreSelectionOnUpdate()
@_restoreSelection() @_restoreSelection()

View file

@ -1,6 +1,7 @@
Message = require('../models/message').default Message = require('../models/message').default
Actions = require '../actions' Actions = require '../actions'
DatabaseStore = require './database-store' DatabaseStore = require './database-store'
UndoStack = require '../../undo-stack'
ExtensionRegistry = require('../../extension-registry') ExtensionRegistry = require('../../extension-registry')
{Listener, Publisher} = require '../modules/reflux-coffee' {Listener, Publisher} = require '../modules/reflux-coffee'
SyncbackDraftTask = require('../tasks/syncback-draft-task').default SyncbackDraftTask = require('../tasks/syncback-draft-task').default
@ -26,7 +27,10 @@ DraftChangeSet associated with the store session. The DraftChangeSet does two th
Section: Drafts Section: Drafts
### ###
class DraftChangeSet class DraftChangeSet
constructor: (@_onAltered, @_onCommit) -> @include: CoffeeHelpers.includeModule
@include Publisher
constructor: (@callbacks) ->
@_commitChain = Promise.resolve() @_commitChain = Promise.resolve()
@_pending = {} @_pending = {}
@_saving = {} @_saving = {}
@ -40,9 +44,10 @@ class DraftChangeSet
@_timer = null @_timer = null
add: (changes, {doesNotAffectPristine}={}) => add: (changes, {doesNotAffectPristine}={}) =>
@callbacks.onWillAddChanges(changes)
@_pending = _.extend(@_pending, changes) @_pending = _.extend(@_pending, changes)
@_pending['pristine'] = false unless doesNotAffectPristine @_pending.pristine = false unless doesNotAffectPristine
@_onAltered() @callbacks.onDidAddChanges(changes)
clearTimeout(@_timer) if @_timer clearTimeout(@_timer) if @_timer
@_timer = setTimeout(@commit, 10000) @_timer = setTimeout(@commit, 10000)
@ -59,7 +64,7 @@ class DraftChangeSet
@_saving = @_pending @_saving = @_pending
@_pending = {} @_pending = {}
return @_onCommit({noSyncback}).then => return @callbacks.onCommit({noSyncback}).then =>
@_saving = {} @_saving = {}
return @_commitChain return @_commitChain
@ -100,8 +105,13 @@ class DraftEditingSession
@_draft = false @_draft = false
@_draftPristineBody = null @_draftPristineBody = null
@_destroyed = false @_destroyed = false
@_undoStack = new UndoStack()
@changes = new DraftChangeSet(@_changeSetAltered, @_changeSetCommit) @changes = new DraftChangeSet({
onWillAddChanges: @changeSetWillAddChanges
onDidAddChanges: @changeSetDidAddChanges
onCommit: @changeSetCommit
})
if draft if draft
@_draftPromise = @_setDraft(draft) @_draftPromise = @_setDraft(draft)
@ -137,12 +147,6 @@ class DraftEditingSession
if !draft.body? if !draft.body?
throw new Error("DraftEditingSession._setDraft - new draft has no 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 # Reverse draft transformations performed by third-party plugins when the draft
# was last saved to disk # was last saved to disk
return Promise.each ExtensionRegistry.Composer.extensions(), (ext) -> return Promise.each ExtensionRegistry.Composer.extensions(), (ext) ->
@ -152,6 +156,14 @@ class DraftEditingSession
draft = untransformed draft = untransformed
.then => .then =>
@_draft = draft @_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() @trigger()
Promise.resolve(@) Promise.resolve(@)
@ -173,15 +185,7 @@ class DraftEditingSession
@_setDraft(Object.assign(new Message(), @_draft, nextValues)) @_setDraft(Object.assign(new Message(), @_draft, nextValues))
@trigger() @trigger()
_changeSetAltered: => changeSetCommit: ({noSyncback}={}) =>
return if @_destroyed
if !@_draft
throw new Error("DraftChangeSet was modified before the draft was prepared.")
@changes.applyToModel(@_draft)
@trigger()
_changeSetCommit: ({noSyncback}={}) =>
if @_destroyed or not @_draft if @_destroyed or not @_draft
return Promise.resolve(true) return Promise.resolve(true)
@ -215,6 +219,51 @@ class DraftEditingSession
Actions.ensureDraftSynced(@draftClientId) 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 DraftEditingSession.DraftChangeSet = DraftChangeSet
module.exports = DraftEditingSession 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;
}
}