1 // ==========================================================================
  2 // Project:   SproutCore - JavaScript Application Framework
  3 // Copyright: ©2006-2011 Strobe Inc. and contributors.
  4 //            Portions ©2008-2011 Apple Inc. All rights reserved.
  5 // License:   Licensed under MIT license (see license.js)
  6 // ==========================================================================
  7 
  8 /**
  9   @class
 10 
 11   This is a simple undo manager. It manages groups of actions which return
 12   something to an earlier state. It's your responsibility to make sure that
 13   these functions successfully undo the action, and register an undo action
 14   of their own (allowing redo).
 15 
 16   ## Using SC.UndoManager
 17 
 18   You should create one SC.UndoManager instance for each thing you want to
 19   allow undo on. For example, if a controller manages a single record, but
 20   you have two fields that should each have their own undo stack, you should
 21   create two separate managers.
 22 
 23   Register undo functions via the `registerUndoAction`, which takes a target,
 24   action, context and optional human-readable action name. Trigger actions by
 25   calling the `undo` and `redo` methods. Actions will be called with `context`
 26   as their only argument.
 27 
 28   Optionally, you can group undo actions; groups of actions are triggered
 29   together.
 30 
 31   ### Simple Example: A single value
 32 
 33   This example attaches an undo manager to a controller and registers an undo
 34   function each time the value of `value` changes. It also exposes methods to
 35   trigger undos and redos, triggered via buttons in the included stub view class.
 36 
 37         // Controller:
 38         MyApp.myController = SC.ObjectController.create({
 39           // Content, with `value`.
 40           content: SC.Object.create({ value: 'Hello, World.' }),
 41 
 42           // Undo manager.
 43           valueUndoManager: SC.UndoManager.create(),
 44 
 45           // Undo action.
 46           _valueUndoAction(val) {
 47             // This call will trigger the controller's `value` observer, triggering the registration
 48             // of another undo; the UndoManager will automatically and correctly interpret this as
 49             // the registration of a redo method.
 50             this.set('value', val);
 51           },
 52 
 53           // Value observer; tracks `value` and registers undos.
 54           valueDidChange: function() {
 55             // Get the values.
 56             var value = this.get('value'),
 57                 previousValue = this._previousValue,
 58                 that = this;
 59 
 60             // Update previous value.
 61             this._previousValue = value;
 62 
 63             // GATEKEEP: If the current value is the same as the previous value, there's nothing to do.
 64             if (previousValue === value) return;
 65 
 66             // GATEKEEP: If there is no previous value, it's probably our initial spinup. We don't want
 67             // to register an undo-back-to-undefined method, so we should return. (Your situation may be
 68             // different.)
 69             if (SC.none(previousValue)) return;
 70 
 71             // Otherwise, register an undo function. (previousValue is accessed via the closure.)
 72             this.undoManager.registerUndoAction(this, this._valueUndoAction, previousValue);
 73           }.observes('value')
 74         });
 75 
 76         // Stub view:
 77         MyApp.UndoableValueView = SC.View.extend({
 78           childViews: ['labelView', 'undoButtonView', 'redoButtonView'],
 79           labelView: SC.LabelView.extend({
 80             layout: { height: 24 },
 81             isEdiable: YES,
 82             valueBinding: 'MyApp.myController.value'
 83           }),
 84           undoButtonView: SC.ButtonView.extend({
 85             layout: { height: 24, width: 60, bottom: 0 },
 86             title: 'Undo',
 87             isEnabledBinding: SC.Binding.oneWay('MyApp.myController.valueUndoManager.canUndo'),
 88             target: 'MyApp.myController.valueUndoManager',
 89             action: 'undo'
 90           }),
 91           redoButtonView: SC.ButtonView.extend({
 92             layout: { height: 24, width: 60, bottom: 0, right: 0 },
 93             title: 'Redo',
 94             isEnabledBinding: SC.Binding.oneWay('MyApp.myController.valueUndoManager.canRedo'),
 95             target: 'MyApp.myController.valueUndoManager',
 96             action: 'redo'
 97           })
 98         });
 99 
100   ### Advanced: Grouping undos
101 
102   Undo events registered by `registerUndoAction` will undo or redo one at a time. If you wish,
103   you can group undo events into groups (for example, if you wish to group all undos which happen
104   within a short duration of each other). Groups are fired all at once when `undo` or `redo` is
105   called.
106 
107   To start a new undo group, call `beginUndoGroup`; to register undo functions to the currently-
108   open group, call `registerGroupedUndoAction`; finally, to mark the end of a grouped set of undo
109   functions, call `endUndoGroup`. In most cases, you will not need to call `beginUndoGroup` and
110   `endUndoGroup`: if you call `registerUndoAction`, any open group will be closed, and a new group
111   will be created and left open; calling `registerGroupedUndoAction` will simply add to the
112   currently-open group, creating a new one if necessary. This means that in practice, you can call
113   `registerUndoAction` to close previous groups and begin a new one, and `registerGroupedUndoAction`
114   to add to an existing group.
115 
116   If `undo` is called while an undo group is open, UndoManager will simply close the group for
117   you before executing it. This allows you to safely leave groups open pending possible additional
118   undo actions.
119 
120   @extends SC.Object
121 */
122 SC.UndoManager = SC.Object.extend(
123 /** @scope SC.UndoManager.prototype */ {
124 
125   /** 
126     If name arguments are passed into `registerUndoAction` or related methods, then this property
127     will expose the last undo action's name. You can use this to show the user what type of action
128     will be undone (for example "typing" or "delete").
129 
130     @field
131     @readonly
132     @type String
133     @default null
134   */
135   undoActionName: function () { 
136     return this.undoStack ? this.undoStack.name : null;
137   }.property('undoStack').cacheable(),
138 
139   /** 
140     Exposes the timestamp of the most recent undo action.
141 
142     @field
143     @readonly
144     @type SC.DateTime
145     @default null
146   */
147   undoActionTimestamp: function() {
148     return this.undoStack ? this.undoStack.timeStamp : null;
149   }.property('undoStack').cacheable(),
150 
151   /** 
152     If name arguments are passed into `registerUndoAction` or related methods, then this property
153     will expose the last redo action's name. You can use this to show the user what type of
154     action will be redone (for example "Redo typing" or "Redo delete").
155 
156     @field
157     @readonly
158     @type String
159     @default null
160   */
161   redoActionName: function () { 
162     return this.redoStack ? this.redoStack.name : null;
163   }.property('redoStack').cacheable(),
164 
165   /** 
166     Exposes the timestamp of the most recent redo action.
167 
168     @field
169     @readonly
170     @type SC.DateTime
171     @default null
172   */
173   redoActionTimestamp: function() {
174     return this.redoStack ? this.redoStack.timeStamp : null;
175   }.property('redoStack').cacheable(),
176 
177   /** 
178     True if there is an undo action on the stack. Use to validate your menu item or enable
179     your button.
180     
181     @field
182     @readonly
183     @type Boolean
184     @default NO
185   */
186   canUndo: function () { 
187     return !SC.none(this.undoStack);
188   }.property('undoStack').cacheable(),
189   
190   /** 
191     True if there is an redo action on the stack. Use to validate your menu item or enable
192     your button.
193     
194     @field
195     @readonly
196     @type Boolean
197     @default NO
198   */
199   canRedo: function () {
200     return !SC.none(this.redoStack);
201   }.property('redoStack').cacheable(),
202   
203   /**
204     Tries to undo the last action. Fails if an undo group is currently open.
205 
206     @returns {Boolean} YES if succeeded, NO otherwise.
207   */
208   undo: function () {
209     this._undoOrRedo('undoStack','isUndoing');
210   },
211 
212   /**
213     Tries to redo the last action. Fails if a redo group is currently open.
214     
215     @returns {Boolean} YES if succeeded, NO otherwise.
216   */
217   redo: function () {
218     this._undoOrRedo('redoStack','isRedoing');
219   },
220 
221   /**
222     Resets the undo and redo stacks.
223   */
224   reset: function () {
225     this._activeGroup = null;
226     this.set('undoStack', null);
227     this.set('redoStack', null);
228   },
229 
230   /**
231     The maximum number of undo groups the receiver holds.
232     The undo stack is unlimited by default.
233 
234     @type Number
235     @default 0
236   */
237   maxStackLength: 0,
238   
239   /**
240     @type Boolean
241     @default NO
242   */
243   isUndoing: NO,
244   
245   /**
246     @type Boolean
247     @default NO
248   */
249   isRedoing: NO, 
250   
251   // --------------------------------
252   // UNDO ACTION REGISTRATION
253   //
254 
255   /**
256     Registers an undo action. If called while an undo is in progress (i.e. from your
257     undo method, or from observers which it triggers), registers a redo action instead.
258     
259     @param {String|Object} target The action's target (`this`).
260     @param {String|Function} action The method on `target` to be called.
261     @param {Object} context The context passed to the action when called.
262     @param {String} name An optional human-readable name for the undo action.
263   */
264   registerUndoAction: function(target, action, context, name) {
265     // Calls to registerUndoAction close any open undo groups, open a new one and register
266     // to it. This means that a series of calls to registerUndo will simply open and close
267     // a series of single-function groups, as intended.
268 
269     if (!this.isUndoing && !this.isRedoing) {
270       if (this._activeGroup) {
271         this.endUndoGroup();
272       }
273       this.beginUndoGroup(name);
274     }
275     
276     this.registerGroupedUndoAction(target, action, context);
277   },
278 
279   /**
280     Registers an undo action to the current group. If no group is open, opens a new
281     one.
282 
283     @param {String|Object} target The action's target (`this`).
284     @param {String|Function} action The method on `target` to be called.
285     @param {Object} context The context passed to the action when called.
286     @param {String} name An optional human-readable name for the undo action. Sets or
287       changes the current group's name.
288   */
289   registerGroupedUndoAction: function(target, action, context, name) {
290     // If we don't have an active group, route the call through registerUndoAction, which will
291     // handle creating a new group for us before returning here. (Slight hack.)
292     if (!this._activeGroup) {
293       this.registerUndoAction(target, action, context, name);
294     }
295     // Otherwise, register the action.
296     else {
297       if (name) this._activeGroup.name = name;
298       this._activeGroup.targets.push(target);
299       this._activeGroup.actions.push(action);
300       this._activeGroup.contexts.push(context);
301       this._activeGroup.timeStamp = SC.DateTime.create();
302 
303       // If we're not mid-undo or -redo, then we're registering a new undo, and should
304       // clear out any redoStack.
305       if (!this.isUndoing && !this.isRedoing) {
306         this.set('redoStack', null);
307       }
308     }
309   },
310 
311   /**
312     Begins a new undo group.
313 
314     Whenever you start an action that you expect to need to bundle under a single
315     undo action in the menu, you should begin an undo group.  This way any
316     undo actions registered by other parts of the application will be
317     automatically bundled into this one action.
318 
319     When you are finished performing the action, balance this with a call to
320     `endUndoGroup()`. (You can call `undo` or `redo` with an open group; the group
321     will simply be closed and processed as normal.)
322 
323     @param {String} name
324   */
325   beginUndoGroup: function (name) {
326     if (this._activeGroup) {
327       //@if(debug)
328       SC.warn("SC.UndoManager#beginUndoGroup() called while inside group.");
329       //@endif
330       return;
331     }
332 
333     var stack = this.isUndoing ? 'redoStack' : 'undoStack';
334 
335     this._activeGroup = {
336       // The action's name (see undoActionName). Optional.
337       name: name,
338       // Ordered lists of targets, actions and contexts. (Nth items in each list go together.)
339       targets: [],
340       actions: [],
341       contexts: [],
342       // The previous undo action. When this group is triggered, prev will become the new stack.
343       prev: this.get(stack),
344       // When the action was registered. Useful for grouping undo actions by time.
345       timeStamp: SC.DateTime.create()
346     };
347 
348     this.set(stack, this._activeGroup);
349   },
350  
351   /**
352     Ends a group of undo functions. All functions in an undo group will be undone or redone
353     together when `undo` or `redo` is called.
354 
355     @see beginUndoGroup()
356   */
357   endUndoGroup: function () {
358     var maxStackLength = this.get('maxStackLength'),
359       stackName = this.isUndoing ? 'redoStack' : 'undoStack';
360 
361     if (!this._activeGroup) {
362       //@if(debug)
363       SC.warn("SC.UndoManager#endUndoGroup() called outside group.");
364       //@endif
365       return;
366     }
367 
368     this._activeGroup = null;
369     this.notifyPropertyChange(stackName);
370 
371     // If we have a maxStackLength, trace back through stack.prev that many times and
372     // null out anything older.
373     if (maxStackLength > 0) {
374       var stack = this[stackName],
375         i = 1;
376       while(stack = stack.prev) {
377         i++;
378         if (i >= maxStackLength) {
379           stack.prev = null;
380         }
381       }
382     }
383   },
384 
385   /**
386     Change the name of the current undo group.
387     
388     @param {String} name
389   */
390   setActionName: function (name) {
391     if (!this._activeGroup) {
392       //@if(debug)
393       SC.warn("SC.UndoManager#setActionName() called without an active undo group.");
394       //@endif
395       return;
396     }
397     this._activeGroup.name = name;
398   },
399   
400   // --------------------------------
401   // PRIVATE
402   //
403   
404   /** @private */
405   _activeGroup: null,
406   
407   /** @private */
408   undoStack: null,
409   
410   /** @private */
411   redoStack: null, 
412   
413   /** @private */
414   _undoOrRedo: function (stack, state) {
415     // Close out any open undo groups.
416     if (this._activeGroup) this.endUndoGroup();
417 
418     // Flag the state.
419     this.set(state, true);
420 
421     // Run the group of actions!
422     var group = this.get(stack);
423     if (group) {
424       // Roll back the stack to the previous item.
425       this.set(stack, group.prev);
426       // Open a new group of the opposite persuasion with the same name. This makes sure a redo
427       // action will have the same name as its corresponding undo action.
428       this.beginUndoGroup(group.name);
429 
430       // Run the actions backwards.
431       var len = group.actions.length,
432           target, action, context, i;
433       for (i = len - 1; i >= 0; i--) {
434         target = group.targets[i];
435         action = group.actions[i];
436         context = group.contexts[i];
437 
438         // Normalize (for convenience and backward-compatibility).
439         // If target is a function, it's the action.
440         if (SC.typeOf(target) === SC.T_FUNCTION) {
441           action = target;
442           target = null;
443         }
444         // If target is a string, see if it points to an object.
445         if (SC.typeOf(target) === SC.T_STRING) target = SC.objectForPropertyPath(target);
446         // If action is a string, see if it's the name of a method on target.
447         if (target && SC.typeOf(action) === SC.T_STRING && SC.typeOf(target[action]) === SC.T_FUNCTION) action = target[action];
448 
449         // Call!
450         if (SC.typeOf(action) === SC.T_FUNCTION) action.call(target, context);
451       }
452 
453       // Close the opposite-persuasion group opened above.
454       this.endUndoGroup();
455     }
456     this.set(state, false);
457   },
458 
459   /** @private */
460   destroy: function() {
461     this._activeGroup = null;
462     this.set('undoStack', null);
463     this.set('redoStack', null);
464     return sc_super();
465   },
466 
467   /** @private Deprecated as of 1.11. Use registerUndoAction instead. */
468   registerUndo: function (func, name) {
469     //@if(debug)
470     SC.warn('SC.UndoManager#registerUndo is deprecated and will be removed in a future version. Use registerUndoAction.');
471     //@endif
472     this.registerUndoAction(null, func, null, name);
473   }
474 
475 });
476