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