1 /* 2 Copyright (c) 2003-2012, 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( evt ) 68 { 69 undoManager.save( evt.data && evt.data.contentOnly ); 70 }); 71 72 // Registering keydown on every document recreation.(#3844) 73 editor.on( 'contentDom', function() 74 { 75 editor.document.on( 'keydown', function( event ) 76 { 77 // Do not capture CTRL hotkeys. 78 if ( !event.data.$.ctrlKey && !event.data.$.metaKey ) 79 undoManager.type( event ); 80 }); 81 }); 82 83 // Always save an undo snapshot - the previous mode might have 84 // changed editor contents. 85 editor.on( 'beforeModeUnload', function() 86 { 87 editor.mode == 'wysiwyg' && undoManager.save( true ); 88 }); 89 90 // Make the undo manager available only in wysiwyg mode. 91 editor.on( 'mode', function() 92 { 93 undoManager.enabled = editor.readOnly ? false : editor.mode == 'wysiwyg'; 94 undoManager.onChange(); 95 }); 96 97 editor.ui.addButton( 'Undo', 98 { 99 label : editor.lang.undo, 100 command : 'undo' 101 }); 102 103 editor.ui.addButton( 'Redo', 104 { 105 label : editor.lang.redo, 106 command : 'redo' 107 }); 108 109 editor.resetUndo = function() 110 { 111 // Reset the undo stack. 112 undoManager.reset(); 113 114 // Create the first image. 115 editor.fire( 'saveSnapshot' ); 116 }; 117 118 /** 119 * Amend the top of undo stack (last undo image) with the current DOM changes. 120 * @name CKEDITOR.editor#updateUndo 121 * @example 122 * function() 123 * { 124 * editor.fire( 'saveSnapshot' ); 125 * editor.document.body.append(...); 126 * // Make new changes following the last undo snapshot part of it. 127 * editor.fire( 'updateSnapshot' ); 128 * ... 129 * } 130 */ 131 editor.on( 'updateSnapshot', function() 132 { 133 if ( undoManager.currentImage ) 134 undoManager.update(); 135 }); 136 } 137 }); 138 139 CKEDITOR.plugins.undo = {}; 140 141 /** 142 * Undo snapshot which represents the current document status. 143 * @name CKEDITOR.plugins.undo.Image 144 * @param editor The editor instance on which the image is created. 145 */ 146 var Image = CKEDITOR.plugins.undo.Image = function( editor ) 147 { 148 this.editor = editor; 149 150 editor.fire( 'beforeUndoImage' ); 151 152 var contents = editor.getSnapshot(), 153 selection = contents && editor.getSelection(); 154 155 // In IE, we need to remove the expando attributes. 156 CKEDITOR.env.ie && contents && ( contents = contents.replace( /\s+data-cke-expando=".*?"/g, '' ) ); 157 158 this.contents = contents; 159 this.bookmarks = selection && selection.createBookmarks2( true ); 160 161 editor.fire( 'afterUndoImage' ); 162 }; 163 164 // Attributes that browser may changing them when setting via innerHTML. 165 var protectedAttrs = /\b(?:href|src|name)="[^"]*?"/gi; 166 167 Image.prototype = 168 { 169 equals : function( otherImage, contentOnly ) 170 { 171 172 var thisContents = this.contents, 173 otherContents = otherImage.contents; 174 175 // For IE6/7 : Comparing only the protected attribute values but not the original ones.(#4522) 176 if ( CKEDITOR.env.ie && ( CKEDITOR.env.ie7Compat || CKEDITOR.env.ie6Compat ) ) 177 { 178 thisContents = thisContents.replace( protectedAttrs, '' ); 179 otherContents = otherContents.replace( protectedAttrs, '' ); 180 } 181 182 if ( thisContents != otherContents ) 183 return false; 184 185 if ( contentOnly ) 186 return true; 187 188 var bookmarksA = this.bookmarks, 189 bookmarksB = otherImage.bookmarks; 190 191 if ( bookmarksA || bookmarksB ) 192 { 193 if ( !bookmarksA || !bookmarksB || bookmarksA.length != bookmarksB.length ) 194 return false; 195 196 for ( var i = 0 ; i < bookmarksA.length ; i++ ) 197 { 198 var bookmarkA = bookmarksA[ i ], 199 bookmarkB = bookmarksB[ i ]; 200 201 if ( 202 bookmarkA.startOffset != bookmarkB.startOffset || 203 bookmarkA.endOffset != bookmarkB.endOffset || 204 !CKEDITOR.tools.arrayCompare( bookmarkA.start, bookmarkB.start ) || 205 !CKEDITOR.tools.arrayCompare( bookmarkA.end, bookmarkB.end ) ) 206 { 207 return false; 208 } 209 } 210 } 211 212 return true; 213 } 214 }; 215 216 /** 217 * @constructor Main logic for Redo/Undo feature. 218 */ 219 function UndoManager( editor ) 220 { 221 this.editor = editor; 222 223 // Reset the undo stack. 224 this.reset(); 225 } 226 227 228 var editingKeyCodes = { /*Backspace*/ 8:1, /*Delete*/ 46:1 }, 229 modifierKeyCodes = { /*Shift*/ 16:1, /*Ctrl*/ 17:1, /*Alt*/ 18:1 }, 230 navigationKeyCodes = { 37:1, 38:1, 39:1, 40:1 }; // Arrows: L, T, R, B 231 232 UndoManager.prototype = 233 { 234 /** 235 * Process undo system regard keystrikes. 236 * @param {CKEDITOR.dom.event} event 237 */ 238 type : function( event ) 239 { 240 var keystroke = event && event.data.getKey(), 241 isModifierKey = keystroke in modifierKeyCodes, 242 isEditingKey = keystroke in editingKeyCodes, 243 wasEditingKey = this.lastKeystroke in editingKeyCodes, 244 sameAsLastEditingKey = isEditingKey && keystroke == this.lastKeystroke, 245 // Keystrokes which navigation through contents. 246 isReset = keystroke in navigationKeyCodes, 247 wasReset = this.lastKeystroke in navigationKeyCodes, 248 249 // Keystrokes which just introduce new contents. 250 isContent = ( !isEditingKey && !isReset ), 251 252 // Create undo snap for every different modifier key. 253 modifierSnapshot = ( isEditingKey && !sameAsLastEditingKey ), 254 // Create undo snap on the following cases: 255 // 1. Just start to type . 256 // 2. Typing some content after a modifier. 257 // 3. Typing some content after make a visible selection. 258 startedTyping = !( isModifierKey || this.typing ) 259 || ( isContent && ( wasEditingKey || wasReset ) ); 260 261 if ( startedTyping || modifierSnapshot ) 262 { 263 var beforeTypeImage = new Image( this.editor ), 264 beforeTypeCount = this.snapshots.length; 265 266 // Use setTimeout, so we give the necessary time to the 267 // browser to insert the character into the DOM. 268 CKEDITOR.tools.setTimeout( function() 269 { 270 var currentSnapshot = this.editor.getSnapshot(); 271 272 // In IE, we need to remove the expando attributes. 273 if ( CKEDITOR.env.ie ) 274 currentSnapshot = currentSnapshot.replace( /\s+data-cke-expando=".*?"/g, '' ); 275 276 // If changes have taken place, while not been captured yet (#8459), 277 // compensate the snapshot. 278 if ( beforeTypeImage.contents != currentSnapshot && 279 beforeTypeCount == this.snapshots.length ) 280 { 281 // It's safe to now indicate typing state. 282 this.typing = true; 283 284 // This's a special save, with specified snapshot 285 // and without auto 'fireChange'. 286 if ( !this.save( false, beforeTypeImage, false ) ) 287 // Drop future snapshots. 288 this.snapshots.splice( this.index + 1, this.snapshots.length - this.index - 1 ); 289 290 this.hasUndo = true; 291 this.hasRedo = false; 292 293 this.typesCount = 1; 294 this.modifiersCount = 1; 295 296 this.onChange(); 297 } 298 }, 299 0, this 300 ); 301 } 302 303 this.lastKeystroke = keystroke; 304 305 // Create undo snap after typed too much (over 25 times). 306 if ( isEditingKey ) 307 { 308 this.typesCount = 0; 309 this.modifiersCount++; 310 311 if ( this.modifiersCount > 25 ) 312 { 313 this.save( false, null, false ); 314 this.modifiersCount = 1; 315 } 316 } 317 else if ( !isReset ) 318 { 319 this.modifiersCount = 0; 320 this.typesCount++; 321 322 if ( this.typesCount > 25 ) 323 { 324 this.save( false, null, false ); 325 this.typesCount = 1; 326 } 327 } 328 329 }, 330 331 reset : function() // Reset the undo stack. 332 { 333 /** 334 * Remember last pressed key. 335 */ 336 this.lastKeystroke = 0; 337 338 /** 339 * Stack for all the undo and redo snapshots, they're always created/removed 340 * in consistency. 341 */ 342 this.snapshots = []; 343 344 /** 345 * Current snapshot history index. 346 */ 347 this.index = -1; 348 349 this.limit = this.editor.config.undoStackSize || 20; 350 351 this.currentImage = null; 352 353 this.hasUndo = false; 354 this.hasRedo = false; 355 356 this.resetType(); 357 }, 358 359 /** 360 * Reset all states about typing. 361 * @see UndoManager.type 362 */ 363 resetType : function() 364 { 365 this.typing = false; 366 delete this.lastKeystroke; 367 this.typesCount = 0; 368 this.modifiersCount = 0; 369 }, 370 fireChange : function() 371 { 372 this.hasUndo = !!this.getNextImage( true ); 373 this.hasRedo = !!this.getNextImage( false ); 374 // Reset typing 375 this.resetType(); 376 this.onChange(); 377 }, 378 379 /** 380 * Save a snapshot of document image for later retrieve. 381 */ 382 save : function( onContentOnly, image, autoFireChange ) 383 { 384 var snapshots = this.snapshots; 385 386 // Get a content image. 387 if ( !image ) 388 image = new Image( this.editor ); 389 390 // Do nothing if it was not possible to retrieve an image. 391 if ( image.contents === false ) 392 return false; 393 394 // Check if this is a duplicate. In such case, do nothing. 395 if ( this.currentImage && image.equals( this.currentImage, onContentOnly ) ) 396 return false; 397 398 // Drop future snapshots. 399 snapshots.splice( this.index + 1, snapshots.length - this.index - 1 ); 400 401 // If we have reached the limit, remove the oldest one. 402 if ( snapshots.length == this.limit ) 403 snapshots.shift(); 404 405 // Add the new image, updating the current index. 406 this.index = snapshots.push( image ) - 1; 407 408 this.currentImage = image; 409 410 if ( autoFireChange !== false ) 411 this.fireChange(); 412 return true; 413 }, 414 415 restoreImage : function( image ) 416 { 417 // Bring editor focused to restore selection. 418 var editor = this.editor, 419 sel; 420 421 if ( image.bookmarks ) 422 { 423 editor.focus(); 424 // Retrieve the selection beforehand. (#8324) 425 sel = editor.getSelection(); 426 } 427 428 this.editor.loadSnapshot( image.contents ); 429 430 if ( image.bookmarks ) 431 sel.selectBookmarks( image.bookmarks ); 432 else if ( CKEDITOR.env.ie ) 433 { 434 // IE BUG: If I don't set the selection to *somewhere* after setting 435 // document contents, then IE would create an empty paragraph at the bottom 436 // the next time the document is modified. 437 var $range = this.editor.document.getBody().$.createTextRange(); 438 $range.collapse( true ); 439 $range.select(); 440 } 441 442 this.index = image.index; 443 444 // Update current image with the actual editor 445 // content, since actualy content may differ from 446 // the original snapshot due to dom change. (#4622) 447 this.update(); 448 this.fireChange(); 449 }, 450 451 // Get the closest available image. 452 getNextImage : function( isUndo ) 453 { 454 var snapshots = this.snapshots, 455 currentImage = this.currentImage, 456 image, i; 457 458 if ( currentImage ) 459 { 460 if ( isUndo ) 461 { 462 for ( i = this.index - 1 ; i >= 0 ; i-- ) 463 { 464 image = snapshots[ i ]; 465 if ( !currentImage.equals( image, true ) ) 466 { 467 image.index = i; 468 return image; 469 } 470 } 471 } 472 else 473 { 474 for ( i = this.index + 1 ; i < snapshots.length ; i++ ) 475 { 476 image = snapshots[ i ]; 477 if ( !currentImage.equals( image, true ) ) 478 { 479 image.index = i; 480 return image; 481 } 482 } 483 } 484 } 485 486 return null; 487 }, 488 489 /** 490 * Check the current redo state. 491 * @return {Boolean} Whether the document has previous state to 492 * retrieve. 493 */ 494 redoable : function() 495 { 496 return this.enabled && this.hasRedo; 497 }, 498 499 /** 500 * Check the current undo state. 501 * @return {Boolean} Whether the document has future state to restore. 502 */ 503 undoable : function() 504 { 505 return this.enabled && this.hasUndo; 506 }, 507 508 /** 509 * Perform undo on current index. 510 */ 511 undo : function() 512 { 513 if ( this.undoable() ) 514 { 515 this.save( true ); 516 517 var image = this.getNextImage( true ); 518 if ( image ) 519 return this.restoreImage( image ), true; 520 } 521 522 return false; 523 }, 524 525 /** 526 * Perform redo on current index. 527 */ 528 redo : function() 529 { 530 if ( this.redoable() ) 531 { 532 // Try to save. If no changes have been made, the redo stack 533 // will not change, so it will still be redoable. 534 this.save( true ); 535 536 // If instead we had changes, we can't redo anymore. 537 if ( this.redoable() ) 538 { 539 var image = this.getNextImage( false ); 540 if ( image ) 541 return this.restoreImage( image ), true; 542 } 543 } 544 545 return false; 546 }, 547 548 /** 549 * Update the last snapshot of the undo stack with the current editor content. 550 */ 551 update : function() 552 { 553 this.snapshots.splice( this.index, 1, ( this.currentImage = new Image( this.editor ) ) ); 554 } 555 }; 556 })(); 557 558 /** 559 * The number of undo steps to be saved. The higher this setting value the more 560 * memory is used for it. 561 * @name CKEDITOR.config.undoStackSize 562 * @type Number 563 * @default 20 564 * @example 565 * config.undoStackSize = 50; 566 */ 567 568 /** 569 * Fired when the editor is about to save an undo snapshot. This event can be 570 * fired by plugins and customizations to make the editor saving undo snapshots. 571 * @name CKEDITOR.editor#saveSnapshot 572 * @event 573 */ 574 575 /** 576 * Fired before an undo image is to be taken. An undo image represents the 577 * editor state at some point. It's saved into an undo store, so the editor is 578 * able to recover the editor state on undo and redo operations. 579 * @name CKEDITOR.editor#beforeUndoImage 580 * @since 3.5.3 581 * @see CKEDITOR.editor#afterUndoImage 582 * @event 583 */ 584 585 /** 586 * Fired after an undo image is taken. An undo image represents the 587 * editor state at some point. It's saved into an undo store, so the editor is 588 * able to recover the editor state on undo and redo operations. 589 * @name CKEDITOR.editor#afterUndoImage 590 * @since 3.5.3 591 * @see CKEDITOR.editor#beforeUndoImage 592 * @event 593 */ 594