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