1 /* 2 Copyright (c) 2003-2009, CKSource - Frederico Knabben. All rights reserved. 3 For licensing, see LICENSE.html or http://ckeditor.com/license 4 */ 5 6 /** 7 * @fileOverview Undo/Redo system for saving shapshot for document modification 8 * and other recordable changes. 9 */ 10 11 (function() 12 { 13 CKEDITOR.plugins.add( 'undo', 14 { 15 requires : [ 'selection', 'wysiwygarea' ], 16 17 init : function( editor ) 18 { 19 var undoManager = new UndoManager( editor ); 20 21 var undoCommand = editor.addCommand( 'undo', 22 { 23 exec : function() 24 { 25 if ( undoManager.undo() ) 26 { 27 editor.selectionChange(); 28 this.fire( 'afterUndo' ); 29 } 30 }, 31 state : CKEDITOR.TRISTATE_DISABLED, 32 canUndo : false 33 }); 34 35 var redoCommand = editor.addCommand( 'redo', 36 { 37 exec : function() 38 { 39 if ( undoManager.redo() ) 40 { 41 editor.selectionChange(); 42 this.fire( 'afterRedo' ); 43 } 44 }, 45 state : CKEDITOR.TRISTATE_DISABLED, 46 canUndo : false 47 }); 48 49 undoManager.onChange = function() 50 { 51 undoCommand.setState( undoManager.undoable() ? CKEDITOR.TRISTATE_OFF : CKEDITOR.TRISTATE_DISABLED ); 52 redoCommand.setState( undoManager.redoable() ? CKEDITOR.TRISTATE_OFF : CKEDITOR.TRISTATE_DISABLED ); 53 }; 54 55 function recordCommand( event ) 56 { 57 // If the command hasn't been marked to not support undo. 58 if ( undoManager.enabled && event.data.command.canUndo !== false ) 59 undoManager.save(); 60 } 61 62 // We'll save snapshots before and after executing a command. 63 editor.on( 'beforeCommandExec', recordCommand ); 64 editor.on( 'afterCommandExec', recordCommand ); 65 66 // Save snapshots before doing custom changes. 67 editor.on( 'saveSnapshot', function() 68 { 69 undoManager.save(); 70 }); 71 72 // Make the undo manager available only in wysiwyg mode. 73 editor.on( 'mode', function() 74 { 75 if ( editor.mode == 'wysiwyg' ) 76 { 77 if ( !undoManager.enabled ) 78 { 79 undoManager.enabled = true; 80 81 editor.document.on( 'keydown', function( event ) 82 { 83 // Do not capture CTRL hotkeys. 84 if ( !event.data.$.ctrlKey && !event.data.$.metaKey ) 85 undoManager.type( event ); 86 }); 87 88 // Always save an undo snapshot - the previous mode might have changed 89 // editor contents. 90 undoManager.save( true ); 91 } 92 } 93 else 94 undoManager.enabled = false; 95 96 undoManager.onChange(); 97 }); 98 99 editor.ui.addButton( 'Undo', 100 { 101 label : editor.lang.undo, 102 command : 'undo' 103 }); 104 105 editor.ui.addButton( 'Redo', 106 { 107 label : editor.lang.redo, 108 command : 'redo' 109 }); 110 111 editor.resetUndo = function() 112 { 113 // Reset the undo stack. 114 undoManager.reset(); 115 116 // Create the first image. 117 editor.fire( 'saveSnapshot' ); 118 }; 119 } 120 }); 121 122 // Gets a snapshot image which represent the current document status. 123 function Image( editor ) 124 { 125 var selection = editor.getSelection(); 126 127 this.contents = editor.getSnapshot(); 128 this.bookmarks = selection && selection.createBookmarks2( true ); 129 130 // In IE, we need to remove the expando attributes. 131 if ( CKEDITOR.env.ie ) 132 this.contents = this.contents.replace( /\s+_cke_expando=".*?"/g, '' ); 133 } 134 135 Image.prototype = 136 { 137 equals : function( otherImage, contentOnly ) 138 { 139 if ( this.contents != otherImage.contents ) 140 return false; 141 142 if ( contentOnly ) 143 return true; 144 145 var bookmarksA = this.bookmarks, 146 bookmarksB = otherImage.bookmarks; 147 148 if ( bookmarksA || bookmarksB ) 149 { 150 if ( !bookmarksA || !bookmarksB || bookmarksA.length != bookmarksB.length ) 151 return false; 152 153 for ( var i = 0 ; i < bookmarksA.length ; i++ ) 154 { 155 var bookmarkA = bookmarksA[ i ], 156 bookmarkB = bookmarksB[ i ]; 157 158 if ( 159 bookmarkA.startOffset != bookmarkB.startOffset || 160 bookmarkA.endOffset != bookmarkB.endOffset || 161 !CKEDITOR.tools.arrayCompare( bookmarkA.start, bookmarkB.start ) || 162 !CKEDITOR.tools.arrayCompare( bookmarkA.end, bookmarkB.end ) ) 163 { 164 return false; 165 } 166 } 167 } 168 169 return true; 170 } 171 }; 172 173 /** 174 * @constructor Main logic for Redo/Undo feature. 175 */ 176 function UndoManager( editor ) 177 { 178 this.editor = editor; 179 180 // Reset the undo stack. 181 this.reset(); 182 } 183 184 UndoManager.prototype = 185 { 186 /** 187 * Process undo system regard keystrikes. 188 * @param {CKEDITOR.dom.event} event 189 */ 190 type : function( event ) 191 { 192 var keystroke = event && event.data.getKeystroke(), 193 194 // Backspace, Delete 195 modifierCodes = { 8:1, 46:1 }, 196 // Keystrokes which will modify the contents. 197 isModifier = keystroke in modifierCodes, 198 wasModifier = this.lastKeystroke in modifierCodes, 199 lastWasSameModifier = isModifier && keystroke == this.lastKeystroke, 200 201 // Arrows: L, T, R, B 202 resetTypingCodes = { 37:1, 38:1, 39:1, 40:1 }, 203 // Keystrokes which navigation through contents. 204 isReset = keystroke in resetTypingCodes, 205 wasReset = this.lastKeystroke in resetTypingCodes, 206 207 // Keystrokes which just introduce new contents. 208 isContent = ( !isModifier && !isReset ), 209 210 // Create undo snap for every different modifier key. 211 modifierSnapshot = ( isModifier && !lastWasSameModifier ), 212 // Create undo snap on the following cases: 213 // 1. Just start to type. 214 // 2. Typing some content after a modifier. 215 // 3. Typing some content after make a visible selection. 216 startedTyping = !this.typing 217 || ( isContent && ( wasModifier || wasReset ) ); 218 219 if ( startedTyping || modifierSnapshot ) 220 { 221 var beforeTypeImage = new Image( this.editor ); 222 223 // Use setTimeout, so we give the necessary time to the 224 // browser to insert the character into the DOM. 225 CKEDITOR.tools.setTimeout( function() 226 { 227 var currentSnapshot = this.editor.getSnapshot(); 228 229 // In IE, we need to remove the expando attributes. 230 if ( CKEDITOR.env.ie ) 231 currentSnapshot = currentSnapshot.replace( /\s+_cke_expando=".*?"/g, '' ); 232 233 if ( beforeTypeImage.contents != currentSnapshot ) 234 { 235 // This's a special save, with specified snapshot 236 // and without auto 'fireChange'. 237 if ( !this.save( false, beforeTypeImage, false ) ) 238 // Drop future snapshots. 239 this.snapshots.splice( this.index + 1, this.snapshots.length - this.index - 1 ); 240 241 this.hasUndo = true; 242 this.hasRedo = false; 243 244 this.typesCount = 1; 245 this.modifiersCount = 1; 246 247 this.onChange(); 248 } 249 }, 250 0, this 251 ); 252 } 253 254 this.lastKeystroke = keystroke; 255 // Create undo snap after typed too much (over 25 times). 256 if ( isModifier ) 257 { 258 this.typesCount = 0; 259 this.modifiersCount++; 260 261 if ( this.modifiersCount > 25 ) 262 { 263 this.save(); 264 this.modifiersCount = 1; 265 } 266 } 267 else if ( !isReset ) 268 { 269 this.modifiersCount = 0; 270 this.typesCount++; 271 272 if ( this.typesCount > 25 ) 273 { 274 this.save(); 275 this.typesCount = 1; 276 } 277 } 278 279 this.typing = true; 280 }, 281 282 reset : function() // Reset the undo stack. 283 { 284 /** 285 * Remember last pressed key. 286 */ 287 this.lastKeystroke = 0; 288 289 /** 290 * Stack for all the undo and redo snapshots, they're always created/removed 291 * in consistency. 292 */ 293 this.snapshots = []; 294 295 /** 296 * Current snapshot history index. 297 */ 298 this.index = -1; 299 300 this.limit = this.editor.config.undoStackSize; 301 302 this.currentImage = null; 303 304 this.hasUndo = false; 305 this.hasRedo = false; 306 307 this.resetType(); 308 }, 309 310 /** 311 * Reset all states about typing. 312 * @see UndoManager.type 313 */ 314 resetType : function() 315 { 316 this.typing = false; 317 delete this.lastKeystroke; 318 this.typesCount = 0; 319 this.modifiersCount = 0; 320 }, 321 fireChange : function() 322 { 323 this.hasUndo = !!this.getNextImage( true ); 324 this.hasRedo = !!this.getNextImage( false ); 325 // Reset typing 326 this.resetType(); 327 this.onChange(); 328 }, 329 330 /** 331 * Save a snapshot of document image for later retrieve. 332 */ 333 save : function( onContentOnly, image, autoFireChange ) 334 { 335 var snapshots = this.snapshots; 336 337 // Get a content image. 338 if ( !image ) 339 image = new Image( this.editor ); 340 341 // Check if this is a duplicate. In such case, do nothing. 342 if ( this.currentImage && image.equals( this.currentImage, onContentOnly ) ) 343 return false; 344 345 // Drop future snapshots. 346 snapshots.splice( this.index + 1, snapshots.length - this.index - 1 ); 347 348 // If we have reached the limit, remove the oldest one. 349 if ( snapshots.length == this.limit ) 350 snapshots.shift(); 351 352 // Add the new image, updating the current index. 353 this.index = snapshots.push( image ) - 1; 354 355 this.currentImage = image; 356 357 if ( autoFireChange !== false ) 358 this.fireChange(); 359 return true; 360 }, 361 362 restoreImage : function( image ) 363 { 364 this.editor.loadSnapshot( image.contents ); 365 366 if ( image.bookmarks ) 367 this.editor.getSelection().selectBookmarks( image.bookmarks ); 368 else if ( CKEDITOR.env.ie ) 369 { 370 // IE BUG: If I don't set the selection to *somewhere* after setting 371 // document contents, then IE would create an empty paragraph at the bottom 372 // the next time the document is modified. 373 var $range = this.editor.document.getBody().$.createTextRange(); 374 $range.collapse( true ); 375 $range.select(); 376 } 377 378 this.index = image.index; 379 380 this.currentImage = image; 381 382 this.fireChange(); 383 }, 384 385 // Get the closest available image. 386 getNextImage : function( isUndo ) 387 { 388 var snapshots = this.snapshots, 389 currentImage = this.currentImage, 390 image, i; 391 392 if ( currentImage ) 393 { 394 if ( isUndo ) 395 { 396 for ( i = this.index - 1 ; i >= 0 ; i-- ) 397 { 398 image = snapshots[ i ]; 399 if ( !currentImage.equals( image, true ) ) 400 { 401 image.index = i; 402 return image; 403 } 404 } 405 } 406 else 407 { 408 for ( i = this.index + 1 ; i < snapshots.length ; i++ ) 409 { 410 image = snapshots[ i ]; 411 if ( !currentImage.equals( image, true ) ) 412 { 413 image.index = i; 414 return image; 415 } 416 } 417 } 418 } 419 420 return null; 421 }, 422 423 /** 424 * Check the current redo state. 425 * @return {Boolean} Whether the document has previous state to 426 * retrieve. 427 */ 428 redoable : function() 429 { 430 return this.enabled && this.hasRedo; 431 }, 432 433 /** 434 * Check the current undo state. 435 * @return {Boolean} Whether the document has future state to restore. 436 */ 437 undoable : function() 438 { 439 return this.enabled && this.hasUndo; 440 }, 441 442 /** 443 * Perform undo on current index. 444 */ 445 undo : function() 446 { 447 if ( this.undoable() ) 448 { 449 this.save( true ); 450 451 var image = this.getNextImage( true ); 452 if ( image ) 453 return this.restoreImage( image ), true; 454 } 455 456 return false; 457 }, 458 459 /** 460 * Perform redo on current index. 461 */ 462 redo : function() 463 { 464 if ( this.redoable() ) 465 { 466 // Try to save. If no changes have been made, the redo stack 467 // will not change, so it will still be redoable. 468 this.save( true ); 469 470 // If instead we had changes, we can't redo anymore. 471 if ( this.redoable() ) 472 { 473 var image = this.getNextImage( false ); 474 if ( image ) 475 return this.restoreImage( image ), true; 476 } 477 } 478 479 return false; 480 } 481 }; 482 })(); 483 484 CKEDITOR.config.undoStackSize = 20; 485