|
1 /** |
|
2 * UndoManager.js |
|
3 * |
|
4 * Copyright, Moxiecode Systems AB |
|
5 * Released under LGPL License. |
|
6 * |
|
7 * License: http://www.tinymce.com/license |
|
8 * Contributing: http://www.tinymce.com/contributing |
|
9 */ |
|
10 |
|
11 /** |
|
12 * This class handles the undo/redo history levels for the editor. Since the build in undo/redo has major drawbacks a custom one was needed. |
|
13 * |
|
14 * @class tinymce.UndoManager |
|
15 */ |
|
16 define("tinymce/UndoManager", [ |
|
17 "tinymce/util/VK", |
|
18 "tinymce/Env", |
|
19 "tinymce/util/Tools", |
|
20 "tinymce/html/SaxParser" |
|
21 ], function(VK, Env, Tools, SaxParser) { |
|
22 var trim = Tools.trim, trimContentRegExp; |
|
23 |
|
24 trimContentRegExp = new RegExp([ |
|
25 '<span[^>]+data-mce-bogus[^>]+>[\u200B\uFEFF]+<\\/span>', // Trim bogus spans like caret containers |
|
26 '\\s?data-mce-selected="[^"]+"' // Trim temporaty data-mce prefixed attributes like data-mce-selected |
|
27 ].join('|'), 'gi'); |
|
28 |
|
29 return function(editor) { |
|
30 var self = this, index = 0, data = [], beforeBookmark, isFirstTypedCharacter, locks = 0; |
|
31 |
|
32 /** |
|
33 * Returns a trimmed version of the editor contents to be used for the undo level. This |
|
34 * will remove any data-mce-bogus="all" marked elements since these are used for UI it will also |
|
35 * remove the data-mce-selected attributes used for selection of objects and caret containers. |
|
36 * It will keep all data-mce-bogus="1" elements since these can be used to place the caret etc and will |
|
37 * be removed by the serialization logic when you save. |
|
38 * |
|
39 * @private |
|
40 * @return {String} HTML contents of the editor excluding some internal bogus elements. |
|
41 */ |
|
42 function getContent() { |
|
43 var content = editor.getContent({format: 'raw', no_events: 1}); |
|
44 var bogusAllRegExp = /<(\w+) [^>]*data-mce-bogus="all"[^>]*>/g; |
|
45 var endTagIndex, index, matchLength, matches, shortEndedElements, schema = editor.schema; |
|
46 |
|
47 content = content.replace(trimContentRegExp, ''); |
|
48 shortEndedElements = schema.getShortEndedElements(); |
|
49 |
|
50 // Remove all bogus elements marked with "all" |
|
51 while ((matches = bogusAllRegExp.exec(content))) { |
|
52 index = bogusAllRegExp.lastIndex; |
|
53 matchLength = matches[0].length; |
|
54 |
|
55 if (shortEndedElements[matches[1]]) { |
|
56 endTagIndex = index; |
|
57 } else { |
|
58 endTagIndex = SaxParser.findEndTag(schema, content, index); |
|
59 } |
|
60 |
|
61 content = content.substring(0, index - matchLength) + content.substring(endTagIndex); |
|
62 bogusAllRegExp.lastIndex = index - matchLength; |
|
63 } |
|
64 |
|
65 return trim(content); |
|
66 } |
|
67 |
|
68 function setDirty(state) { |
|
69 editor.isNotDirty = !state; |
|
70 } |
|
71 |
|
72 function addNonTypingUndoLevel(e) { |
|
73 self.typing = false; |
|
74 self.add({}, e); |
|
75 } |
|
76 |
|
77 // Add initial undo level when the editor is initialized |
|
78 editor.on('init', function() { |
|
79 self.add(); |
|
80 }); |
|
81 |
|
82 // Get position before an execCommand is processed |
|
83 editor.on('BeforeExecCommand', function(e) { |
|
84 var cmd = e.command; |
|
85 |
|
86 if (cmd != 'Undo' && cmd != 'Redo' && cmd != 'mceRepaint') { |
|
87 self.beforeChange(); |
|
88 } |
|
89 }); |
|
90 |
|
91 // Add undo level after an execCommand call was made |
|
92 editor.on('ExecCommand', function(e) { |
|
93 var cmd = e.command; |
|
94 |
|
95 if (cmd != 'Undo' && cmd != 'Redo' && cmd != 'mceRepaint') { |
|
96 addNonTypingUndoLevel(e); |
|
97 } |
|
98 }); |
|
99 |
|
100 editor.on('ObjectResizeStart', function() { |
|
101 self.beforeChange(); |
|
102 }); |
|
103 |
|
104 editor.on('SaveContent ObjectResized blur', addNonTypingUndoLevel); |
|
105 editor.on('DragEnd', addNonTypingUndoLevel); |
|
106 |
|
107 editor.on('KeyUp', function(e) { |
|
108 var keyCode = e.keyCode; |
|
109 |
|
110 if ((keyCode >= 33 && keyCode <= 36) || (keyCode >= 37 && keyCode <= 40) || keyCode == 45 || keyCode == 13 || e.ctrlKey) { |
|
111 addNonTypingUndoLevel(); |
|
112 editor.nodeChanged(); |
|
113 } |
|
114 |
|
115 if (keyCode == 46 || keyCode == 8 || (Env.mac && (keyCode == 91 || keyCode == 93))) { |
|
116 editor.nodeChanged(); |
|
117 } |
|
118 |
|
119 // Fire a TypingUndo event on the first character entered |
|
120 if (isFirstTypedCharacter && self.typing) { |
|
121 // Make it dirty if the content was changed after typing the first character |
|
122 if (!editor.isDirty()) { |
|
123 setDirty(data[0] && getContent() != data[0].content); |
|
124 |
|
125 // Fire initial change event |
|
126 if (!editor.isNotDirty) { |
|
127 editor.fire('change', {level: data[0], lastLevel: null}); |
|
128 } |
|
129 } |
|
130 |
|
131 editor.fire('TypingUndo'); |
|
132 isFirstTypedCharacter = false; |
|
133 editor.nodeChanged(); |
|
134 } |
|
135 }); |
|
136 |
|
137 editor.on('KeyDown', function(e) { |
|
138 var keyCode = e.keyCode; |
|
139 |
|
140 // Is caracter positon keys left,right,up,down,home,end,pgdown,pgup,enter |
|
141 if ((keyCode >= 33 && keyCode <= 36) || (keyCode >= 37 && keyCode <= 40) || keyCode == 45) { |
|
142 if (self.typing) { |
|
143 addNonTypingUndoLevel(e); |
|
144 } |
|
145 |
|
146 return; |
|
147 } |
|
148 |
|
149 // If key isn't Ctrl+Alt/AltGr |
|
150 var modKey = (e.ctrlKey && !e.altKey) || e.metaKey; |
|
151 if ((keyCode < 16 || keyCode > 20) && keyCode != 224 && keyCode != 91 && !self.typing && !modKey) { |
|
152 self.beforeChange(); |
|
153 self.typing = true; |
|
154 self.add({}, e); |
|
155 isFirstTypedCharacter = true; |
|
156 } |
|
157 }); |
|
158 |
|
159 editor.on('MouseDown', function(e) { |
|
160 if (self.typing) { |
|
161 addNonTypingUndoLevel(e); |
|
162 } |
|
163 }); |
|
164 |
|
165 // Add keyboard shortcuts for undo/redo keys |
|
166 editor.addShortcut('meta+z', '', 'Undo'); |
|
167 editor.addShortcut('meta+y,meta+shift+z', '', 'Redo'); |
|
168 |
|
169 editor.on('AddUndo Undo Redo ClearUndos', function(e) { |
|
170 if (!e.isDefaultPrevented()) { |
|
171 editor.nodeChanged(); |
|
172 } |
|
173 }); |
|
174 |
|
175 /*eslint consistent-this:0 */ |
|
176 self = { |
|
177 // Explose for debugging reasons |
|
178 data: data, |
|
179 |
|
180 /** |
|
181 * State if the user is currently typing or not. This will add a typing operation into one undo |
|
182 * level instead of one new level for each keystroke. |
|
183 * |
|
184 * @field {Boolean} typing |
|
185 */ |
|
186 typing: false, |
|
187 |
|
188 /** |
|
189 * Stores away a bookmark to be used when performing an undo action so that the selection is before |
|
190 * the change has been made. |
|
191 * |
|
192 * @method beforeChange |
|
193 */ |
|
194 beforeChange: function() { |
|
195 if (!locks) { |
|
196 beforeBookmark = editor.selection.getBookmark(2, true); |
|
197 } |
|
198 }, |
|
199 |
|
200 /** |
|
201 * Adds a new undo level/snapshot to the undo list. |
|
202 * |
|
203 * @method add |
|
204 * @param {Object} level Optional undo level object to add. |
|
205 * @param {DOMEvent} Event Optional event responsible for the creation of the undo level. |
|
206 * @return {Object} Undo level that got added or null it a level wasn't needed. |
|
207 */ |
|
208 add: function(level, event) { |
|
209 var i, settings = editor.settings, lastLevel; |
|
210 |
|
211 level = level || {}; |
|
212 level.content = getContent(); |
|
213 |
|
214 if (locks || editor.removed) { |
|
215 return null; |
|
216 } |
|
217 |
|
218 lastLevel = data[index]; |
|
219 if (editor.fire('BeforeAddUndo', {level: level, lastLevel: lastLevel, originalEvent: event}).isDefaultPrevented()) { |
|
220 return null; |
|
221 } |
|
222 |
|
223 // Add undo level if needed |
|
224 if (lastLevel && lastLevel.content == level.content) { |
|
225 return null; |
|
226 } |
|
227 |
|
228 // Set before bookmark on previous level |
|
229 if (data[index]) { |
|
230 data[index].beforeBookmark = beforeBookmark; |
|
231 } |
|
232 |
|
233 // Time to compress |
|
234 if (settings.custom_undo_redo_levels) { |
|
235 if (data.length > settings.custom_undo_redo_levels) { |
|
236 for (i = 0; i < data.length - 1; i++) { |
|
237 data[i] = data[i + 1]; |
|
238 } |
|
239 |
|
240 data.length--; |
|
241 index = data.length; |
|
242 } |
|
243 } |
|
244 |
|
245 // Get a non intrusive normalized bookmark |
|
246 level.bookmark = editor.selection.getBookmark(2, true); |
|
247 |
|
248 // Crop array if needed |
|
249 if (index < data.length - 1) { |
|
250 data.length = index + 1; |
|
251 } |
|
252 |
|
253 data.push(level); |
|
254 index = data.length - 1; |
|
255 |
|
256 var args = {level: level, lastLevel: lastLevel, originalEvent: event}; |
|
257 |
|
258 editor.fire('AddUndo', args); |
|
259 |
|
260 if (index > 0) { |
|
261 setDirty(true); |
|
262 editor.fire('change', args); |
|
263 } |
|
264 |
|
265 return level; |
|
266 }, |
|
267 |
|
268 /** |
|
269 * Undoes the last action. |
|
270 * |
|
271 * @method undo |
|
272 * @return {Object} Undo level or null if no undo was performed. |
|
273 */ |
|
274 undo: function() { |
|
275 var level; |
|
276 |
|
277 if (self.typing) { |
|
278 self.add(); |
|
279 self.typing = false; |
|
280 } |
|
281 |
|
282 if (index > 0) { |
|
283 level = data[--index]; |
|
284 |
|
285 // Undo to first index then set dirty state to false |
|
286 if (index === 0) { |
|
287 setDirty(false); |
|
288 } |
|
289 |
|
290 editor.setContent(level.content, {format: 'raw'}); |
|
291 editor.selection.moveToBookmark(level.beforeBookmark); |
|
292 |
|
293 editor.fire('undo', {level: level}); |
|
294 } |
|
295 |
|
296 return level; |
|
297 }, |
|
298 |
|
299 /** |
|
300 * Redoes the last action. |
|
301 * |
|
302 * @method redo |
|
303 * @return {Object} Redo level or null if no redo was performed. |
|
304 */ |
|
305 redo: function() { |
|
306 var level; |
|
307 |
|
308 if (index < data.length - 1) { |
|
309 level = data[++index]; |
|
310 |
|
311 editor.setContent(level.content, {format: 'raw'}); |
|
312 editor.selection.moveToBookmark(level.bookmark); |
|
313 setDirty(true); |
|
314 |
|
315 editor.fire('redo', {level: level}); |
|
316 } |
|
317 |
|
318 return level; |
|
319 }, |
|
320 |
|
321 /** |
|
322 * Removes all undo levels. |
|
323 * |
|
324 * @method clear |
|
325 */ |
|
326 clear: function() { |
|
327 data = []; |
|
328 index = 0; |
|
329 self.typing = false; |
|
330 editor.fire('ClearUndos'); |
|
331 }, |
|
332 |
|
333 /** |
|
334 * Returns true/false if the undo manager has any undo levels. |
|
335 * |
|
336 * @method hasUndo |
|
337 * @return {Boolean} true/false if the undo manager has any undo levels. |
|
338 */ |
|
339 hasUndo: function() { |
|
340 // Has undo levels or typing and content isn't the same as the initial level |
|
341 return index > 0 || (self.typing && data[0] && getContent() != data[0].content); |
|
342 }, |
|
343 |
|
344 /** |
|
345 * Returns true/false if the undo manager has any redo levels. |
|
346 * |
|
347 * @method hasRedo |
|
348 * @return {Boolean} true/false if the undo manager has any redo levels. |
|
349 */ |
|
350 hasRedo: function() { |
|
351 return index < data.length - 1 && !this.typing; |
|
352 }, |
|
353 |
|
354 /** |
|
355 * Executes the specified function in an undo transation. The selection |
|
356 * before the modification will be stored to the undo stack and if the DOM changes |
|
357 * it will add a new undo level. Any methods within the transation that adds undo levels will |
|
358 * be ignored. So a transation can include calls to execCommand or editor.insertContent. |
|
359 * |
|
360 * @method transact |
|
361 * @param {function} callback Function to execute dom manipulation logic in. |
|
362 */ |
|
363 transact: function(callback) { |
|
364 self.beforeChange(); |
|
365 |
|
366 try { |
|
367 locks++; |
|
368 callback(); |
|
369 } finally { |
|
370 locks--; |
|
371 } |
|
372 |
|
373 self.add(); |
|
374 } |
|
375 }; |
|
376 |
|
377 return self; |
|
378 }; |
|
379 }); |