mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-12 23:54:45 +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
8 changed files with 251 additions and 339 deletions
|
@ -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>
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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.
|
# 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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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…
Add table
Reference in a new issue