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