mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-21 07:46:06 +08:00
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:
parent
eabdd78935
commit
bf955891d9
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
63
spec/undo-stack-spec.es6
Normal 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")
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
62
src/undo-stack.es6
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue