      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