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