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 			// Make the undo manager available only in wysiwyg mode.
 67 			editor.on( 'mode', function()
 68 				{
 69 					if ( editor.mode == 'wysiwyg' )
 70 					{
 71 						if ( !undoManager.enabled )
 72 						{
 73 							undoManager.enabled = true;
 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();
 80 								});
 81
 82 							// Being this the first call, let's get an undo snapshot.
 83 							if ( undoManager.index == -1 )
 84 								undoManager.save();
 85 						}
 86 					}
 87 					else
 88 						undoManager.enabled = false;
 89
 90 					undoManager.onChange();
 91 				});
 92
 93 			editor.ui.addButton( 'Undo',
 94 				{
 95 					label : editor.lang.undo,
 96 					command : 'undo'
 97 				});
 98
 99 			editor.ui.addButton( 'Redo',
100 				{
101 					label : editor.lang.redo,
102 					command : 'redo'
103 				});
104 		}
105 	});
106
107 	// Gets a snapshot image which represent the current document status.
108 	function Image( editor )
109 	{
110 		var selection = editor.getSelection();
111
112 		this.contents	= editor.getSnapshot();
113 		this.bookmarks	= selection && selection.createBookmarks2( true );
114
115 		// In IE, we need to remove the expando attributes.
116 		if ( CKEDITOR.env.ie )
117 			this.contents = this.contents.replace( /\s+_cke_expando=".*?"/g, '' );
118 	}
119
120 	Image.prototype =
121 	{
122 		equals : function( otherImage, contentOnly )
123 		{
124 			if ( this.contents != otherImage.contents )
125 				return false;
126
127 			if ( contentOnly )
128 				return true;
129
130 			var bookmarksA = this.bookmarks,
131 				bookmarksB = otherImage.bookmarks;
132
133 			if ( bookmarksA || bookmarksB )
134 			{
135 				if ( !bookmarksA || !bookmarksB || bookmarksA.length != bookmarksB.length )
136 					return false;
137
138 				for ( var i = 0 ; i < bookmarksA.length ; i++ )
139 				{
140 					var bookmarkA = bookmarksA[ i ],
141 						bookmarkB = bookmarksB[ i ];
142
143 					if (
144 						bookmarkA.startOffset != bookmarkB.startOffset ||
145 						bookmarkA.endOffset != bookmarkB.endOffset ||
146 						!CKEDITOR.tools.arrayCompare( bookmarkA.start, bookmarkB.start ) ||
147 						!CKEDITOR.tools.arrayCompare( bookmarkA.end, bookmarkB.end ) )
148 					{
149 						return false;
150 					}
151 				}
152 			}
153
154 			return true;
155 		}
156 	};
157
158 	/**
159 	 * @constructor Main logic for Redo/Undo feature.
160 	 */
161 	function UndoManager( editor )
162 	{
163 		this.typesCount = 0;
164
165 		this.editor = editor;
166
167 		/**
168 		 * Stack for all the undo and redo snapshots, they're always created/removed
169 		 * in consistency.
170 		 */
171 		this.snapshots = [];
172
173 		/**
174 		 * Current snapshot history index.
175 		 */
176 		this.index = -1;
177
178 		this.limit = editor.config.undoStackSize;
179 	}
180
181 	UndoManager.prototype =
182 	{
183 		type : function()
184 		{
185 			if ( !this.typing )
186 			{
187 				var beforeTypeImage = new Image( this.editor );
188
189 				// Use setTimeout, so we give the necessary time to the
190 				// browser to insert the character into the DOM.
191 				CKEDITOR.tools.setTimeout( function()
192 					{
193 						var currentSnapshot = this.editor.getSnapshot();
194
195 						// In IE, we need to remove the expando attributes.
196 						if ( CKEDITOR.env.ie )
197 							currentSnapshot = currentSnapshot.replace( /\s+_cke_expando=".*?"/g, '' );
198
199 						if ( beforeTypeImage.contents != currentSnapshot )
200 						{
201 							if ( !this.save( false, beforeTypeImage ) )
202 							{
203 								// Drop future snapshots.
204 								this.snapshots.splice( this.index + 1, this.snapshots.length - this.index - 1 );
205 							}
206
207 							this.hasUndo = true;
208 							this.hasRedo = false;
209
210 							this.typesCount = 1;
211 							this.typing = true;
212
213 							this.onChange();
214 						}
215 					},
216 					0, this );
217
218 				return;
219 			}
220
221 			this.typesCount++;
222
223 			if ( this.typesCount > 25 )
224 			{
225 				this.save();
226 				this.typesCount = 1;
227 			}
228
229 			this.typing = true;
230 		},
231
232 		fireChange : function()
233 		{
234 			this.hasUndo = !!this.getNextImage( true );
235 			this.hasRedo = !!this.getNextImage( false );
236
237 			this.typing = false;
238 			this.typesCount = 0;
239
240 			this.onChange();
241 		},
242
243 		/**
244 		 * Save a snapshot of document image for later retrieve.
245 		 */
246 		save : function( onContentOnly, image )
247 		{
248 			var snapshots = this.snapshots;
249
250 			// Get a content image.
251 			if ( !image )
252 				image = new Image( this.editor );
253
254 			// Check if this is a duplicate. In such case, do nothing.
255 			if ( this.currentImage && image.equals( this.currentImage, onContentOnly ) )
256 				return false;
257
258 			// Drop future snapshots.
259 			snapshots.splice( this.index + 1, snapshots.length - this.index - 1 );
260
261 			// If we have reached the limit, remove the oldest one.
262 			if ( snapshots.length == this.limit )
263 				snapshots.shift();
264
265 			// Add the new image, updating the current index.
266 			this.index = snapshots.push( image ) - 1;
267
268 			this.currentImage = image;
269
270 			this.fireChange();
271
272 			return true;
273 		},
274
275 		restoreImage : function( image )
276 		{
277 			this.editor.loadSnapshot( image.contents );
278
279 			if ( image.bookmarks )
280 				this.editor.getSelection().selectBookmarks( image.bookmarks );
281
282 			this.index = image.index;
283
284 			this.currentImage = image;
285
286 			this.fireChange();
287 		},
288
289 		// Get the closest available image.
290 		getNextImage : function( isUndo )
291 		{
292 			var snapshots = this.snapshots,
293 				currentImage = this.currentImage,
294 				image, i;
295
296 			if ( currentImage )
297 			{
298 				if ( isUndo )
299 				{
300 					for ( i = this.index - 1 ; i >= 0 ; i-- )
301 					{
302 						image = snapshots[ i ];
303 						if ( !currentImage.equals( image, true ) )
304 						{
305 							image.index = i;
306 							return image;
307 						}
308 					}
309 				}
310 				else
311 				{
312 					for ( i = this.index + 1 ; i < snapshots.length ; i++ )
313 					{
314 						image = snapshots[ i ];
315 						if ( !currentImage.equals( image, true ) )
316 						{
317 							image.index = i;
318 							return image;
319 						}
320 					}
321 				}
322 			}
323
324 			return null;
325 		},
326
327 		/**
328 		 * Check the current redo state.
329 		 * @return {Boolean} Whether the document has previous state to
330 		 *		retrieve.
331 		 */
332 		redoable : function()
333 		{
334 			return this.enabled && this.hasRedo;
335 		},
336
337 		/**
338 		 * Check the current undo state.
339 		 * @return {Boolean} Whether the document has future state to restore.
340 		 */
341 		undoable : function()
342 		{
343 			return this.enabled && this.hasUndo;
344 		},
345
346 		/**
347 		 * Perform undo on current index.
348 		 */
349 		undo : function()
350 		{
351 			if ( this.undoable() )
352 			{
353 				this.save( true );
354
355 				var image = this.getNextImage( true );
356 				if ( image )
357 					return this.restoreImage( image ), true;
358 			}
359
360 			return false;
361 		},
362
363 		/**
364 		 * Perform redo on current index.
365 		 */
366 		redo : function()
367 		{
368 			if ( this.redoable() )
369 			{
370 				// Try to save. If no changes have been made, the redo stack
371 				// will not change, so it will still be redoable.
372 				this.save( true );
373
374 				// If instead we had changes, we can't redo anymore.
375 				if ( this.redoable() )
376 				{
377 					var image = this.getNextImage( false );
378 					if ( image )
379 						return this.restoreImage( image ), true;
380 				}
381 			}
382
383 			return false;
384 		}
385 	};
386 })();
387
388 CKEDITOR.config.undoStackSize = 20;
389